diff --git a/CHANGELOG.md b/CHANGELOG.md
index 215a5e7b975..c00737eb612 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ Firmware: 18.0.0 [[release](https://github.com/UltimateHackingKeyboard/firmware/
- Make host connection management slot-focused instead of host-focused.
- Group macros in the sidebar
- Fix: refuse importing a user configuration newer than Agent supports, and refuse saving one newer than the firmware supports.
+- Improve the scancode selector with a tiled category layout.
## [10.1.0] - 2026-06-23
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html
index 394783ce33d..018f3a473fe 100644
--- a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html
+++ b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html
@@ -1,32 +1,14 @@
Scancode:
-
-
-
- {{ item.text }}
-
- {{ item.additional?.explanation}}
-
-
-
-
-
- {{ addTagText(search) }}
-
-
+
Labels are shown according to en-US character-to-scancode mapping. This means that output may differ from the label if your computer uses layout different from en-US. In such case, you need to pick the character of the desired key according to the en-US layout.
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts
index 809dd2dd5f3..3e4abb65fde 100644
--- a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts
+++ b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts
@@ -7,11 +7,9 @@ import {
OnChanges,
Output,
SimpleChanges,
- ViewChild,
inject,
} from '@angular/core';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
-import { NgSelectComponent } from '@ng-select/ng-select';
import { copyRgbColor, KeyAction, KeystrokeAction, KeystrokeType, SCANCODES, SecondaryRoleAction } from 'uhk-common';
import { Tab } from '../tab';
@@ -20,14 +18,7 @@ import { SelectOptionData } from '../../../../models/select-option-data';
import { KeyModifierModel } from '../../../../models/key-modifier-model';
import { mapLeftRightModifierToKeyActionModifier } from '../../../../util';
import { RemapInfo } from '../../../../models/remap-info';
-
-interface FlatOptions {
- id: string;
- text: string;
- group?: string;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- additional?: any;
-}
+import { ScancodeSelectOption } from './scancode-select';
interface SearchResult {
isMatch: boolean;
@@ -49,15 +40,14 @@ export class KeypressTabComponent extends Tab implements OnChanges {
@Input() secondaryRoleOptions: SelectOptionData[];
@Output() keyActionChange = new EventEmitter();
- @ViewChild('scancodeSelect', { static: true }) scancodeSelect: NgSelectComponent;
leftModifiers: KeyModifierModel[];
rightModifiers: KeyModifierModel[];
- scanCodeGroups: Array;
- secondaryRoleGroups: Array = [];
+ scanCodeGroups: Array;
+ secondaryRoleGroups: Array = [];
- selectedScancodeOption: FlatOptions;
+ selectedScancodeOption: ScancodeSelectOption;
selectedSecondaryRoleIndex: number;
warningVisible: boolean;
faInfoCircle = faInfoCircle;
@@ -123,7 +113,6 @@ export class KeypressTabComponent extends Tab implements OnChanges {
this.leftModifiers = event.left;
this.rightModifiers = event.right;
this.keyActionChanged();
- this.scancodeSelect.writeValue(this.selectedScancodeOption.text || '');
}
fromKeyAction(keyAction: KeyAction): boolean {
@@ -189,7 +178,11 @@ export class KeypressTabComponent extends Tab implements OnChanges {
this.keyActionChanged();
}
- addTagFn (name: string): FlatOptions | boolean {
+ addTag = (name: string): ScancodeSelectOption | boolean => this.addTagFn(name);
+
+ getAddTagText = (term: string): string => this.addTagText(term);
+
+ addTagFn (name: string): ScancodeSelectOption | boolean {
const mediaSearchResult = isMediaSearch(name);
if (mediaSearchResult.isMatch) {
const option = {
@@ -242,33 +235,39 @@ export class KeypressTabComponent extends Tab implements OnChanges {
}
addTagText(term: string): string {
- const mediaSearchResult = isMediaSearch(term);
+ const normalizedTerm = term.trim();
+ if (!normalizedTerm) {
+ return '';
+ }
- if (mediaSearchResult.isMatch &&
- !this.scanCodeGroups
- .some(x => x.additional?.type === 'media' && x.additional?.scancode === mediaSearchResult.scancode)) {
- return `Media scancode: ${mediaSearchResult.scancode}`;
+ // Allow M/B/S tags whenever that exact id is new. A named key may already
+ // use the same scancode (e.g. letter "B" is basic scancode 5), but users
+ // still need to be able to add "B5" as an explicit custom entry.
+ const idExists = this.scanCodeGroups
+ .some(option => option.id.toLowerCase() === normalizedTerm.toLowerCase());
+ if (idExists) {
+ return '';
}
- const basicSearchResult = isBasicSearch(term);
+ const mediaSearchResult = isMediaSearch(normalizedTerm);
+ if (mediaSearchResult.isMatch) {
+ return `Media scancode: ${mediaSearchResult.scancode}`;
+ }
- if (basicSearchResult.isMatch &&
- !this.scanCodeGroups
- .some(x => x.additional?.type === 'basic' && x.additional?.scancode === basicSearchResult.scancode)) {
+ const basicSearchResult = isBasicSearch(normalizedTerm);
+ if (basicSearchResult.isMatch) {
return `Basic scancode: ${basicSearchResult.scancode}`;
}
- const systemSearchResult = isSystemSearch(term);
- if (systemSearchResult.isMatch &&
- !this.scanCodeGroups
- .some(x => x.additional?.type === 'system' && x.additional?.scancode === systemSearchResult.scancode)) {
+ const systemSearchResult = isSystemSearch(normalizedTerm);
+ if (systemSearchResult.isMatch) {
return `System scancode: ${systemSearchResult.scancode}`;
}
return '';
}
- searchFn(term: string, item: FlatOptions) {
+ searchFn = (term: string, item: ScancodeSelectOption): boolean => {
term = term.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
if (new RegExp(term, 'i').test(item.text)) {
return true;
@@ -293,7 +292,7 @@ export class KeypressTabComponent extends Tab implements OnChanges {
}
return false;
- }
+ };
modifiersTrackBy(index: number, modifier: KeyModifierModel): string {
return `${modifier.value}${modifier.checked}`;
@@ -306,12 +305,12 @@ export class KeypressTabComponent extends Tab implements OnChanges {
this.cdRef.markForCheck();
}
- private findScancodeOptionById(id: string): FlatOptions {
+ private findScancodeOptionById(id: string): ScancodeSelectOption {
return this.scanCodeGroups.find(scancode => scancode.id === id) ||
- this.addTagFn(id) as FlatOptions;
+ this.addTagFn(id) as ScancodeSelectOption;
}
- private findScancodeOptionByScancode(scancode: number, type: KeystrokeType): FlatOptions {
+ private findScancodeOptionByScancode(scancode: number, type: KeystrokeType): ScancodeSelectOption {
const typeToFind: string =
(type === KeystrokeType.shortMedia || type === KeystrokeType.longMedia) ? 'media' : KeystrokeType[type];
const option = this.scanCodeGroups.find(x => x.additional.scancode === scancode && x.additional.type === typeToFind);
@@ -322,20 +321,20 @@ export class KeypressTabComponent extends Tab implements OnChanges {
switch (typeToFind) {
case 'media':
- return this.addTagFn(`M${scancode}`) as FlatOptions;
+ return this.addTagFn(`M${scancode}`) as ScancodeSelectOption;
case 'basic':
- return this.addTagFn(`B${scancode}`) as FlatOptions;
+ return this.addTagFn(`B${scancode}`) as ScancodeSelectOption;
case 'system':
- return this.addTagFn(`S${scancode}`) as FlatOptions;
+ return this.addTagFn(`S${scancode}`) as ScancodeSelectOption;
default:
break;
}
}
- private toScancodeTypePair(option: FlatOptions): [number, string] {
+ private toScancodeTypePair(option: ScancodeSelectOption): [number, string] {
if (!option) {
return [0, 'basic'];
}
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts
new file mode 100644
index 00000000000..a8908fe8163
--- /dev/null
+++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts
@@ -0,0 +1 @@
+export * from './scancode-select.component';
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html
new file mode 100644
index 00000000000..6fc0f79d162
--- /dev/null
+++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html
@@ -0,0 +1,130 @@
+
+
+
+
+ {{ displayText }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss
new file mode 100644
index 00000000000..55119565b76
--- /dev/null
+++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss
@@ -0,0 +1,166 @@
+:host {
+ display: block;
+ width: 100%;
+}
+
+.scancode-select {
+ position: relative;
+}
+
+.scancode-select__control {
+ display: flex;
+ align-items: center;
+ min-height: 1.75rem;
+ border: 1px solid var(--color-input-border);
+ border-radius: 0.2rem;
+ background-color: var(--color-input-bg, var(--color-popover-bg-light));
+ cursor: pointer;
+}
+
+.scancode-select--open .scancode-select__control {
+ border-color: var(--bs-primary, #0d6efd);
+}
+
+.scancode-select__value-container {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0 0.5rem;
+}
+
+.scancode-select__value {
+ font-size: 14px;
+ line-height: 1.5;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--color-input-text, inherit);
+}
+
+.scancode-select__arrow-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ flex: 0 0 auto;
+}
+
+.scancode-select__arrow {
+ border-color: #999 transparent transparent;
+ border-style: solid;
+ border-width: 4px 4px 2.5px;
+}
+
+.scancode-select--open .scancode-select__arrow {
+ border-width: 0 4px 4px;
+ border-color: transparent transparent #999;
+}
+
+.scancode-select__panel {
+ display: flex;
+ flex-direction: column;
+ width: min(92vw, 20rem);
+ border: 1px solid var(--color-input-border);
+ border-radius: 0.2rem;
+ background-color: var(--color-input-bg, var(--color-popover-bg-light));
+ box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
+ overflow: hidden;
+}
+
+.scancode-select__search {
+ flex: 0 0 auto;
+ padding: 0.35rem 0.5rem;
+ border-bottom: 1px solid var(--color-input-border);
+}
+
+.scancode-select__input {
+ width: 100%;
+ border: 1px solid var(--color-input-border);
+ border-radius: 0.2rem;
+ outline: none;
+ background: var(--color-input-bg, var(--color-popover-bg-light));
+ font-size: 14px;
+ line-height: 1.5;
+ padding: 0.15rem 0.4rem;
+ color: var(--color-input-text, inherit);
+}
+
+.scancode-select__panel-scroll {
+ overflow: auto;
+ padding: 0.25rem 0.35rem 0.5rem;
+}
+
+.scancode-select__ungrouped {
+ margin-bottom: 0.25rem;
+}
+
+.scancode-select__group {
+ margin-bottom: 0.35rem;
+}
+
+.scancode-select__group-header {
+ font-weight: 700;
+ padding: 0.15rem 0.35rem;
+ white-space: nowrap;
+ color: var(--color-input-text, inherit);
+}
+
+.scancode-select__option {
+ display: block;
+ width: 100%;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ padding: 1px 0.5rem;
+ font-size: 14px;
+ line-height: 1.4;
+ white-space: nowrap;
+ color: var(--color-input-text, inherit);
+ cursor: pointer;
+}
+
+.scancode-select__tile-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.scancode-select__tile-row {
+ display: grid;
+ gap: 1px;
+}
+
+.scancode-select__tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr));
+ gap: 1px;
+}
+
+.scancode-select__group--tiled .scancode-select__option {
+ width: auto;
+ min-height: 1.5rem;
+ padding: 0.1rem 0.2rem;
+ text-align: center;
+ border-radius: 0.15rem;
+}
+
+.scancode-select__option--marked,
+.scancode-select__option:hover {
+ background-color: var(--color-select-hover-bg, var(--color-select-active-bg));
+ color: var(--color-select-hover-text, var(--color-select-active-text));
+}
+
+.scancode-select__option--selected {
+ background-color: var(--color-select-active-bg);
+ color: var(--color-select-active-text);
+}
+
+.scancode-select__tag {
+ margin-top: 0.25rem;
+ font-style: italic;
+}
+
+.scancode-select__empty {
+ padding: 0.5rem;
+ color: var(--color-popover-label-disabled);
+}
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts
new file mode 100644
index 00000000000..f28aa6f36ff
--- /dev/null
+++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts
@@ -0,0 +1,386 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ EventEmitter,
+ HostListener,
+ Input,
+ OnChanges,
+ Output,
+ SimpleChanges,
+ ViewChild,
+ inject,
+} from '@angular/core';
+import { ConnectedPosition } from '@angular/cdk/overlay';
+
+export interface ScancodeSelectOption {
+ id: string;
+ text: string;
+ group?: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ additional?: any;
+}
+
+export interface ScancodeSelectGroup {
+ name: string;
+ options: ScancodeSelectOption[];
+ /**
+ * Explicit tile rows (Numpad). When empty, tiled groups use a wrapping grid
+ * over `options` instead.
+ */
+ tileRows: ScancodeSelectOption[][];
+ tiled: boolean;
+}
+
+/** Categories with short labels that render as a compact tile grid. */
+const TILED_GROUPS = new Set([
+ 'Function',
+ 'Letter',
+ 'Number',
+ 'Numpad',
+ 'Punctuation',
+ 'Whitespace',
+]);
+
+/** Numpad display rows, in order. */
+const NUMPAD_TILE_ROWS = [
+ ['0', '1', '2', '3', '4'],
+ ['5', '6', '7', '8', '9'],
+ ['/', '*', '-', '+', '.'],
+ ['Enter', 'NumLock'],
+];
+
+const MAX_HEIGHT_OFFSET = 20;
+const SEARCH_BAR_HEIGHT = 44;
+
+@Component({
+ selector: 'scancode-select',
+ standalone: false,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ templateUrl: './scancode-select.component.html',
+ styleUrls: ['./scancode-select.component.scss'],
+})
+export class ScancodeSelectComponent implements OnChanges {
+ @Input() items: ScancodeSelectOption[] = [];
+ @Input() selectedId = '';
+ @Input() searchFn: ((term: string, item: ScancodeSelectOption) => boolean) | null = null;
+ @Input() addTag: ((term: string) => ScancodeSelectOption | boolean) | null = null;
+ @Input() addTagText: ((term: string) => string) | null = null;
+
+ @Output() selectedIdChange = new EventEmitter();
+
+ @ViewChild('searchInput') searchInput?: ElementRef;
+
+ isOpen = false;
+ inputValue = '';
+ searchTerm = '';
+ markedIndex = 0;
+ panelMaxHeight = 240;
+ ungroupedOptions: ScancodeSelectOption[] = [];
+ groups: ScancodeSelectGroup[] = [];
+ tagLabel = '';
+ readonly overlayPositions: ConnectedPosition[] = [
+ {
+ originX: 'start',
+ originY: 'bottom',
+ overlayX: 'start',
+ overlayY: 'top',
+ offsetY: 2,
+ },
+ {
+ originX: 'start',
+ originY: 'top',
+ overlayX: 'start',
+ overlayY: 'bottom',
+ offsetY: -2,
+ },
+ ];
+
+ private readonly cdRef = inject(ChangeDetectorRef);
+ private readonly host = inject(ElementRef);
+
+ get selectedOption(): ScancodeSelectOption | undefined {
+ return this.items.find(item => item.id === this.selectedId);
+ }
+
+ get displayText(): string {
+ return this.selectedOption?.text ?? '';
+ }
+
+ get flatOptions(): ScancodeSelectOption[] {
+ return [
+ ...this.ungroupedOptions,
+ ...this.groups.flatMap(group => group.options),
+ ];
+ }
+
+ get showTag(): boolean {
+ return !!this.tagLabel;
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.items || changes.selectedId) {
+ this.rebuildFilteredView();
+ }
+ }
+
+ open(): void {
+ if (this.isOpen) {
+ return;
+ }
+
+ this.isOpen = true;
+ // Show every category on open; filtering starts when the user types.
+ this.inputValue = this.displayText;
+ this.searchTerm = '';
+ this.rebuildFilteredView();
+ this.markSelectedOption();
+ this.updatePanelMaxHeight();
+ this.cdRef.markForCheck();
+
+ setTimeout(() => {
+ const input = this.searchInput?.nativeElement;
+ if (input) {
+ input.focus();
+ input.select();
+ }
+ });
+ }
+
+ close(): void {
+ if (!this.isOpen) {
+ return;
+ }
+
+ this.isOpen = false;
+ this.inputValue = '';
+ this.searchTerm = '';
+ this.rebuildFilteredView();
+ this.cdRef.markForCheck();
+ }
+
+ toggle(): void {
+ if (this.isOpen) {
+ this.close();
+ } else {
+ this.open();
+ }
+ }
+
+ onSearchInput(value: string): void {
+ this.inputValue = value;
+ this.searchTerm = value;
+ this.rebuildFilteredView();
+ // Prefer the custom M/B/S tag on Enter when the term is an exact custom
+ // scancode pattern (e.g. B5), even if a named key shares that scancode.
+ this.markedIndex = this.showTag ? this.flatOptions.length : 0;
+ this.cdRef.markForCheck();
+ }
+
+ onOptionMouseEnter(option: ScancodeSelectOption): void {
+ const index = this.flatOptions.findIndex(item => item.id === option.id);
+ if (index >= 0) {
+ this.markedIndex = index;
+ }
+ }
+
+ onTagMouseEnter(): void {
+ this.markedIndex = this.flatOptions.length;
+ }
+
+ isMarked(option: ScancodeSelectOption): boolean {
+ return this.flatOptions[this.markedIndex]?.id === option.id;
+ }
+
+ isTagMarked(): boolean {
+ return this.showTag && this.markedIndex === this.flatOptions.length;
+ }
+
+ selectOption(option: ScancodeSelectOption): void {
+ this.selectedIdChange.emit(option.id);
+ this.close();
+ }
+
+ selectTag(): void {
+ const term = this.searchTerm.trim();
+ if (!this.addTag || !term) {
+ return;
+ }
+
+ const result = this.addTag(term);
+ if (result && typeof result !== 'boolean') {
+ this.selectedIdChange.emit(result.id);
+ }
+ this.close();
+ }
+
+ onControlMouseDown(event: MouseEvent): void {
+ // Keep focus handling in this component; avoid input blur before toggle.
+ event.preventDefault();
+ this.toggle();
+ if (this.isOpen) {
+ setTimeout(() => {
+ this.searchInput?.nativeElement.focus();
+ this.searchInput?.nativeElement.select();
+ });
+ }
+ }
+
+ @HostListener('document:keydown', ['$event'])
+ onDocumentKeyDown(event: KeyboardEvent): void {
+ if (!this.isOpen || event.key !== 'Escape') {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ this.close();
+ }
+
+ onKeyDown(event: KeyboardEvent): void {
+ if (!this.isOpen) {
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ this.open();
+ }
+ return;
+ }
+
+ const navigableCount = this.flatOptions.length + (this.showTag ? 1 : 0);
+
+ switch (event.key) {
+ case 'Escape':
+ event.preventDefault();
+ event.stopPropagation();
+ this.close();
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ if (navigableCount > 0) {
+ this.markedIndex = (this.markedIndex + 1) % navigableCount;
+ this.scrollMarkedOptionIntoView();
+ }
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ if (navigableCount > 0) {
+ this.markedIndex = (this.markedIndex - 1 + navigableCount) % navigableCount;
+ this.scrollMarkedOptionIntoView();
+ }
+ break;
+ case 'Enter':
+ event.preventDefault();
+ if (this.isTagMarked()) {
+ this.selectTag();
+ } else if (this.flatOptions[this.markedIndex]) {
+ this.selectOption(this.flatOptions[this.markedIndex]);
+ }
+ break;
+ case 'Tab':
+ this.close();
+ break;
+ default:
+ break;
+ }
+
+ this.cdRef.markForCheck();
+ }
+
+ private rebuildFilteredView(): void {
+ const term = this.searchTerm.trim();
+ const ungroupedOptions: ScancodeSelectOption[] = [];
+ const groups: ScancodeSelectGroup[] = [];
+ const groupIndex = new Map();
+
+ for (const item of this.items) {
+ if (term && !this.matches(term, item)) {
+ continue;
+ }
+
+ if (!item.group) {
+ ungroupedOptions.push(item);
+ continue;
+ }
+
+ let index = groupIndex.get(item.group);
+ if (index === undefined) {
+ index = groups.length;
+ groupIndex.set(item.group, index);
+ groups.push({
+ name: item.group,
+ options: [],
+ tileRows: [],
+ tiled: TILED_GROUPS.has(item.group),
+ });
+ }
+ groups[index].options.push(item);
+ }
+
+ for (const group of groups) {
+ if (group.name === 'Numpad') {
+ this.applyNumpadTileRows(group);
+ }
+ }
+
+ this.ungroupedOptions = ungroupedOptions;
+ this.groups = groups;
+ this.tagLabel = term && this.addTagText ? this.addTagText(term) : '';
+
+ const navigableCount = this.flatOptions.length + (this.showTag ? 1 : 0);
+ if (navigableCount === 0) {
+ this.markedIndex = 0;
+ } else if (this.markedIndex >= navigableCount) {
+ this.markedIndex = navigableCount - 1;
+ }
+ }
+
+ private applyNumpadTileRows(group: ScancodeSelectGroup): void {
+ const byText = new Map(group.options.map(option => [option.text, option]));
+ const tileRows: ScancodeSelectOption[][] = [];
+
+ for (const rowTexts of NUMPAD_TILE_ROWS) {
+ const row = rowTexts
+ .map(text => byText.get(text))
+ .filter((option): option is ScancodeSelectOption => !!option);
+ if (row.length > 0) {
+ tileRows.push(row);
+ }
+ }
+
+ group.tileRows = tileRows;
+ // Keep keyboard navigation order aligned with the visual layout.
+ if (tileRows.length > 0) {
+ group.options = tileRows.flat();
+ }
+ }
+
+ private matches(term: string, item: ScancodeSelectOption): boolean {
+ if (this.searchFn) {
+ return this.searchFn(term, item);
+ }
+
+ return item.text.toLowerCase().includes(term.toLowerCase());
+ }
+
+ private markSelectedOption(): void {
+ const selectedIndex = this.flatOptions.findIndex(item => item.id === this.selectedId);
+ this.markedIndex = selectedIndex >= 0 ? selectedIndex : 0;
+ }
+
+ private updatePanelMaxHeight(): void {
+ const triggerRect = this.host.nativeElement.getBoundingClientRect();
+ const spaceBelow = window.document.body.clientHeight - triggerRect.bottom - MAX_HEIGHT_OFFSET;
+ const spaceAbove = triggerRect.top - MAX_HEIGHT_OFFSET;
+ // Prefer the side with more room so a tall panel is less likely to be
+ // pushed back over the trigger (especially in the macro editor).
+ this.panelMaxHeight = Math.max(120, Math.max(spaceBelow, spaceAbove) - SEARCH_BAR_HEIGHT);
+ }
+
+ private scrollMarkedOptionIntoView(): void {
+ setTimeout(() => {
+ document.querySelector('.scancode-select__option--marked')
+ ?.scrollIntoView({ block: 'nearest' });
+ });
+ }
+}
diff --git a/packages/uhk-web/src/app/shared.module.ts b/packages/uhk-web/src/app/shared.module.ts
index e2c7d547446..3dbfabc9255 100644
--- a/packages/uhk-web/src/app/shared.module.ts
+++ b/packages/uhk-web/src/app/shared.module.ts
@@ -1,8 +1,9 @@
-import { APP_INITIALIZER, NgModule } from '@angular/core';
+import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule } from '@angular/common';
+import { HttpClientModule } from '@angular/common/http';
+import { APP_INITIALIZER, NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import { HttpClientModule } from '@angular/common/http';
import { ConfirmationPopoverModule } from 'angular-confirmation-popover';
import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NotifierModule } from 'gramli-angular-notifier';
@@ -71,6 +72,7 @@ import {
MouseTabComponent,
NoneTabComponent,
} from './components/popover/tab';
+import { ScancodeSelectComponent } from './components/popover/tab/keypress/scancode-select';
import { CaptureKeystrokeButtonComponent } from './components/popover/widgets/capture-keystroke';
import { IconComponent } from './components/popover/widgets/icon';
import { AboutComponent, SettingsComponent, ContributorBadgeComponent } from './components/agent';
@@ -227,6 +229,7 @@ import appInitFactory from './services/app-init-factory';
DeviceTabComponent,
KeypressTabComponent,
KeymapTabComponent,
+ ScancodeSelectComponent,
LayerTabComponent,
MacroTabComponent,
MouseTabComponent,
@@ -305,6 +308,7 @@ import appInitFactory from './services/app-init-factory';
MonacoEditorModule,
NgSelectModule,
NouisliderModule,
+ OverlayModule,
NotifierModule.withConfig(angularNotifierConfig),
ConfirmationPopoverModule.forRoot({
confirmButtonType: 'danger' // set defaults here
diff --git a/packages/uhk-web/src/styles/_global.scss b/packages/uhk-web/src/styles/_global.scss
index 355b6fe51a8..62d182fa84b 100644
--- a/packages/uhk-web/src/styles/_global.scss
+++ b/packages/uhk-web/src/styles/_global.scss
@@ -15,6 +15,11 @@ body {
--tooltip-max-width: 200px;
}
+/* Above key-action popover (1051) so dropdowns like scancode-select are visible. */
+.cdk-overlay-container {
+ z-index: 1100;
+}
+
.full-screen-component {
display: block;
height: 100%;
diff --git a/packages/uhk-web/src/styles/themes/_dark.scss b/packages/uhk-web/src/styles/themes/_dark.scss
index 543233eb44f..1124a973dd9 100644
--- a/packages/uhk-web/src/styles/themes/_dark.scss
+++ b/packages/uhk-web/src/styles/themes/_dark.scss
@@ -126,6 +126,8 @@ $input-focus-border-color: tint-color($component-active-bg, 25%) !default;
--color-select-active-bg: #{$color-select-active-bg};
--color-select-active-text: #{$color-select-active-text};
+ --color-select-hover-bg: #{$color-select-hover-bg};
+ --color-select-hover-text: #{$color-select-hover-text};
--color-configuration-history-text: #{$color-text};
diff --git a/packages/uhk-web/src/styles/themes/_light.scss b/packages/uhk-web/src/styles/themes/_light.scss
index 287671d09a9..c1f3bb60dff 100644
--- a/packages/uhk-web/src/styles/themes/_light.scss
+++ b/packages/uhk-web/src/styles/themes/_light.scss
@@ -66,6 +66,8 @@ $input-focus-border-color: lighten($component-active-bg, 25%) !default;
--color-keyboard-key-active: #{$primary};
--color-module-puzzle-path-fill: #{$white};
+ --color-input-bg: #{$input-bg};
+ --color-input-text: #{$input-color};
--color-input-border: #ccc;
--color-btn-secondary-border: #ccc;
@@ -99,6 +101,8 @@ $input-focus-border-color: lighten($component-active-bg, 25%) !default;
--color-select-active-bg: #{$color-select-active-bg};
--color-select-active-text: #{$color-select-active-text};
+ --color-select-hover-bg: #{$color-select-hover-bg};
+ --color-select-hover-text: #{$color-select-hover-text};
--color-configuration-history-text: #{$color-text};