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
45 changes: 45 additions & 0 deletions frontend/src/components/panels/ButtonRemapPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,51 @@ describe("ButtonRemapPanel", () => {
expect(screen.getByRole("status")).toHaveTextContent("draft pending confirmation");
});

it("groups multimedia actions and disables them only for Button 1", () => {
const onStage = vi.fn();
const multimediaRemap = {
...remap,
Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"],
};
render(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={onStage} />);

const selectors = screen.getAllByRole("combobox");
fireEvent.click(selectors[0]);
expect(screen.getByRole("group", { name: "Multimedia" })).toBeInTheDocument();
const disabledMediaPlayer = screen.getByRole("option", { name: "Media Player" });
expect(disabledMediaPlayer).toHaveAttribute("aria-disabled", "true");
fireEvent.click(disabledMediaPlayer);
expect(onStage).not.toHaveBeenCalled();
fireEvent.keyDown(selectors[0], { key: "End" });
expect(selectors[0]).toHaveAttribute("aria-activedescendant", expect.stringMatching(/-opt-7$/));

fireEvent.click(selectors[1]);
const enabledMediaPlayer = Array.from(screen.getByRole("listbox", { name: "Button 2 action" }).querySelectorAll('[role="option"]')).find((option) => option.textContent === "Media Player")!;
expect(enabledMediaPlayer).toHaveAttribute("aria-disabled", "false");
fireEvent.click(enabledMediaPlayer);
expect(onStage).toHaveBeenCalledWith(2, "media_player");
});

it("preserves ordered groups and disabled semantics for all seven selectors", () => {
const multimediaRemap = {
...remap,
Actions: [...remap.Actions, "media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"],
};
render(<ButtonRemapPanel remap={multimediaRemap as never} ready onStage={vi.fn()} />);

const selectors = screen.getAllByRole("combobox");
selectors.forEach((selector) => fireEvent.click(selector));
const listboxes = screen.getAllByRole("listbox");
expect(listboxes).toHaveLength(7);
listboxes.forEach((listbox, index) => {
const groups = Array.from(listbox.querySelectorAll('[role="group"]')).map((group) => group.getAttribute("aria-label"));
expect(groups).toEqual(["Basic", "Multimedia"]);
const multimedia = Array.from(listbox.querySelectorAll('[role="group"][aria-label="Multimedia"] [role="option"]'));
expect(multimedia.map((option) => option.textContent)).toEqual(["Media Player", "Play/Pause", "Stop", "Previous Track", "Next Track", "Volume Up", "Volume Down", "Mute"]);
expect(multimedia.every((option) => option.getAttribute("aria-disabled") === (index === 0 ? "true" : "false"))).toBe(true);
});
});

