Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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