mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
dbeaver/pro#6283 Add Combobox component (#3597)
* dbeaver/pro#6283 feat: add simple Combobox implementation It has fuzzy search inside to filter items. * dbeaver/pro#6283 feat: implement Combobox component with search functionality and update related files * dbeaver/pro#6283 refactor: remove unused onChange prop * dbeaver/pro#6283 style: update imports in Combobox component * dbeaver/pro#6283 feat: update styles, add popoverClassName prop * dbeaver/pro#6283 refactor: keep Combobox composable for better control * dbeaver/pro#6283 refactor: SRP in Combobox, split provider and components * dbeaver/pro#6283 feat: implement advanced search see AdvancedSearch example in stories * dbeaver/pro#6283 refactor: use strict comparison * dbeaver/pro#6283 refactor: replace fuzzy search with standard string search * dbeaver/pro#6283 refactor: simplify Combobox component * dbeaver/pro#6283 feat: add value formatter for ComboboxInput It might be useful for cases when value is not in user friendly format. Combobox doesn't support such transformations from the box Updated examples, added valueFormatter to advanced search, added MultiSelect example * dbeaver/pro#6283 feat: sync styles with select * dbeaver/pro#6283 fix: formatter * dbeaver/pro#6283 fix: respect the user's reduce motion preference * dbeaver/pro#6283 refactor: simplify component remove search context, use some built-in features for input value manipulations and for auto selection of the first item * dbeaver/pro#6283 refactor: use tailwind for Field styles * dbeaver/pro#6283 fix: should open all options on first interaction with any value * dbeaver/pro#6283 refactor: strip Combobox component Due to difficulties with items and search value managing in Combobox using only native combobox store, it was decided to move all that logic to CloudBeaver combobox component. This commit removes all the logic from UIKit Combobox and partially restores it in Cloudbeaver Combobox * dbeaver/pro#6283 feat: show no results when no items * dbeaver/pro#6283 feat: restore input to the last valid value * dbeaver/pro#6283 refactor: simplify restore input logic * dbeaver/pro#6283 refactor: reset input value on blur * dbeaver/pro#6283 refactor: don't sent input value on item click * dbeaver/pro#6283 refactor: remove getComputed for filteredItems getComputed will not work as expected for not primitive values( for example, for arrays) * dbeaver/pro#6283 refactor: remove getComputed for filteredItems * dbeaver/pro#6283 style: update z-index for ComboboxPopover component our backdrop component has 999 z-index(weird?), we need more to show popover in modals * dbeaver/pro#6283 feat: put CB Combobox near the component, fix layers issue * dbeaver/pro#6283 fix: radio title wrap * dbeaver/pro#6283 fix: use the same letter spacing as in other inputs * dbeaver/pro#6283 fix:combobox item styles * dbeaver/pro#6283 fix: clear value on click this fix is for situation when user started to type a word, found an option and clicked on it. Previously we didn't clear value and just setSelectedValue on click. This fix clears value as it happens onBlur and we use the value from selectedItem. --------- Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com>
This commit is contained in:
co-authored by
mr-anton-t
parent
7acbbfe4ec
commit
c3be008ba2
@@ -47,12 +47,15 @@
|
||||
outline-offset: var(--dbv-kit-control-outline-offset);
|
||||
outline-width: var(--dbv-kit-control-outline-width);
|
||||
outline-color: var(--dbv-kit-control-outline-color);
|
||||
transition-property: stroke-dashoffset;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
stroke-dasharray: 20;
|
||||
stroke-dashoffset: 20;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition-property: stroke-dashoffset;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
&[data-focus-visible] {
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
@import './_base.css';
|
||||
|
||||
@layer base {
|
||||
.dbv-kit-combobox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
height: var(--dbv-kit-combobox-height);
|
||||
padding-inline: var(--dbv-kit-combobox-padding-inline);
|
||||
background-color: var(--dbv-kit-combobox-background);
|
||||
color: var(--dbv-kit-combobox-foreground);
|
||||
border-color: var(--dbv-kit-combobox-border-color);
|
||||
border-radius: var(--dbv-kit-combobox-border-radius);
|
||||
border-width: var(--dbv-kit-combobox-border-width);
|
||||
border-style: var(--dbv-kit-combobox-border-style);
|
||||
font-weight: var(--dbv-kit-combobox-font-weight);
|
||||
font-size: var(--dbv-kit-combobox-font-size);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
border-color: var(--dbv-kit-combobox-hover-border-color);
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
opacity: var(--dbv-kit-control-disabled-opacity);
|
||||
}
|
||||
&[data-focus-visible],
|
||||
&:focus-visible {
|
||||
outline-offset: var(--dbv-kit-control-outline-offset);
|
||||
outline: var(--dbv-kit-control-outline-width) solid var(--dbv-kit-control-outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-combobox__popover {
|
||||
z-index: 1000;
|
||||
min-width: 100%;
|
||||
max-height: var(--dbv-kit-combobox-popover-height);
|
||||
overflow-y: auto;
|
||||
background-color: var(--dbv-kit-combobox-popover-background);
|
||||
color: var(--dbv-kit-combobox-popover-foreground);
|
||||
border-color: var(--dbv-kit-combobox-border-color);
|
||||
border-radius: var(--dbv-kit-combobox-border-radius);
|
||||
border-width: var(--dbv-kit-combobox-border-width);
|
||||
border-style: var(--dbv-kit-combobox-border-style);
|
||||
box-shadow: var(--dbv-kit-combobox-popover-shadow);
|
||||
outline: 0;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
opacity: 0;
|
||||
transform-origin: top;
|
||||
scale: 0.95;
|
||||
translate: 0 -0.5rem;
|
||||
transition-property: opacity, scale, translate;
|
||||
transition-timing-function: var(--tw-ease-in-out);
|
||||
transition-duration: 150ms;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
translate: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-combobox__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-inline: var(--dbv-kit-combobox-item-padding-inline);
|
||||
padding-block: var(--dbv-kit-combobox-item-padding-block);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
opacity: var(--dbv-kit-control-disabled-opacity);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background-color: var(--dbv-kit-combobox-item-selected-background);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[data-focus-visible],
|
||||
&[data-active-item] {
|
||||
color: var(--dbv-kit-combobox-item-hover-foreground);
|
||||
background-color: var(--dbv-kit-combobox-item-hover-background);
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-combobox__empty {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ComboboxProvider,
|
||||
Combobox,
|
||||
ComboboxPopover as AriaComboboxPopover,
|
||||
ComboboxItem as AriaComboboxItem,
|
||||
ComboboxDisclosure,
|
||||
ComboboxValue,
|
||||
type ComboboxValueProps,
|
||||
type ComboboxProviderProps,
|
||||
type ComboboxProps as AriaComboboxProps,
|
||||
type ComboboxPopoverProps as AriaComboboxPopoverProps,
|
||||
type ComboboxItemProps,
|
||||
useComboboxContext,
|
||||
useComboboxStore,
|
||||
useStoreState,
|
||||
} from '@ariakit/react';
|
||||
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 ComboboxPopoverProps extends AriaComboboxPopoverProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ComboboxPopover - Wrapper around AriaKit's popover with default styles
|
||||
*/
|
||||
export function ComboboxPopover({ children, className, ...props }: ComboboxPopoverProps) {
|
||||
return (
|
||||
<AriaComboboxPopover gutter={8} portal sameWidth unmountOnHide className={clsx('dbv-kit-combobox__popover', className)} {...props}>
|
||||
{children}
|
||||
</AriaComboboxPopover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComboboxItem(props: ComboboxItemProps) {
|
||||
return <AriaComboboxItem {...props} className={clsx('dbv-kit-combobox__item', props.className)} />;
|
||||
}
|
||||
|
||||
export {
|
||||
useComboboxContext,
|
||||
useComboboxStore,
|
||||
useStoreState,
|
||||
ComboboxDisclosure,
|
||||
ComboboxValue,
|
||||
ComboboxProvider,
|
||||
type ComboboxProviderProps,
|
||||
type ComboboxValueProps,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--dbv-kit-combobox-height: var(--dbv-kit-control-height-medium);
|
||||
--dbv-kit-combobox-padding-inline: calc(var(--tw-spacing) * 2.5);
|
||||
|
||||
--dbv-kit-combobox-foreground: var(--dbv-kit-color-foreground);
|
||||
--dbv-kit-combobox-background: var(--dbv-kit-color-background);
|
||||
--dbv-kit-combobox-disabled-foreground: var(--dbv-kit-control-disabled-foreground);
|
||||
--dbv-kit-combobox-disabled-background: var(--dbv-kit-control-disabled-background);
|
||||
|
||||
--dbv-kit-combobox-border-width: var(--dbv-kit-control-border-width);
|
||||
--dbv-kit-combobox-border-color: var(--dbv-kit-color-control-inactive);
|
||||
--dbv-kit-combobox-border-style: solid;
|
||||
--dbv-kit-combobox-border-radius: var(--dbv-kit-control-border-radius);
|
||||
--dbv-kit-combobox-hover-border-color: var(--dbv-kit-color-control-hover);
|
||||
|
||||
--dbv-kit-combobox-font-weight: var(--tw-font-weight-normal);
|
||||
--dbv-kit-combobox-font-size: calc(var(--dbv-kit-font-size-base) * 0.875);
|
||||
|
||||
--dbv-kit-combobox-label-foreground: var(--dbv-kit-color-control-label);
|
||||
--dbv-kit-combobox-label-padding-inline: var(--tw-spacing);
|
||||
|
||||
--dbv-kit-combobox-popover-background: var(--dbv-kit-color-background);
|
||||
--dbv-kit-combobox-popover-foreground: var(--dbv-kit-color-foreground);
|
||||
--dbv-kit-combobox-popover-shadow: var(--tw-shadow-md);
|
||||
--dbv-kit-combobox-popover-height: calc(var(--tw-spacing) * 80);
|
||||
|
||||
--dbv-kit-combobox-item-foreground: var(--tw-color-gray-500);
|
||||
--dbv-kit-combobox-item-padding-inline: calc(var(--tw-spacing) * 3);
|
||||
--dbv-kit-combobox-item-padding-block: var(--tw-spacing);
|
||||
--dbv-kit-combobox-item-hover-background: var(--tw-color-gray-200);
|
||||
--dbv-kit-combobox-item-hover-foreground: var(--dbv-kit-combobox-foreground);
|
||||
--dbv-kit-combobox-item-selected-background: var(--dbv-kit-color-primary-100);
|
||||
}
|
||||
}
|
||||
@@ -88,9 +88,12 @@
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--dbv-kit-radio-inactive-border);
|
||||
background-color: var(--dbv-kit-radio-inactive-background);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
border-color 0.3s;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
border-color 0.3s;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@@ -100,9 +103,11 @@
|
||||
background-color: var(--dbv-kit-radio-active-foreground);
|
||||
scale: 0;
|
||||
|
||||
transition-property: scale;
|
||||
transition-timing-function: var(--tw-ease-in-out);
|
||||
transition-duration: 0.2s;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition-property: scale;
|
||||
transition-timing-function: var(--tw-ease-in-out);
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,18 +57,20 @@
|
||||
box-shadow: var(--dbv-kit-select-popover-shadow);
|
||||
outline: 0;
|
||||
|
||||
opacity: 0;
|
||||
transform-origin: top;
|
||||
transition-property: opacity, scale, translate;
|
||||
transition-timing-function: var(--tw-ease-in-out);
|
||||
transition-duration: 150ms;
|
||||
scale: 0.95;
|
||||
translate: 0 -0.5rem;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
opacity: 0;
|
||||
transform-origin: top;
|
||||
transition-property: opacity, scale, translate;
|
||||
transition-timing-function: var(--tw-ease-in-out);
|
||||
transition-duration: 150ms;
|
||||
scale: 0.95;
|
||||
translate: 0 -0.5rem;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
translate: 0;
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
translate: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
|
||||
animation: var(--dbv-kit-animate-spin);
|
||||
animation-delay: var(--dbv-kit-spinner-animation-delay);
|
||||
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
}
|
||||
|
||||
.dbv-kit-spinner__circle {
|
||||
|
||||
@@ -35,6 +35,7 @@ export {
|
||||
type SelectPopoverProps,
|
||||
type SelectItemProps,
|
||||
} from './Select/Select.js';
|
||||
export * from './Combobox/Combobox.js';
|
||||
export { SelectField, type SelectFieldProps, type SelectItem } from './Select/SelectField.js';
|
||||
export { Spinner, type SpinnerProps } from './Spinner/Spinner.js';
|
||||
export { Radio, RadioGroup, useRadioContext, useRadioStore, type RadioProviderProps, type RadioProps, type RadioGroupProps } from './Radio/index.js';
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { ComboboxProvider, ComboboxInput, ComboboxPopover, ComboboxItem } from '../../../Combobox/Combobox.js';
|
||||
|
||||
function Simple() {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<ComboboxProvider value={value} setSelectedValue={item => setValue(item as string)}>
|
||||
<ComboboxPopover>
|
||||
<ComboboxItem value="apple">Apple</ComboboxItem>
|
||||
<ComboboxItem value="banana">Banana</ComboboxItem>
|
||||
<ComboboxItem value="cherry">Cherry</ComboboxItem>
|
||||
</ComboboxPopover>
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FlexibleLayout() {
|
||||
return (
|
||||
<ComboboxProvider>
|
||||
<div className="search-container">
|
||||
{/* Input in header */}
|
||||
<header className="search-header">
|
||||
<h2>Food Search</h2>
|
||||
<ComboboxInput placeholder="Type to search..." />
|
||||
</header>
|
||||
|
||||
{/* Results in main content area */}
|
||||
<main className="search-results">
|
||||
<ComboboxPopover className="results-popover">
|
||||
<div className="category">
|
||||
<h3>🍎 Fruits</h3>
|
||||
<ComboboxItem value="apple">Apple</ComboboxItem>
|
||||
<ComboboxItem value="banana">Banana</ComboboxItem>
|
||||
<ComboboxItem value="orange">Orange</ComboboxItem>
|
||||
</div>
|
||||
|
||||
<div className="category">
|
||||
<h3>🥕 Vegetables</h3>
|
||||
<ComboboxItem value="carrot">Carrot</ComboboxItem>
|
||||
<ComboboxItem value="broccoli">Broccoli</ComboboxItem>
|
||||
<ComboboxItem value="spinach">Spinach</ComboboxItem>
|
||||
</div>
|
||||
</ComboboxPopover>
|
||||
</main>
|
||||
|
||||
{/* Quick access sidebar */}
|
||||
<aside className="quick-access">
|
||||
<h3>Quick Access</h3>
|
||||
<div className="quick-items">
|
||||
<ComboboxItem value="apple" className="quick-item">
|
||||
🍎
|
||||
</ComboboxItem>
|
||||
<ComboboxItem value="banana" className="quick-item">
|
||||
🍌
|
||||
</ComboboxItem>
|
||||
<ComboboxItem value="carrot" className="quick-item">
|
||||
🥕
|
||||
</ComboboxItem>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiInput() {
|
||||
return (
|
||||
<ComboboxProvider>
|
||||
<div className="multi-search">
|
||||
{/* Primary search */}
|
||||
<div className="primary-search">
|
||||
<label>Main Search:</label>
|
||||
<ComboboxInput placeholder="Search all items..." />
|
||||
</div>
|
||||
|
||||
{/* Quick search */}
|
||||
<div className="quick-search">
|
||||
<label>Quick Search:</label>
|
||||
<ComboboxInput placeholder="Quick find..." />
|
||||
</div>
|
||||
|
||||
{/* Shared results */}
|
||||
<ComboboxPopover>
|
||||
<ComboboxItem value="react">React</ComboboxItem>
|
||||
<ComboboxItem value="vue">Vue</ComboboxItem>
|
||||
<ComboboxItem value="angular">Angular</ComboboxItem>
|
||||
<ComboboxItem value="svelte">Svelte</ComboboxItem>
|
||||
</ComboboxPopover>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicItems() {
|
||||
const [items, setItems] = useState([
|
||||
{ id: 1, name: 'Task 1', category: 'work' },
|
||||
{ id: 2, name: 'Task 2', category: 'personal' },
|
||||
{ id: 3, name: 'Task 3', category: 'work' },
|
||||
]);
|
||||
|
||||
const [newItemName, setNewItemName] = useState('');
|
||||
|
||||
const addItem = () => {
|
||||
if (newItemName.trim()) {
|
||||
setItems(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now(),
|
||||
name: newItemName,
|
||||
category: 'work',
|
||||
},
|
||||
]);
|
||||
setNewItemName('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = (id: number) => {
|
||||
setItems(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ComboboxProvider>
|
||||
<div className="dynamic-example">
|
||||
<div className="controls">
|
||||
<ComboboxInput placeholder="Search tasks..." />
|
||||
|
||||
<div className="add-item">
|
||||
<input type="text" value={newItemName} onChange={e => setNewItemName(e.target.value)} placeholder="New task name..." />
|
||||
<button onClick={addItem}>Add Task</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComboboxPopover>
|
||||
<div className="task-categories">
|
||||
<div className="category">
|
||||
<h4>Work Tasks</h4>
|
||||
{items
|
||||
.filter(item => item.category === 'work')
|
||||
.map(item => (
|
||||
<ComboboxItem key={item.id} value={item.name}>
|
||||
<div className="task-item">
|
||||
<span>{item.name}</span>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
removeItem(item.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="category">
|
||||
<h4>Personal Tasks</h4>
|
||||
{items
|
||||
.filter(item => item.category === 'personal')
|
||||
.map(item => (
|
||||
<ComboboxItem key={item.id} value={item.name}>
|
||||
<div className="task-item">
|
||||
<span>{item.name}</span>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
removeItem(item.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxPopover>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectExample() {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
const items = [
|
||||
{ value: 'apple', label: 'Apple' },
|
||||
{ value: 'banana', label: 'Banana' },
|
||||
{ value: 'cherry', label: 'Cherry' },
|
||||
{ value: 'orange', label: 'Orange' },
|
||||
{ value: 'grape', label: 'Grape' },
|
||||
];
|
||||
|
||||
return (
|
||||
<ComboboxProvider selectedValue={selectedValues} setSelectedValue={setSelectedValues}>
|
||||
<div className="multi-select-example">
|
||||
<div>
|
||||
<strong>Selected:</strong> {selectedValues.join(', ') || 'None'}
|
||||
</div>
|
||||
<ComboboxInput placeholder="Select fruits..." />
|
||||
<ComboboxPopover>
|
||||
{items.map(item => (
|
||||
<ComboboxItem key={item.value} value={item.value}>
|
||||
<input type="checkbox" checked={selectedValues.includes(item.value)} readOnly style={{ marginRight: 8 }} />
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxPopover>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
export { Simple, FlexibleLayout, MultiInput, DynamicItems, MultiSelectExample };
|
||||
@@ -0,0 +1,34 @@
|
||||
@layer components {
|
||||
.dbv-kit-combobox,
|
||||
.dbv-kit-combobox__popover {
|
||||
--dbv-kit-combobox-border-width: 2px;
|
||||
--dbv-kit-combobox-hover-border-color: var(--theme-background);
|
||||
|
||||
--dbv-kit-combobox-border-color: var(--theme-input-border);
|
||||
--dbv-kit-combobox-foreground: var(--theme-input-color);
|
||||
--dbv-kit-combobox-background: var(--theme-input-background);
|
||||
|
||||
--dbv-kit-combobox-disabled-background: var(--theme-input-background-disabled);
|
||||
|
||||
--dbv-kit-control-outline-offset: -2px;
|
||||
|
||||
--dbv-kit-combobox-popover-background: var(--theme-input-background);
|
||||
--dbv-kit-combobox-popover-foreground: var(--theme-input-color);
|
||||
|
||||
--dbv-kit-combobox-item-foreground: var(--theme-secondary);
|
||||
--dbv-kit-combobox-item-hover-background: var(--theme-background);
|
||||
--dbv-kit-combobox-item-hover-foreground: var(--theme-input-color);
|
||||
--dbv-kit-combobox-item-selected-background: none;
|
||||
}
|
||||
|
||||
.dbv-kit-combobox__item {
|
||||
--tw-leading: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: calc(var(--tw-spacing) * 3);
|
||||
padding-block: calc(var(--tw-spacing) * 2);
|
||||
gap: calc(var(--tw-spacing) * 2);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
.field.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& .fieldLabel {
|
||||
padding-right: 8px;
|
||||
padding-bottom: 0;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
.field .input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
composes: theme-typography--body1 from global;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding-right: 24px !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px 0 0;
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
composes: theme-ripple from global;
|
||||
}
|
||||
|
||||
.menu {
|
||||
composes: theme-text-on-surface theme-background-surface theme-typography--caption theme-elevation-z3 from global;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
z-index: 999;
|
||||
border-radius: var(--theme-form-element-radius);
|
||||
|
||||
& .menuItem {
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
|
||||
& .itemIcon,
|
||||
& .itemTitle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .itemIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
& .iconOrImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menuButton .icon.focus {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.inputBox {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& .inputIcon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 12px;
|
||||
|
||||
& .iconOrImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:not(:empty) + .input {
|
||||
padding-left: 34px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.validationInput {
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -6,30 +6,33 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Menu, MenuButton, MenuItem, useMenuState } from 'reakit';
|
||||
import { useCallback, useContext, useId, useState } from 'react';
|
||||
import {
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
clsx,
|
||||
Spinner,
|
||||
ComboboxPopover,
|
||||
ComboboxDisclosure,
|
||||
ComboboxProvider,
|
||||
type ComboboxProviderProps,
|
||||
} from '@dbeaver/ui-kit';
|
||||
|
||||
import { filterLayoutFakeProps, getLayoutProps } from '../Containers/filterLayoutFakeProps.js';
|
||||
import type { ILayoutSizeProps } from '../Containers/ILayoutSizeProps.js';
|
||||
import { getComputed } from '../getComputed.js';
|
||||
import { Icon } from '../Icon.js';
|
||||
import { IconOrImage } from '../IconOrImage.js';
|
||||
import { Loader } from '../Loader/Loader.js';
|
||||
import { useTranslate } from '../localization/useTranslate.js';
|
||||
import { s } from '../s.js';
|
||||
import { useS } from '../useS.js';
|
||||
import comboboxStyles from './Combobox.module.css';
|
||||
import { Field } from './Field.js';
|
||||
import { FieldDescription } from './FieldDescription.js';
|
||||
import { FieldLabel } from './FieldLabel.js';
|
||||
import { FormContext } from './FormContext.js';
|
||||
import './Combobox.css';
|
||||
|
||||
export type ComboboxBaseProps<TKey, TValue> = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'onChange' | 'onSelect' | 'name' | 'value' | 'defaultValue'
|
||||
> &
|
||||
ILayoutSizeProps & {
|
||||
propertyName?: string;
|
||||
items: TValue[];
|
||||
defaultValue?: TKey;
|
||||
loading?: boolean;
|
||||
@@ -39,7 +42,6 @@ export type ComboboxBaseProps<TKey, TValue> = Omit<
|
||||
titleSelector?: (item: TValue) => string | undefined;
|
||||
iconSelector?: (item: TValue) => string | React.ReactElement | undefined;
|
||||
isDisabled?: (item: TValue) => boolean;
|
||||
onSwitch?: (state: boolean) => void;
|
||||
inline?: boolean;
|
||||
};
|
||||
|
||||
@@ -47,7 +49,6 @@ type ControlledProps<TKey, TValue> = ComboboxBaseProps<TKey, TValue> & {
|
||||
name?: string;
|
||||
value?: TKey;
|
||||
onSelect?: (value: TKey, name: string | undefined, prev: TKey) => void;
|
||||
onChange?: (value: string, name: string | undefined) => any;
|
||||
state?: never;
|
||||
};
|
||||
|
||||
@@ -55,7 +56,6 @@ type ObjectProps<TValue, TKey extends keyof TState, TState> = ComboboxBaseProps<
|
||||
name: TKey;
|
||||
state: TState;
|
||||
onSelect?: (value: TState[TKey], name: TKey | undefined, prev: TState[TKey]) => void;
|
||||
onChange?: (value: string, name: TKey | undefined) => any;
|
||||
value?: never;
|
||||
};
|
||||
|
||||
@@ -64,15 +64,11 @@ export interface ComboboxType {
|
||||
<TValue, TKey extends keyof TState, TState>(props: ObjectProps<TValue, TKey, TState>): React.JSX.Element;
|
||||
}
|
||||
|
||||
{
|
||||
/* TODO rewrite whole component to select attribute instead of input type text so it has an okay form validation */
|
||||
}
|
||||
export const Combobox: ComboboxType = observer(function Combobox({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
name,
|
||||
state,
|
||||
propertyName,
|
||||
items,
|
||||
loading,
|
||||
children,
|
||||
@@ -87,245 +83,153 @@ export const Combobox: ComboboxType = observer(function Combobox({
|
||||
iconSelector,
|
||||
titleSelector,
|
||||
isDisabled,
|
||||
onChange = () => {},
|
||||
onSelect,
|
||||
onSwitch,
|
||||
...rest
|
||||
}: ControlledProps<any, any> | ObjectProps<any, any, any>) {
|
||||
const layoutProps = getLayoutProps(rest);
|
||||
const inputId = useId();
|
||||
rest = filterLayoutFakeProps(rest);
|
||||
const translate = useTranslate();
|
||||
const context = useContext(FormContext);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null);
|
||||
const styles = useS(comboboxStyles);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: 'bottom-end',
|
||||
currentId: null,
|
||||
gutter: 4,
|
||||
unstable_fixed: true,
|
||||
});
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string | null>(null);
|
||||
|
||||
const filteredItems = getComputed(() => {
|
||||
const result = items.filter(item => !searchValue || valueSelector(item).toUpperCase().includes(searchValue.toUpperCase()));
|
||||
|
||||
if (isDisabled) {
|
||||
return result.sort((a, b) => Number(isDisabled(a)) - Number(isDisabled(b)));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
let value: string | number | readonly string[] | undefined = controlledValue ?? defaultValue ?? undefined;
|
||||
|
||||
let selectedKey: string | string[] = controlledValue ?? defaultValue ?? undefined;
|
||||
if (state && name !== undefined && name in state) {
|
||||
value = state[name];
|
||||
selectedKey = state[name];
|
||||
}
|
||||
|
||||
const selectedItem = items.find((item, index) => keySelector(item, index) === value);
|
||||
const selectedItem = items.find((item, index) => keySelector(item, index) === selectedKey);
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
|
||||
let inputValue = (selectedItem ? valueSelector(selectedItem) : searchValue) ?? '';
|
||||
const selectedValue = selectedItem ? valueSelector(selectedItem) : '';
|
||||
const displayValue = inputValue ?? selectedValue;
|
||||
|
||||
if (searchValue !== null && selectedItem && valueSelector(selectedItem) !== searchValue) {
|
||||
inputValue = searchValue;
|
||||
}
|
||||
const filteredItems = items
|
||||
.map((item, index) => {
|
||||
const itemKey = String(keySelector(item, index));
|
||||
const itemValue = valueSelector(item);
|
||||
const itemTitle = titleSelector?.(item);
|
||||
const itemIcon = iconSelector?.(item);
|
||||
const itemDisabled = isDisabled?.(item);
|
||||
|
||||
const hideMenu = items.length === 1 && (!!selectedItem || isDisabled?.(items[0]) === true);
|
||||
const isVisible = inputValue === null || !inputValue.trim() || itemValue.toLowerCase().includes(inputValue.trim().toLowerCase());
|
||||
|
||||
function handleClick() {
|
||||
if (menu.visible) {
|
||||
menu.hide();
|
||||
} else {
|
||||
menu.show();
|
||||
}
|
||||
}
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
itemKey,
|
||||
itemValue,
|
||||
itemTitle,
|
||||
itemIcon,
|
||||
itemDisabled,
|
||||
isVisible,
|
||||
};
|
||||
})
|
||||
.filter(({ isVisible }) => isVisible);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
onChange(value, name);
|
||||
setSearchValue(value);
|
||||
},
|
||||
[name, onChange],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: any) => {
|
||||
id = id ?? value ?? '';
|
||||
const changed = id !== value;
|
||||
|
||||
menu.hide();
|
||||
if (state && changed) {
|
||||
state[name] = id;
|
||||
}
|
||||
if (onSelect && changed) {
|
||||
onSelect(id, name, value);
|
||||
}
|
||||
if (context && changed) {
|
||||
context.change(id, name);
|
||||
}
|
||||
setSearchValue(null);
|
||||
},
|
||||
[value, state, name, menu, context, onSelect],
|
||||
);
|
||||
|
||||
const matchItems = useCallback(
|
||||
(input?: boolean) => {
|
||||
if (searchValue === null) {
|
||||
const handleSelect: ComboboxProviderProps['setSelectedValue'] = useCallback(
|
||||
(selectedValue: string | string[]) => {
|
||||
const item = items.find((item, idx) => keySelector(item, idx) === selectedValue);
|
||||
if (!item || selectedValue === selectedKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
setSearchValue(null);
|
||||
return;
|
||||
if (state) {
|
||||
state[name] = selectedValue;
|
||||
}
|
||||
|
||||
const filteredItemIndex = items.indexOf(filteredItems[0]);
|
||||
|
||||
if (filteredItems.length === 1) {
|
||||
handleSelect(keySelector(filteredItems[0], filteredItemIndex));
|
||||
return;
|
||||
if (onSelect) {
|
||||
onSelect(selectedValue, name, selectedKey);
|
||||
}
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
if (input) {
|
||||
handleSelect(keySelector(filteredItems[0], filteredItemIndex));
|
||||
} else {
|
||||
setSearchValue(null);
|
||||
}
|
||||
if (context) {
|
||||
context.change(selectedValue as string, name);
|
||||
}
|
||||
},
|
||||
[items, filteredItems, keySelector, handleSelect, searchValue],
|
||||
[items, selectedKey, state, onSelect, context, keySelector, name],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
matchItems(true);
|
||||
}
|
||||
},
|
||||
[matchItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef === document.activeElement) {
|
||||
if (inputValue === searchValue) {
|
||||
menu.show();
|
||||
}
|
||||
} else {
|
||||
if (!menu.visible) {
|
||||
matchItems();
|
||||
}
|
||||
}
|
||||
}, [inputValue, searchValue, matchItems, menu]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onSwitch?.(menu.visible);
|
||||
}, [onSwitch, menu.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (menuRef.current) {
|
||||
const size = inputRef.getBoundingClientRect();
|
||||
menuRef.current.style.width = size.width + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(inputRef);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [inputRef]);
|
||||
|
||||
const icon = selectedItem && iconSelector?.(selectedItem);
|
||||
const focus = menu.visible;
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
inputValue = translate('ui_processing_loading');
|
||||
const comboboxDefaultSelectedValue = defaultValue;
|
||||
let comboboxDefaultValue = undefined;
|
||||
|
||||
if (comboboxDefaultSelectedValue !== undefined) {
|
||||
const defaultItem = items.find((item, index) => keySelector(item, index) === comboboxDefaultSelectedValue);
|
||||
|
||||
if (defaultItem) {
|
||||
comboboxDefaultValue = valueSelector(defaultItem);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Field {...layoutProps} className={s(styles, { field: true, inline }, className)}>
|
||||
<Field {...layoutProps} className={clsx(className, inline && 'tw:flex tw:items-center')}>
|
||||
{children && (
|
||||
<FieldLabel required={rest.required} title={title} className={s(styles, { fieldLabel: true })}>
|
||||
<FieldLabel
|
||||
required={rest.required}
|
||||
htmlFor={inputId}
|
||||
title={title}
|
||||
className={clsx('theme-typography--body1', 'tw:block tw:font-medium!', inline ? 'tw:mr-2' : 'tw:mb-2.5')}
|
||||
>
|
||||
{children}
|
||||
</FieldLabel>
|
||||
)}
|
||||
<div className={s(styles, { inputBox: true })}>
|
||||
<input className={s(styles, { validationInput: true })} value={inputValue} required={rest.required} readOnly />
|
||||
{(icon || loading) && (
|
||||
<div className={s(styles, { inputIcon: true })}>
|
||||
{loading ? (
|
||||
<Loader small fullSize />
|
||||
) : typeof icon === 'string' ? (
|
||||
<IconOrImage icon={icon} className={s(styles, { iconOrImage: true })} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={setInputRef}
|
||||
required={rest.required}
|
||||
autoComplete="off"
|
||||
name={name}
|
||||
title={title}
|
||||
value={inputValue}
|
||||
disabled={disabled || hideMenu || readOnly}
|
||||
readOnly={readOnly}
|
||||
data-focus={focus}
|
||||
className={s(styles, { input: true, focus })}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
{...rest}
|
||||
/>
|
||||
<MenuButton {...menu} disabled={readOnly || disabled || hideMenu} className={styles['menuButton']}>
|
||||
<Icon name="arrow" viewBox="0 0 16 16" className={s(styles, { icon: true, focus })} />
|
||||
</MenuButton>
|
||||
<Menu {...menu} ref={menuRef} aria-label={propertyName} className={s(styles, { menu: true })} modal>
|
||||
{!filteredItems.length ? (
|
||||
<MenuItem id="placeholder" disabled {...menu} className={s(styles, { menuItem: true })}>
|
||||
{translate('combobox_no_results_placeholder')}
|
||||
</MenuItem>
|
||||
<ComboboxProvider
|
||||
value={displayValue}
|
||||
setValue={setInputValue}
|
||||
defaultValue={comboboxDefaultValue}
|
||||
defaultSelectedValue={comboboxDefaultSelectedValue}
|
||||
setSelectedValue={handleSelect}
|
||||
>
|
||||
<div className="tw:relative tw:flex tw:flex-1 tw:items-center tw:gap-2">
|
||||
<ComboboxInput
|
||||
defaultValue={comboboxDefaultValue}
|
||||
disabled={disabled || loading || readOnly}
|
||||
readOnly={readOnly}
|
||||
placeholder={rest.placeholder}
|
||||
className={clsx('theme-typography--caption tw:tracking-normal!', icon || loading ? 'tw:pl-8!' : '', 'tw:pr-6!')}
|
||||
title={title}
|
||||
id={inputId}
|
||||
onBlur={() => setInputValue(null)}
|
||||
{...rest}
|
||||
/>
|
||||
{loading ? (
|
||||
<Spinner size="small" className="tw:absolute tw:right-2 tw:top-[50%] tw:-translate-y-1/2" />
|
||||
) : (
|
||||
filteredItems.map((item, index) => {
|
||||
const icon = iconSelector?.(item);
|
||||
const title = titleSelector?.(item);
|
||||
const disabled = isDisabled?.(item);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={keySelector(item, index)}
|
||||
id={keySelector(item, index)}
|
||||
type="button"
|
||||
title={title}
|
||||
{...menu}
|
||||
disabled={disabled}
|
||||
className={s(styles, { menuItem: true })}
|
||||
onClick={event => handleSelect(event.currentTarget.id)}
|
||||
<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,
|
||||
})}
|
||||
>
|
||||
{iconSelector && (
|
||||
<div className={s(styles, { itemIcon: true })}>
|
||||
{icon && typeof icon === 'string' ? <IconOrImage icon={icon} className={s(styles, { iconOrImage: true })} /> : icon}
|
||||
<div className="tw:w-4 tw:h-4 tw:shrink-0">
|
||||
{itemIcon && typeof itemIcon === 'string' ? <IconOrImage icon={itemIcon} /> : itemIcon}
|
||||
</div>
|
||||
)}
|
||||
<div>{valueSelector(item)}</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
<div>{itemValue}</div>
|
||||
</ComboboxItem>
|
||||
))
|
||||
) : (
|
||||
<div className="tw:p-2">{translate('combobox_no_results_placeholder')}</div>
|
||||
)}
|
||||
</ComboboxPopover>
|
||||
</div>
|
||||
</ComboboxProvider>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
|
||||
--dbv-kit-radio-inactive-border: color-mix(in srgb, var(--theme-on-secondary), white 30%);
|
||||
--dbv-kit-radio-hover-shadow-color: color-mix(in srgb, var(--theme-on-secondary), white 80%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dbv-kit-radio__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user