it("applies on Enter and discards on Escape only while the panel is focused", () => {
const onApply = vi.fn();
const onDiscard = vi.fn();
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/components/panels/ButtonRemapPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { GnomeSelect } from "./GnomeSelect";

type Action = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire";
type Action = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire" | "media_player" | "play_pause" | "stop" | "previous_track" | "next_track" | "volume_up" | "volume_down" | "mute";
type Button = { Button: number; Action: Action | null; PreservedDefault: string };
type Remap = {
Pending: { Buttons: Button[] };
Expand All @@ -20,8 +20,18 @@ const labelFor = (action: Action) =>
backward: "Backward",
double_click: "Double Click",
fire: "Fire",
media_player: "Media Player",
play_pause: "Play/Pause",
stop: "Stop",
previous_track: "Previous Track",
next_track: "Next Track",
volume_up: "Volume Up",
volume_down: "Volume Down",
mute: "Mute",
})[action];

const isMultimedia = (action: Action) => ["media_player", "play_pause", "stop", "previous_track", "next_track", "volume_up", "volume_down", "mute"].includes(action);

export function ButtonRemapPanel({
remap,
ready,
Expand Down Expand Up @@ -74,8 +84,13 @@ export function ButtonRemapPanel({
options={remap.Actions.map((action) => ({
value: action,
label: labelFor(action),
group: isMultimedia(action) ? "Multimedia" : "Basic",
disabled: button.Button === 1 && isMultimedia(action),
}))}
onChange={(val) => onStage(button.Button, val as Action)}
onChange={(val) => {
const action = val as Action;
if (!(button.Button === 1 && isMultimedia(action))) onStage(button.Button, action);
}}
/>
</div>
))}
Expand Down
104 changes: 57 additions & 47 deletions frontend/src/components/panels/GnomeSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
export type GnomeSelectOption = {
value: string;
label: string;
group?: string;
disabled?: boolean;
};

type IndexedOption = GnomeSelectOption & { index: number };
type OptionGroup = { name?: string; options: IndexedOption[] };

export type GnomeSelectProps = {
id?: string;
"aria-label": string;
Expand Down Expand Up @@ -32,70 +37,73 @@ export function GnomeSelect({
const selectedIndex = options.findIndex((opt) => opt.value === value);
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
const [activeIndex, setActiveIndex] = useState(currentIndex);

const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
const groupedOptions = options.reduce<OptionGroup[]>((groups, option, index) => {
const group = groups.at(-1);
if (group?.name === option.group) group.options.push({ ...option, index });
else groups.push({ name: option.group, options: [{ ...option, index }] });
return groups;
}, []);

useEffect(() => {
if (!open) {
setActiveIndex(currentIndex);
const nextEnabledIndex = (start: number, direction: number) => {
for (let step = 1; step <= options.length; step++) {
const index = (start + direction * step + options.length) % options.length;
if (!options[index].disabled) return index;
}
return start;
};

useEffect(() => {
if (!open) setActiveIndex(currentIndex);
}, [currentIndex, open]);

useEffect(() => {
if (!open) return;
const closeOnOutsideClick = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", closeOnOutsideClick);
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
}, [open]);

const handleSelect = (val: string) => {
onChange(val);
const handleSelect = (option: GnomeSelectOption) => {
if (option.disabled) return;
onChange(option.value);
setOpen(false);
};

const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;

if (event.key === "Escape") {
if (open) {
event.preventDefault();
setOpen(false);
}
return;
}

if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(currentIndex);
} else if (options[activeIndex]) {
handleSelect(options[activeIndex].value);
}
} else if (options[activeIndex]) handleSelect(options[activeIndex]);
return;
}

if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(currentIndex);
return;
}
const direction = event.key === "ArrowDown" ? 1 : -1;
setActiveIndex((prev) =>
options.length ? (prev + direction + options.length) % options.length : 0
);
setActiveIndex((previous) => nextEnabledIndex(previous, event.key === "ArrowDown" ? 1 : -1));
return;
}

if (open && (event.key === "Home" || event.key === "End")) {
event.preventDefault();
setActiveIndex(event.key === "Home" ? 0 : Math.max(0, options.length - 1));
const start = event.key === "Home" ? -1 : 0;
const direction = event.key === "Home" ? 1 : -1;
setActiveIndex(nextEnabledIndex(start, direction));
}
};

Expand All @@ -114,44 +122,46 @@ export function GnomeSelect({
disabled={disabled}
onClick={() => {
if (!disabled) {
setOpen((prev) => !prev);
setOpen((previous) => !previous);
setActiveIndex(currentIndex);
}
}}
onKeyDown={handleKeyDown}
>
<span className="gnome-select-value">
{selectedOption?.label ?? placeholder ?? value}
</span>
<span className="gnome-select-value">{selectedOption?.label ?? placeholder ?? value}</span>
<span className="gnome-select-arrow" aria-hidden="true">▾</span>
</button>

{open && (
<div
id={listboxId}
className="gnome-select-popup"
role="listbox"
aria-label={ariaLabel}
>
{options.map((opt, index) => {
const isSelected = opt.value === value;
const isActive = index === activeIndex;
return (
<div
id={`${listboxId}-opt-${index}`}
key={opt.value}
role="option"
aria-selected={isSelected}
className={`gnome-select-option ${isSelected ? "selected" : ""} ${isActive ? "active" : ""}`}
onClick={() => handleSelect(opt.value)}
onMouseEnter={() => setActiveIndex(index)}
>
{opt.label}
<div id={listboxId} className="gnome-select-popup" role="listbox" aria-label={ariaLabel}>
{groupedOptions.map((group) => (
group.name ? (
<div key={group.name} role="group" aria-label={group.name}>
<div className="gnome-select-group-label">{group.name}</div>
{group.options.map((option) => <Option key={option.value} option={option} />)}
</div>
);
})}
) : group.options.map((option) => <Option key={option.value} option={option} />)
))}
</div>
)}
</div>
);

function Option({ option }: { option: IndexedOption }) {
const isSelected = option.value === value;
const isActive = option.index === activeIndex;
return (
<div
id={`${listboxId}-opt-${option.index}`}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled ?? false}
className={`gnome-select-option ${isSelected ? "selected" : ""} ${isActive ? "active" : ""} ${option.disabled ? "disabled" : ""}`}
onClick={() => handleSelect(option)}
onMouseEnter={() => !option.disabled && setActiveIndex(option.index)}
>
{option.label}
</div>
);
}
}
2 changes: 1 addition & 1 deletion frontend/src/desktop-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type LightingSpeedVariant = { TemplateID: string };
export type LightingColorTemplate = { TemplateID: string; CSSColor: string };
export type LightingEffect = { Mode: LightingMode; Label: string; DefaultTemplateID: string; SpeedVariants: LightingSpeedVariant[]; ColorTemplates: LightingColorTemplate[] };
export type LightingSnapshot = { Pending: LightingSelection; Applied: LightingSelection | null; Effects: LightingEffect[]; Revision: number; Firmware: string; Error: { Code: string } };
export type RemapAction = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire";
export type RemapAction = "off" | "left" | "right" | "middle" | "forward" | "backward" | "double_click" | "fire" | "media_player" | "play_pause" | "stop" | "previous_track" | "next_track" | "volume_up" | "volume_down" | "mute";
export type RemapButton = { Button: number; Action: RemapAction | null; PreservedDefault: "" | "DPI+" | "DPI-" };
export type RemapConfig = { Buttons: RemapButton[] };
export type RemapSnapshot = { Pending: RemapConfig; Applied: RemapConfig; Factory: RemapConfig; Actions: RemapAction[]; Revision: number; Firmware: string; Persistence: string; RetryAvailable: boolean; Error: { Code: string } };
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/styles/controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,25 @@ select option {
font-weight: 500;
}

.gnome-select-group-label {
padding: 8px 10px 4px;
color: var(--dim);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}

.gnome-select-option.disabled {
color: var(--dim);
cursor: not-allowed;
opacity: 0.55;
}

.gnome-select-option.disabled:hover,
.gnome-select-option.disabled.active {
background: transparent;
}

/* Buttons & Actions */
.actions {
display: flex;
Expand Down
Loading