Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions dashboard/src/components/config/ConfigSpecialEditors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { MdiIcon } from '@/components/icons/MdiIcon';
import { Button } from '@/components/ui/Button';
import { DialogActions } from '@/components/ui/DialogActions';
import { SelectControl } from '@/components/ui/SelectControl';
import { confirmAction, toast } from '@/stores/feedback';
import { normalizeT2iPreview } from './configSpecialEditorsModel';
import { isConfigRecord, setConfigValue, type ConfigRecord } from './configFormModel';
Expand Down Expand Up @@ -229,14 +230,14 @@
value={name}
/>
) : (
<select disabled={loading} onChange={(event) => setSelected(event.target.value)} value={selected}>
<SelectControl disabled={loading} onChange={(event) => setSelected(event.target.value)} value={selected}>
{templates.map((template) => (
<option key={template.name} value={template.name}>
{template.name}
{template.name === active ? ` · ${label('applied')}` : ''}
</option>
))}
</select>
</SelectControl>
)}
<button onClick={startNew} type="button">
<MdiIcon name="mdi-plus" />
Expand Down Expand Up @@ -467,7 +468,7 @@

return (
<div className="totp-manager">
<label className="dynamic-switch">

Check warning on line 471 in dashboard/src/components/config/ConfigSpecialEditors.tsx

View workflow job for this annotation

GitHub Actions / lint, test, and build

A form label must have accessible text
<input checked={value} onChange={(event) => toggle(event.target.checked)} type="checkbox" />
<span className="dynamic-switch__track" />
</label>
Expand Down
5 changes: 3 additions & 2 deletions dashboard/src/components/config/DynamicConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { ExpandCollapse } from '@/components/motion/ExpandCollapse';
import { Button } from '@/components/ui/Button';
import { DialogActions } from '@/components/ui/DialogActions';
import { SelectControl } from '@/components/ui/SelectControl';
import { toast } from '@/stores/feedback';
import { ConfigSpecialSelector, isConfigSelectorSpecial, PersonaQuickPreview } from './ConfigSpecialControls';
import { DashboardTotpManager, T2ITemplateEditor } from './ConfigSpecialEditors';
Expand Down Expand Up @@ -447,7 +448,7 @@
);
}

