mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1852 from rcourtman/maintainer/20260902T060320Z
Fix empty-slot alerts and make keyboard navigation accessible
This commit is contained in:
@@ -4689,6 +4689,11 @@ route-path wiring, and command selection, and
|
||||
owns canonical command construction plus query normalization and filtering
|
||||
policy. Future command-palette work should extend those owners instead of
|
||||
pushing route construction or search policy back into the shared shell.
|
||||
The palette search composes `SearchField` as an editable list-autocomplete
|
||||
combobox: DOM focus remains on the search input while arrow-key selection is
|
||||
exposed through `aria-activedescendant`, and the active listbox option remains
|
||||
scrolled into view. Palette options stay outside the Tab sequence; filtering
|
||||
to an empty result collapses the combobox and clears its active descendant.
|
||||
The OpenCode reference for this interaction is
|
||||
`packages/opencode/src/cli/cmd/run/footer.command.tsx` at `origin/dev`
|
||||
`e82542b8023a8374f29c23b70ec019c8f256354e`, where `RunCommandMenuBody`
|
||||
|
||||
@@ -1,70 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "fd36b3124c31bde2bea6b916652b9319827f890f",
|
||||
"verified_at": "2026-09-02T06:55:42Z",
|
||||
"base_sha": "23b3893ae88b86497f3f103c8972663508555766",
|
||||
"verified_at": "2026-09-02T07:15:12Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/aiPatrolCost.ts",
|
||||
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
|
||||
"frontend-modern/src/components/Settings/AISettings.tsx",
|
||||
"frontend-modern/src/components/Settings/useAISettingsState.ts",
|
||||
"frontend-modern/src/components/shared/AIModelPicker.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolControlPresentation.ts",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts",
|
||||
"frontend-modern/src/types/ai.ts",
|
||||
"frontend-modern/src/utils/aiPatrolCostPresentation.ts",
|
||||
"frontend-modern/src/utils/patrolRuntimeActions.ts"
|
||||
"frontend-modern/src/components/shared/CommandPaletteModal.tsx",
|
||||
"frontend-modern/src/components/shared/SearchField.tsx",
|
||||
"frontend-modern/src/components/shared/searchFieldModel.ts",
|
||||
"frontend-modern/src/components/shared/useCommandPaletteState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/aiPatrolCost.ts": "be947370111a1efb7e2618f344b4b81481262f6b1fd8fc4ed2690a7729a05bff",
|
||||
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx": "33b63989306752e5b852e5d2438dfcb99c0df78530ba50c1e157b895d7f1a623",
|
||||
"frontend-modern/src/components/Settings/AISettings.tsx": "7103686e133e08d50563bf66da63ea161f73ae0386c59d6c770da78f0bdb78bb",
|
||||
"frontend-modern/src/components/Settings/useAISettingsState.ts": "841fa917cf7c5a1f7867b992813984bbd1ce6f57ed9f1abe8db2b7183a4596a5",
|
||||
"frontend-modern/src/components/shared/AIModelPicker.tsx": "79abf84c58d6baec0351bea8cfb7d828db8ff00f5bae7ed0fff4079bbef25b91",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx": "085c1d677ed5e15698e32759b4c01a05e0fd038fbf686ded48b390623de41d22",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "69ff486b95aafd445600571c977f8670e9686d9185f2c1285c2a6690e9c2d113",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx": "cd6d888b620e44fa8ec2125b818e59c8ed802f8aeae0c554c4ce5bd1d40e314f",
|
||||
"frontend-modern/src/features/patrol/patrolControlPresentation.ts": "58ad626d6af2472343307c01583621d4cfd742684f36c5583a3b72de75f86929",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "7578f9e010bc36a1d5a3ec932a69a23a077e176755e7207905ec47f5e94d5fae",
|
||||
"frontend-modern/src/stores/aiIntelligence.ts": "8b81de1c09f47555b148415641f9cc42a9db25e0a54cd0bc64bb24d03bb1a0ac",
|
||||
"frontend-modern/src/types/ai.ts": "12473e6f0e6339094689ac77f9a32aa1ffb62def1e81c780f4263cbcf5923474",
|
||||
"frontend-modern/src/utils/aiPatrolCostPresentation.ts": "3d1ffa67e0df24af89ca26bb40e29fe55ef859902145aa958d4331483952a032",
|
||||
"frontend-modern/src/utils/patrolRuntimeActions.ts": "a71f234c2007dafd6e8773542ae238b8cd3cdb9501aaf5f1a403060335fbabd9"
|
||||
"frontend-modern/src/components/shared/CommandPaletteModal.tsx": "300030975b76634513d98b9459fe9ae4d07e8d7d03fedffa91f702746a7d3f06",
|
||||
"frontend-modern/src/components/shared/SearchField.tsx": "0c2cc0d002763b59c2c065ceccff134318122aa975fe45ddc31271387cd623ad",
|
||||
"frontend-modern/src/components/shared/searchFieldModel.ts": "3b9ca4ffc6aef0e5910e5094daebb9b03878b2b6ef4c9c7aed165bf7f46097db",
|
||||
"frontend-modern/src/components/shared/useCommandPaletteState.ts": "df4baf2ccba420f0c8d7d67ee7ddeba5a55ddbee8af323d8d7924aa389ad9b3a"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/pulse-intelligence/patrol",
|
||||
"/settings/pulse-intelligence/provider",
|
||||
"/patrol"
|
||||
],
|
||||
"routes": ["/proxmox"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 1000
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
"width": 393,
|
||||
"height": 851
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Patrol model picker open with the Suggested for Patrol section and Recommended for Patrol badge (Ollama qwen3:8b)",
|
||||
"estimate box for a local model (no per-token bill) with the 30-day budget line",
|
||||
"estimate box for a per-token model (Anthropic Sonnet 5) with monthly figure, per-run assumption, token explainer, and schedule recommendation",
|
||||
"schedule select priced per preset and current-schedule card after the auto-adjust to once a day",
|
||||
"estimate box with the budget reached line after seeding an over-budget usage history",
|
||||
"Patrol page paused banner with spend, limit, and Raise the cost budget action",
|
||||
"Patrol page setup card after a backend restart: paused subtitle and Raise the cost budget action from the persisted finding cause",
|
||||
"narrow viewport: settings Patrol tab, picker, and Patrol page with no horizontal overflow"
|
||||
"Command palette open with twelve results and the first result selected at desktop and narrow widths",
|
||||
"Command palette scrolled to the last keyboard-selected result at desktop and narrow widths",
|
||||
"Command palette empty result state after a query with no matching commands at desktop and narrow widths",
|
||||
"Command palette closed after Escape and after backdrop dismissal at desktop and narrow widths"
|
||||
],
|
||||
"interactions": [
|
||||
"configured Ollama and a 20 USD budget on Provider & Models and saved",
|
||||
"opened the Patrol model picker, searched a custom route, selected anthropic:claude-sonnet-5 and observed the schedule change to once a day",
|
||||
"triggered a Patrol run against an over-budget usage history and observed the blocked status and finding",
|
||||
"restarted the backend and reloaded the Patrol page",
|
||||
"resized to 375x812 and repeated the settings and Patrol page checks"
|
||||
"opened the command palette with Control+K on the authenticated Proxmox route",
|
||||
"verified the search retained DOM focus while pointer hover and Home and End keys updated aria-activedescendant and aria-selected",
|
||||
"verified End scrolled the last selected option fully into the result viewport and Tab did not focus an option",
|
||||
"entered a no-match query and verified the combobox collapsed, cleared its active descendant, and removed the listbox",
|
||||
"dismissed the palette with Escape and reopened and dismissed it through the backdrop"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,6 +46,28 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
|
||||
}
|
||||
void props.handleDiscoveryModeChange(mode);
|
||||
};
|
||||
const handleScanScopeKeyDown = (
|
||||
event: KeyboardEvent & { currentTarget: HTMLButtonElement },
|
||||
currentMode: 'auto' | 'custom',
|
||||
) => {
|
||||
const direction =
|
||||
event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
||||
? 1
|
||||
: event.key === 'ArrowLeft' || event.key === 'ArrowUp'
|
||||
? -1
|
||||
: 0;
|
||||
if (direction === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
const modes = ['auto', 'custom'] as const;
|
||||
const currentIndex = modes.indexOf(currentMode);
|
||||
const targetMode = modes[(currentIndex + direction + modes.length) % modes.length];
|
||||
const target = event.currentTarget
|
||||
.closest('[role="radiogroup"]')
|
||||
?.querySelector<HTMLButtonElement>(`[role="radio"][data-scan-scope="${targetMode}"]`);
|
||||
selectDiscoveryMode(targetMode);
|
||||
queueMicrotask(() => target?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
@@ -111,8 +133,11 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.discoveryMode() === 'auto'}
|
||||
data-scan-scope="auto"
|
||||
tabIndex={props.discoveryMode() === 'auto' ? 0 : -1}
|
||||
disabled={scanScopeLocked()}
|
||||
onClick={() => selectDiscoveryMode('auto')}
|
||||
onKeyDown={(event) => handleScanScopeKeyDown(event, 'auto')}
|
||||
class={scanScopeOptionClass('auto')}
|
||||
>
|
||||
<span class={scanScopeIndicatorClass('auto')} aria-hidden="true">
|
||||
@@ -132,8 +157,11 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.discoveryMode() === 'custom'}
|
||||
data-scan-scope="custom"
|
||||
tabIndex={props.discoveryMode() === 'custom' ? 0 : -1}
|
||||
disabled={scanScopeLocked()}
|
||||
onClick={() => selectDiscoveryMode('custom')}
|
||||
onKeyDown={(event) => handleScanScopeKeyDown(event, 'custom')}
|
||||
class={scanScopeOptionClass('custom')}
|
||||
>
|
||||
<span class={scanScopeIndicatorClass('custom')} aria-hidden="true">
|
||||
|
||||
@@ -91,6 +91,31 @@ describe('DiscoverySettingsForm', () => {
|
||||
expect(screen.getByRole('button', { name: '192.168.1.0/24' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses one tab stop and arrow keys to move and select within the scan scope group', async () => {
|
||||
const { discoveryMode, handleDiscoveryModeChange } = renderDiscoverySettingsForm();
|
||||
|
||||
const autoScope = screen.getByRole('radio', { name: /Automatic scan \(full network scope\)/i });
|
||||
const customScope = screen.getByRole('radio', { name: /Custom subnets \(targeted\)/i });
|
||||
|
||||
expect(autoScope).toHaveAttribute('tabindex', '0');
|
||||
expect(customScope).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
autoScope.focus();
|
||||
fireEvent.keyDown(autoScope, { key: 'ArrowDown' });
|
||||
|
||||
await waitFor(() => expect(discoveryMode()).toBe('custom'));
|
||||
expect(customScope).toHaveFocus();
|
||||
expect(handleDiscoveryModeChange).toHaveBeenLastCalledWith('custom');
|
||||
expect(autoScope).toHaveAttribute('tabindex', '-1');
|
||||
expect(customScope).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(customScope, { key: 'ArrowRight' });
|
||||
|
||||
await waitFor(() => expect(discoveryMode()).toBe('auto'));
|
||||
expect(autoScope).toHaveFocus();
|
||||
expect(handleDiscoveryModeChange).toHaveBeenLastCalledWith('auto');
|
||||
});
|
||||
|
||||
it('commits a common custom subnet chip from the scan scope control', async () => {
|
||||
const { commitDiscoverySubnet, discoverySubnetDraft } = renderDiscoverySettingsForm({
|
||||
initialMode: 'custom',
|
||||
|
||||
@@ -9,9 +9,17 @@ import {
|
||||
|
||||
export type { CommandPaletteModalProps } from './useCommandPaletteState';
|
||||
|
||||
const COMMAND_PALETTE_RESULTS_ID = 'command-palette-results';
|
||||
const getCommandPaletteOptionId = (commandId: string) => `command-palette-option-${commandId}`;
|
||||
|
||||
export function CommandPaletteModal(props: CommandPaletteModalProps) {
|
||||
const commandPalette = useCommandPaletteState(props);
|
||||
|
||||
const activeOptionId = () => {
|
||||
const command = commandPalette.filteredCommands()[commandPalette.selectedIndex()];
|
||||
return command ? getCommandPaletteOptionId(command.id) : undefined;
|
||||
};
|
||||
|
||||
const handleSelect = (command: CommandPaletteModalCommand) => {
|
||||
commandPalette.handleSelect(command);
|
||||
};
|
||||
@@ -34,6 +42,11 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
|
||||
inputClass="bg-base"
|
||||
clearOnFocusedEscape={false}
|
||||
shortcutHint="Cmd+K"
|
||||
role="combobox"
|
||||
ariaAutocomplete="list"
|
||||
ariaControls={COMMAND_PALETTE_RESULTS_ID}
|
||||
ariaExpanded={commandPalette.filteredCommands().length > 0}
|
||||
ariaActiveDescendant={activeOptionId()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,15 +55,22 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
|
||||
when={commandPalette.filteredCommands().length > 0}
|
||||
fallback={<div class="px-3 py-8 text-center text-sm text-muted">No matches found.</div>}
|
||||
>
|
||||
<div role="listbox" aria-label="Command palette results">
|
||||
<div
|
||||
ref={commandPalette.setListboxRef}
|
||||
id={COMMAND_PALETTE_RESULTS_ID}
|
||||
role="listbox"
|
||||
aria-label="Command palette results"
|
||||
>
|
||||
<For each={commandPalette.filteredCommands()}>
|
||||
{(command, index) => {
|
||||
const selected = () => commandPalette.selectedIndex() === index();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={getCommandPaletteOptionId(command.id)}
|
||||
role="option"
|
||||
aria-selected={selected()}
|
||||
tabIndex={-1}
|
||||
class={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm text-base-content outline-none transition-colors hover:bg-surface-hover focus:bg-surface-hover ${
|
||||
selected() ? 'bg-surface-hover' : ''
|
||||
}`}
|
||||
|
||||
@@ -23,6 +23,9 @@ export const SearchField: Component<SearchFieldProps> = (props) => {
|
||||
aria-label={props.title ?? props.placeholder ?? 'Search'}
|
||||
role={props.role}
|
||||
aria-autocomplete={props.ariaAutocomplete}
|
||||
aria-controls={props.ariaControls}
|
||||
aria-expanded={props.ariaExpanded}
|
||||
aria-activedescendant={props.ariaActiveDescendant}
|
||||
class={`min-h-11 w-full pl-8 sm:min-h-10 sm:pl-9 ${search.inputPaddingRight()} py-1.5 sm:py-2 text-sm border border-border rounded-md
|
||||
bg-surface text-base-content placeholder-muted
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all disabled:opacity-60 disabled:cursor-not-allowed ${props.inputClass ?? ''}`}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const subtabsListClass =
|
||||
export const subtabsRailClass = 'relative min-w-0 flex-1';
|
||||
export const subtabsTrailingRowClass = 'flex flex-wrap items-center justify-between gap-3';
|
||||
export const subtabButtonClass =
|
||||
'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors sm:min-h-10 sm:py-2 sm:text-sm';
|
||||
'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:min-h-10 sm:py-2 sm:text-sm';
|
||||
export const subtabButtonActiveClass = 'border-blue-600 text-base-content';
|
||||
export const subtabButtonInactiveClass = 'border-transparent text-muted hover:text-base-content';
|
||||
export const Subtabs: Component<SubtabsProps> = (props) => {
|
||||
@@ -107,6 +107,36 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const focusTab = (currentTab: HTMLButtonElement, key: string) => {
|
||||
const enabledTabs = Array.from(
|
||||
currentTab
|
||||
.closest('[role="tablist"]')
|
||||
?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)') ?? [],
|
||||
);
|
||||
const currentIndex = enabledTabs.indexOf(currentTab);
|
||||
if (currentIndex < 0 || enabledTabs.length < 2) return;
|
||||
|
||||
let targetIndex: number;
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
targetIndex = (currentIndex - 1 + enabledTabs.length) % enabledTabs.length;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
targetIndex = (currentIndex + 1) % enabledTabs.length;
|
||||
break;
|
||||
case 'Home':
|
||||
targetIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
targetIndex = enabledTabs.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
enabledTabs[targetIndex]?.focus();
|
||||
};
|
||||
|
||||
const tablist = () => (
|
||||
<div class={subtabsRailClass}>
|
||||
<div
|
||||
@@ -126,6 +156,17 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
|
||||
tabIndex={selected() ? 0 : -1}
|
||||
disabled={tab.disabled}
|
||||
onClick={() => local.onChange(tab.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'Home' ||
|
||||
event.key === 'End'
|
||||
) {
|
||||
event.preventDefault();
|
||||
focusTab(event.currentTarget, event.key);
|
||||
}
|
||||
}}
|
||||
class={`${subtabButtonClass} ${
|
||||
selected() ? subtabButtonActiveClass : subtabButtonInactiveClass
|
||||
} ${local.tabClass ?? ''}`.trim()}
|
||||
|
||||
@@ -157,17 +157,21 @@ describe('CommandPaletteModal', () => {
|
||||
));
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command or search...');
|
||||
expect(screen.getByRole('option', { name: /Ask about Proxmox/ })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
const firstOption = screen.getByRole('option', { name: /Ask about Proxmox/ });
|
||||
expect(input).toHaveAttribute('role', 'combobox');
|
||||
expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
||||
expect(input).toHaveAttribute('aria-controls', 'command-palette-results');
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
expect(firstOption).toHaveAttribute('tabindex', '-1');
|
||||
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id);
|
||||
|
||||
await fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
|
||||
expect(screen.getByRole('option', { name: /Show Assistant commands/ })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
const secondOption = screen.getByRole('option', { name: /Show Assistant commands/ });
|
||||
expect(secondOption).toHaveAttribute('aria-selected', 'true');
|
||||
expect(input).toHaveFocus();
|
||||
expect(input).toHaveAttribute('aria-activedescendant', secondOption.id);
|
||||
|
||||
await fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
@@ -178,6 +182,48 @@ describe('CommandPaletteModal', () => {
|
||||
expect(openAssistant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the active command visible while keyboard focus remains on the combobox', async () => {
|
||||
render(() => (
|
||||
<CommandPaletteModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
platformVisibility={platformVisibility}
|
||||
/>
|
||||
));
|
||||
|
||||
const input = screen.getByRole('combobox', { name: 'Type a command or search...' });
|
||||
const secondOption = screen.getByRole('option', { name: /Show Assistant commands/ });
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(secondOption, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
|
||||
input.focus();
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
|
||||
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }));
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
|
||||
it('clears active-option semantics when filtering produces no matches', async () => {
|
||||
render(() => (
|
||||
<CommandPaletteModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
platformVisibility={platformVisibility}
|
||||
/>
|
||||
));
|
||||
|
||||
const input = screen.getByRole('combobox', { name: 'Type a command or search...' });
|
||||
fireEvent.input(input, { target: { value: 'definitely-no-command-matches-this' } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No matches found.')).toBeInTheDocument());
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(input).not.toHaveAttribute('aria-activedescendant');
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the selected command across command-list recomputes before Enter selection', async () => {
|
||||
const onClose = vi.fn();
|
||||
const openAssistant = vi.spyOn(aiChatStore, 'open').mockImplementation(() => {});
|
||||
|
||||
@@ -42,6 +42,27 @@ describe('SearchField', () => {
|
||||
expect(screen.getByPlaceholderText('Search field')).toHaveClass('sm:min-h-10');
|
||||
});
|
||||
|
||||
it('forwards combobox ownership and active-option semantics', () => {
|
||||
render(() => (
|
||||
<SearchField
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
placeholder="Command search"
|
||||
role="combobox"
|
||||
ariaAutocomplete="list"
|
||||
ariaControls="command-results"
|
||||
ariaExpanded={true}
|
||||
ariaActiveDescendant="command-result-active"
|
||||
/>
|
||||
));
|
||||
|
||||
const input = screen.getByRole('combobox', { name: 'Command search' });
|
||||
expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
||||
expect(input).toHaveAttribute('aria-controls', 'command-results');
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(input).toHaveAttribute('aria-activedescendant', 'command-result-active');
|
||||
});
|
||||
|
||||
it('reserves mobile input space for every visible trailing action', () => {
|
||||
expect(
|
||||
getSearchFieldInputPaddingRightClass({
|
||||
|
||||
@@ -77,6 +77,70 @@ describe('Subtabs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('moves focus across enabled tabs with standard tab-list keys without changing selection', () => {
|
||||
const onChange = vi.fn();
|
||||
render(() => (
|
||||
<Subtabs
|
||||
value="overview"
|
||||
onChange={onChange}
|
||||
ariaLabel="Resource detail sections"
|
||||
tabs={[
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'performance', label: 'Performance', disabled: true },
|
||||
{ value: 'history', label: 'History' },
|
||||
{ value: 'manage', label: 'Manage' },
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const overview = screen.getByRole('tab', { name: 'Overview' });
|
||||
const history = screen.getByRole('tab', { name: 'History' });
|
||||
const manage = screen.getByRole('tab', { name: 'Manage' });
|
||||
|
||||
overview.focus();
|
||||
fireEvent.keyDown(overview, { key: 'ArrowRight' });
|
||||
expect(history).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(history, { key: 'End' });
|
||||
expect(manage).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(manage, { key: 'ArrowRight' });
|
||||
expect(overview).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(overview, { key: 'ArrowLeft' });
|
||||
expect(manage).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(manage, { key: 'Home' });
|
||||
expect(overview).toHaveFocus();
|
||||
expect(overview).toHaveAttribute('aria-selected', 'true');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps keyboard-focused tabs available for manual activation', () => {
|
||||
const onChange = vi.fn();
|
||||
render(() => (
|
||||
<Subtabs
|
||||
value="overview"
|
||||
onChange={onChange}
|
||||
ariaLabel="Resource detail sections"
|
||||
tabs={[
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'history', label: 'History' },
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const overview = screen.getByRole('tab', { name: 'Overview' });
|
||||
const history = screen.getByRole('tab', { name: 'History' });
|
||||
overview.focus();
|
||||
fireEvent.keyDown(overview, { key: 'ArrowRight' });
|
||||
fireEvent.click(history);
|
||||
|
||||
expect(history).toHaveFocus();
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith('history');
|
||||
});
|
||||
|
||||
it('shows phone scroll affordances when the tab rail is clipped', async () => {
|
||||
render(() => (
|
||||
<Subtabs
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface SearchFieldProps {
|
||||
completionSuffix?: string;
|
||||
role?: JSX.InputHTMLAttributes<HTMLInputElement>['role'];
|
||||
ariaAutocomplete?: JSX.AriaAttributes['aria-autocomplete'];
|
||||
ariaControls?: JSX.AriaAttributes['aria-controls'];
|
||||
ariaExpanded?: JSX.AriaAttributes['aria-expanded'];
|
||||
ariaActiveDescendant?: JSX.AriaAttributes['aria-activedescendant'];
|
||||
}
|
||||
|
||||
export const shouldShowSearchFieldShortcutHint = (value: string, shortcutHint?: string) =>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
|
||||
const location = useLocation();
|
||||
const [query, setQuery] = createSignal('');
|
||||
const [inputRef, setInputRef] = createSignal<HTMLInputElement>();
|
||||
const [listboxRef, setListboxRef] = createSignal<HTMLDivElement>();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const assistantPageContext = createMemo(() => getAssistantPageContext(location.pathname));
|
||||
|
||||
@@ -128,6 +129,16 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
|
||||
setSelectedIndex((index) => (total > 0 ? Math.min(index, total - 1) : 0));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
selectedIndex();
|
||||
filteredCommands();
|
||||
queueMicrotask(() => {
|
||||
listboxRef()
|
||||
?.querySelector<HTMLElement>('[role="option"][aria-selected="true"]')
|
||||
?.scrollIntoView?.({ block: 'nearest' });
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
setQuery('');
|
||||
@@ -148,6 +159,7 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
setInputRef,
|
||||
setListboxRef,
|
||||
setQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -522,10 +522,10 @@ func isUnraidEmptySlot(disk agentshost.UnraidDisk) bool {
|
||||
// Unraid names every configured slot (for example disk6 or parity2), even
|
||||
// when it has never been assigned. A slot label is therefore topology, not
|
||||
// membership evidence. Preserve DISK_NP members only when native identity,
|
||||
// device, filesystem, or size evidence shows that a disk was assigned.
|
||||
// device, a concrete filesystem, or size evidence shows that a disk was assigned.
|
||||
return strings.TrimSpace(disk.Device) == "" &&
|
||||
!unraidstatus.HasMeaningfulIdentity(disk.Model, disk.Serial) &&
|
||||
strings.TrimSpace(disk.Filesystem) == "" &&
|
||||
!unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) &&
|
||||
disk.SizeBytes == 0
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ diskNumber.5=5
|
||||
diskName.5=disk5
|
||||
diskSize.5=0
|
||||
diskId.5=ata-_
|
||||
diskFsType.5=auto
|
||||
rdevStatus.5=DISK_NP
|
||||
rdevName.5=
|
||||
rdevId.5=ata-_
|
||||
@@ -161,6 +162,7 @@ diskNumber.29=29
|
||||
diskName.29=parity2
|
||||
diskSize.29=0
|
||||
diskId.29=ata-_
|
||||
diskFsType.29=auto
|
||||
rdevStatus.29=DISK_NP_DSBL
|
||||
rdevName.29=
|
||||
rdevId.29=ata-_
|
||||
@@ -286,6 +288,7 @@ id="ata-_"
|
||||
size="0"
|
||||
status="DISK_NP"
|
||||
type="Data"
|
||||
fsType="auto"
|
||||
["parity2"]
|
||||
idx="29"
|
||||
name="parity2"
|
||||
@@ -294,6 +297,7 @@ id="ata-_"
|
||||
size="0"
|
||||
status="DISK_NP_DSBL"
|
||||
type="Parity"
|
||||
fsType="auto"
|
||||
`
|
||||
|
||||
disks := parseUnraidDisksINI(input)
|
||||
|
||||
@@ -4147,7 +4147,7 @@ func isLegacyUnraidEmptySlot(disk agentshost.UnraidDisk, normalizedStatus string
|
||||
}
|
||||
return strings.TrimSpace(disk.Device) == "" &&
|
||||
!unraidstatus.HasMeaningfulIdentity(disk.Model, disk.Serial) &&
|
||||
strings.TrimSpace(disk.Filesystem) == "" &&
|
||||
!unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) &&
|
||||
disk.SizeBytes == 0
|
||||
}
|
||||
|
||||
|
||||
@@ -2588,8 +2588,8 @@ func TestApplyHostReportFiltersLegacyUnraidEmptySlots(t *testing.T) {
|
||||
Disks: []agentshost.UnraidDisk{
|
||||
{Name: "parity", Device: "/dev/sdb", Role: "parity", RawStatus: "DISK_OK", SizeBytes: 5860522532},
|
||||
{Name: "disk1", Device: "/dev/sde", Role: "data", RawStatus: "DISK_OK", SizeBytes: 5860522532},
|
||||
{Name: "disk6", Role: "data", RawStatus: "DISK_NP", Model: "ata -", Serial: "ata-_", Slot: 6},
|
||||
{Name: "parity2", Role: "parity", RawStatus: "DISK_NP_DSBL", Model: "ata -", Serial: "ata-_", Slot: 29},
|
||||
{Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Model: "ata -", Serial: "ata-_", Filesystem: "auto", Slot: 6},
|
||||
{Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Model: "ata -", Serial: "ata-_", Filesystem: "auto", Slot: 29},
|
||||
},
|
||||
},
|
||||
Timestamp: time.Now().UTC(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
unraidstatus "github.com/rcourtman/pulse-go-rewrite/internal/unraid"
|
||||
)
|
||||
|
||||
func AssessHostRAIDArray(array models.HostRAIDArray) Assessment {
|
||||
@@ -346,13 +347,16 @@ func unraidDiskStateCounts(storage models.HostUnraidStorage) (disabled, invalid,
|
||||
func isUnraidEmptySlot(disk models.HostUnraidDisk) bool {
|
||||
rawStatus := strings.ToUpper(strings.TrimSpace(disk.RawStatus))
|
||||
status := strings.ToLower(strings.TrimSpace(disk.Status))
|
||||
if unraidstatus.IsExplicitMissingMember(rawStatus) {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(rawStatus, "DISK_NP") && status != "missing" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(disk.Device) == "" &&
|
||||
strings.TrimSpace(disk.Model) == "" &&
|
||||
strings.TrimSpace(disk.Serial) == "" &&
|
||||
strings.TrimSpace(disk.Filesystem) == "" &&
|
||||
!unraidstatus.HasMeaningfulFilesystem(disk.Filesystem) &&
|
||||
disk.SizeBytes == 0
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,8 @@ func TestAssessUnraidStorageTreatsEmptyNoPresentSlotsAsUnprotected(t *testing.T)
|
||||
{Name: "parity", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL"},
|
||||
{Name: "md1p1", Device: "/dev/sde", Status: "online", RawStatus: "DISK_OK", SizeBytes: 5860522532},
|
||||
{Name: "disk5", Role: "data", Status: "missing", RawStatus: "DISK_NP", Slot: 5},
|
||||
{Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Slot: 29},
|
||||
{Name: "disk6", Role: "data", Status: "missing", RawStatus: "DISK_NP", Filesystem: "auto", Slot: 6},
|
||||
{Name: "parity2", Role: "parity", Status: "missing", RawStatus: "DISK_NP_DSBL", Filesystem: "auto", Slot: 29},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -281,6 +282,27 @@ func TestAssessUnraidStorageUsesDiskStatusesOverAggregateCounters(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessUnraidStoragePreservesExplicitMissingMemberWithoutIdentity(t *testing.T) {
|
||||
assessment := AssessUnraidStorage(models.HostUnraidStorage{
|
||||
ArrayStarted: true,
|
||||
Disks: []models.HostUnraidDisk{
|
||||
{Name: "parity", Role: "parity", Status: "online"},
|
||||
{Name: "disk1", Role: "data", Status: "online"},
|
||||
{Name: "disk2", Role: "data", Status: "missing", RawStatus: "DISK_NP_MISSING", Filesystem: "auto"},
|
||||
},
|
||||
})
|
||||
|
||||
if assessment.Level != RiskCritical {
|
||||
t.Fatalf("Level = %q, want %q", assessment.Level, RiskCritical)
|
||||
}
|
||||
for _, reason := range assessment.Reasons {
|
||||
if reason.Code == "unraid_missing_disks" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("explicit missing member without identity was not preserved: %+v", assessment.Reasons)
|
||||
}
|
||||
|
||||
func TestAssessUnraidStoragePreservesGenuineStructuredMissingDisk(t *testing.T) {
|
||||
assessment := AssessUnraidStorage(models.HostUnraidStorage{
|
||||
ArrayStarted: true,
|
||||
|
||||
@@ -31,6 +31,15 @@ func HasMeaningfulIdentity(model, serial string) bool {
|
||||
return NormalizeNativeIdentity(model) != "" || NormalizeNativeIdentity(serial) != ""
|
||||
}
|
||||
|
||||
// HasMeaningfulFilesystem reports whether a native filesystem field is
|
||||
// evidence that a slot has a disk assigned. Unraid emits "auto" for configured
|
||||
// but empty DISK_NP slots, so presence of that value alone cannot establish
|
||||
// membership.
|
||||
func HasMeaningfulFilesystem(filesystem string) bool {
|
||||
filesystem = strings.TrimSpace(filesystem)
|
||||
return filesystem != "" && !strings.EqualFold(filesystem, "auto")
|
||||
}
|
||||
|
||||
// IsExplicitMissingMember reports Unraid's provider-owned status for a slot
|
||||
// that was assigned but whose device is no longer present. Plain DISK_NP means
|
||||
// no device is assigned and must not be treated as equivalent.
|
||||
|
||||
@@ -35,3 +35,18 @@ func TestIsExplicitMissingMember(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasMeaningfulFilesystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, filesystem := range []string{"", "auto", " AUTO "} {
|
||||
if HasMeaningfulFilesystem(filesystem) {
|
||||
t.Errorf("%q must not establish Unraid disk assignment", filesystem)
|
||||
}
|
||||
}
|
||||
for _, filesystem := range []string{"xfs", "btrfs", "luks:xfs"} {
|
||||
if !HasMeaningfulFilesystem(filesystem) {
|
||||
t.Errorf("%q must establish Unraid disk assignment", filesystem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,56 @@ test.describe("Infrastructure onboarding", () => {
|
||||
await expect(page).toHaveURL(/\/settings\/infrastructure(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test("discovery scan scope uses radio-group keyboard navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await prepareOnboardingPage(page);
|
||||
await page.route("**/api/system/settings", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
body.discoveryEnabled = true;
|
||||
body.discoverySubnet = "auto";
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
await page.goto("/settings/infrastructure", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const discoveryBand = page.getByRole("region", {
|
||||
name: "Discover Proxmox systems",
|
||||
});
|
||||
await discoveryBand
|
||||
.getByRole("button", { name: "Settings", exact: true })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Discovery settings" });
|
||||
const automaticScope = dialog.getByRole("radio", {
|
||||
name: /Automatic scan \(full network scope\)/i,
|
||||
});
|
||||
const customScope = dialog.getByRole("radio", {
|
||||
name: /Custom subnets \(targeted\)/i,
|
||||
});
|
||||
|
||||
await expect(automaticScope).toHaveAttribute("tabindex", "0");
|
||||
await expect(customScope).toHaveAttribute("tabindex", "-1");
|
||||
|
||||
await automaticScope.focus();
|
||||
await automaticScope.press("ArrowDown");
|
||||
|
||||
await expect(customScope).toBeFocused();
|
||||
await expect(customScope).toHaveAttribute("aria-checked", "true");
|
||||
await expect(customScope).toHaveAttribute("tabindex", "0");
|
||||
await expect(automaticScope).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
test("desktop picker add opens the matching modal", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user