function ConfigControl({

Check warning on line 451 in dashboard/src/components/config/DynamicConfigForm.tsx

View workflow job for this annotation

GitHub Actions / lint, test, and build

Function 'ConfigControl' has a complexity of 50. Maximum allowed is 35
configKey = '',
configRoot,
embeddingDimensionLoading,
Expand Down Expand Up @@ -535,7 +536,7 @@

if (type === 'bool') {
return (
<label className="dynamic-switch">

Check warning on line 539 in dashboard/src/components/config/DynamicConfigForm.tsx

View workflow job for this annotation

GitHub Actions / lint, test, and build

A form label must have accessible text
<input
checked={Boolean(value)}
disabled={disabled}
Expand Down Expand Up @@ -594,7 +595,7 @@
if (metadata.options?.length) {
const selectedIndex = metadata.options.findIndex((option) => Object.is(option, value));
return (
<select
<SelectControl
disabled={disabled}
onChange={(event) => onChange(metadata.options?.[Number(event.target.value)])}
value={selectedIndex < 0 ? '' : selectedIndex}
Expand All @@ -605,7 +606,7 @@
{String(labels[index] ?? option)}
</option>
))}
</select>
</SelectControl>
);
}

Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/components/config/ObjectConfigControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ describe('ObjectConfigControl', () => {
await user.clear(existingValue);
await user.type(existingValue, 'after');
await user.type(screen.getByPlaceholderText('core.common.objectEditor.newKeyLabel'), 'retries');
await user.selectOptions(screen.getByRole('combobox'), 'number');
await user.click(screen.getByRole('button', { name: 'string' }));
await user.click(screen.getByRole('option', { name: 'number' }));
await user.click(screen.getByRole('button', { name: /core\.common\.add/ }));

const numberValue = screen.getByPlaceholderText('core.common.objectEditor.placeholders.numberValue');
Expand Down
5 changes: 3 additions & 2 deletions dashboard/src/components/config/ObjectConfigControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { MdiIcon } from '@/components/icons/MdiIcon';
import { Button } from '@/components/ui/Button';
import { DialogActions } from '@/components/ui/DialogActions';
import { SelectControl } from '@/components/ui/SelectControl';
import { toast } from '@/stores/feedback';
import { isConfigRecord, type ConfigItemMetadata, type ConfigRecord } from './configFormModel';

Expand Down Expand Up @@ -129,7 +130,7 @@
const renderValue = (pair: ObjectPair) => {
if (pair.type === 'boolean')
return (
<label className="dynamic-switch">

Check warning on line 133 in dashboard/src/components/config/ObjectConfigControl.tsx

View workflow job for this annotation

GitHub Actions / lint, test, and build

A form label must have accessible text
<input
checked={Boolean(pair.value)}
onChange={(event) => updatePair(pair.id, { value: event.target.checked })}
Expand Down Expand Up @@ -239,7 +240,7 @@
{pair ? (
renderValue(pair)
) : type === 'boolean' ? (
<label className="dynamic-switch">

Check warning on line 243 in dashboard/src/components/config/ObjectConfigControl.tsx

View workflow job for this annotation

GitHub Actions / lint, test, and build

A form label must have accessible text
<input
checked={Boolean(temporary.value)}
onChange={(event) => updateTemplate({ value: event.target.checked })}
Expand Down Expand Up @@ -300,12 +301,12 @@
/>
<label>
<span>{t('core.common.objectEditor.valueTypeLabel')}</span>
<select onChange={(event) => setNewType(event.target.value as ObjectValueType)} value={newType}>
<SelectControl onChange={(event) => setNewType(event.target.value as ObjectValueType)} value={newType}>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="json">json</option>
</select>
</SelectControl>
</label>
<button className="dynamic-editor-button--tonal" disabled={!newKey.trim()} onClick={addPair} type="button">
<MdiIcon name="mdi-plus" />
Expand Down
26 changes: 26 additions & 0 deletions dashboard/src/components/ui/FloatingActions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// @vitest-environment jsdom

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { FloatingActionButton, FloatingActions } from './FloatingActions';

describe('FloatingActions', () => {
it('portals actions to the document body with shared classes', () => {
render(
<main>
<FloatingActions aria-label="Page actions">
<FloatingActionButton aria-label="Refresh">Refresh</FloatingActionButton>
</FloatingActions>
</main>,
);

const action = screen.getByRole('button', { name: 'Refresh' });
const stack = screen.getByLabelText('Page actions');

expect(action).toHaveClass('ui-floating-action');
expect(action).toHaveAttribute('type', 'button');
expect(stack).toHaveClass('ui-floating-actions');
expect(stack.parentElement).toBe(document.body);
});
});
25 changes: 25 additions & 0 deletions dashboard/src/components/ui/FloatingActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes } from 'react';
import { createPortal } from 'react-dom';

export type FloatingActionsProps = HTMLAttributes<HTMLDivElement>;

export function FloatingActions({ children, className = '', ...props }: FloatingActionsProps) {
if (typeof document === 'undefined') return null;

return createPortal(
<div className={`ui-floating-actions${className ? ` ${className}` : ''}`} {...props}>
{children}
</div>,
document.body,
);
}

export const FloatingActionButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
function FloatingActionButton({ children, className = '', type = 'button', ...props }, ref) {
return (
<button className={`ui-floating-action${className ? ` ${className}` : ''}`} ref={ref} type={type} {...props}>
{children}
</button>
);
},
);
5 changes: 3 additions & 2 deletions dashboard/src/components/ui/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type ReactNode } from 'react';
import { MdiIcon } from '@/components/icons/MdiIcon';
import { paginationDefaults } from '@/config/defaults';
import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl';

export type PaginationLabels = {
navigation: string;
Expand Down Expand Up @@ -45,13 +46,13 @@ export function Pagination({
{onPageSizeChange ? (
<label className="ui-pagination__size">
<span>{labels.pageSize}</span>
<select onChange={(event) => onPageSizeChange(Number(event.target.value))} value={pageSize}>
<SelectControl onChange={(event) => onPageSizeChange(Number(event.target.value))} value={pageSize}>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</SelectControl>
</label>
) : null}
{labels.range ? <span className="ui-pagination__range">{labels.range}</span> : null}
Expand Down
60 changes: 60 additions & 0 deletions dashboard/src/components/ui/SelectControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment jsdom

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ChangeEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';

import { SelectControl } from './SelectControl';

describe('SelectControl', () => {
it('preserves native change handlers while using the shared menu', async () => {
const user = userEvent.setup();
let changedValue = '';
const onChange = vi.fn((event: ChangeEvent<HTMLSelectElement>) => {
changedValue = event.target.value;
});
const view = render(
<SelectControl aria-label="Page size" onChange={onChange} value="10">
<option value="10">10</option>
<option value="20">20</option>
</SelectControl>,
);

await user.click(screen.getByRole('button', { name: 'Page size' }));
expect(view.container.querySelector('[role="listbox"]')).toBeNull();
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull();
await user.click(screen.getByRole('option', { name: '20' }));

expect(onChange).toHaveBeenCalledOnce();
expect(changedValue).toBe('20');
});

it('opens above the trigger when the viewport has no room below', async () => {
const user = userEvent.setup();
render(
<SelectControl aria-label="Page size" onChange={() => undefined} value="10">
<option value="10">10</option>
<option value="20">20</option>
</SelectControl>,
);
const trigger = screen.getByRole('button', { name: 'Page size' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
bottom: 764,
height: 44,
left: 100,
right: 220,
top: 720,
width: 120,
x: 100,
y: 720,
toJSON: () => ({}),
});

await user.click(trigger);

const listbox = screen.getByRole('listbox', { name: 'Page size' });
await waitFor(() => expect(listbox.style.bottom).not.toBe(''));
expect(listbox.style.top).toBe('');
});
});
93 changes: 93 additions & 0 deletions dashboard/src/components/ui/SelectControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
Children,
Fragment,
isValidElement,
type ChangeEventHandler,
type OptionHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
useMemo,
useRef,
} from 'react';

import { SelectMenu, type SelectMenuOption } from './SelectMenu';

type SelectControlProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'multiple' | 'size'> & {
children: ReactNode;
};

function optionLabel(children: ReactNode) {
return Children.toArray(children)
.map((child) => (typeof child === 'string' || typeof child === 'number' ? String(child) : ''))
.join('');
}

function collectOptions(children: ReactNode, options: SelectMenuOption[] = []) {
Children.forEach(children, (child) => {
if (!isValidElement(child)) return;
if (child.type === Fragment || child.type === 'optgroup') {
collectOptions((child.props as { children?: ReactNode }).children, options);
return;
}
if (child.type !== 'option') return;
const props = child.props as OptionHTMLAttributes<HTMLOptionElement>;
const name = optionLabel(props.children);
options.push({
disabled: props.disabled,
id: String(props.value ?? name),
name,
});
});
return options;
}

export function SelectControl({
'aria-label': ariaLabel,
children,
className = '',
defaultValue,
disabled,
onChange,
value,
...props
}: SelectControlProps) {
const nativeRef = useRef<HTMLSelectElement>(null);
const options = useMemo(() => collectOptions(children), [children]);
const selectedValue = String(value ?? defaultValue ?? options[0]?.id ?? '');
const selectedName = options.find((option) => option.id === selectedValue)?.name || '';

const selectValue = (nextValue: string) => {
const select = nativeRef.current;
if (!select) return;
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
setter?.call(select, nextValue);
select.dispatchEvent(new Event('change', { bubbles: true }));
};

return (
<>
<select
{...props}
aria-hidden="true"
className="ui-select-control__native"
disabled={disabled}
onChange={onChange as ChangeEventHandler<HTMLSelectElement>}
ref={nativeRef}
tabIndex={-1}
value={value}
defaultValue={value === undefined ? defaultValue : undefined}
>
{children}
</select>
<SelectMenu
ariaLabel={ariaLabel || selectedName || 'Select'}
className={className}
disabled={disabled}
onChange={selectValue}
options={options}
placeholder={selectedName || options[0]?.name || ''}
value={selectedValue}
/>
</>
);
}
Loading
Loading