Skip to content

Commit b4fcbca

Browse files
authored
Merge pull request #379 from tilfinltd/feature/v6-1
Develop for v6.1.0
2 parents af5fab9 + c59851e commit b4fcbca

File tree

9 files changed

+443
-27
lines changed

9 files changed

+443
-27
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ test/extension/*
55
!test/extension/background.js
66
!test/extension/manifest.json
77
manifest_*_dev.json
8+
.DS_Store
89
test-results/

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@ The 'Show only matching roles' setting is for use with more sophisticated accoun
160160
- **Configuration storage** specifies which storage to save to. 'Sync' can automatically share it between browsers with your account but cannot store many profiles. 'Local' is the exact opposite of 'Sync.'
161161
- **Visual mode** specifies whether light mode or dark mode is applied to the UI appearance.
162162

163+
## Keyboard Navigation
164+
165+
The popup interface supports keyboard navigation for efficient role switching:
166+
167+
### Filter Input
168+
- Type to filter roles by name or account ID
169+
- **Enter** - Select the currently highlighted role and switch to it
170+
- **Escape** - Clear the filter and close the popup
171+
172+
### Role Navigation
173+
- **Arrow Down** - Highlight the next visible role in the filtered list
174+
- **Arrow Up** - Highlight the previous visible role in the filtered list
175+
- **Enter** - Switch to the currently highlighted role
176+
177+
### Usage Example
178+
1. Open the extension popup (click the extension icon)
179+
2. Type part of a role name (e.g., "prod" to filter production roles)
180+
3. Use **Arrow Down**/**Arrow Up** to navigate through the filtered results
181+
4. Press **Enter** to switch to the highlighted role
182+
163183
## Extension API
164184

165185
- **Config sender extension** allowed by the **ID** can send your switch roles configuration to this extension. **'Configuration storage' forcibly becomes 'Local' when the configuration is received from a config sender.** [See](https://github.com/tilfinltd/aws-extend-switch-roles/wiki/External-API#config-sender-extension) how to make your config sender extension.

bin/build_test.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ for file in src/js/*; do
1414
fi
1515
done
1616

17+
rollup -c ./rollup.config.js src/js/lib/profile_db.js --file $destdir/js/lib/profile_db.js
18+
1719
mkdir -p $destdir/tests
1820
for file in src/tests/*; do
1921
fname="${file##*/}"

src/js/popup.js

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -165,33 +165,86 @@ function renderRoleList(profiles, tabId, curURL, isPrism, options) {
165165
function setupRoleFilter() {
166166
const roleFilter = document.getElementById('roleFilter');
167167

168-
let AWSR_firstAnchor = null;
169-
roleFilter.onkeyup = function(e) {
170-
const words = this.value.toLowerCase().split(' ');
171-
if (e.keyCode === 13) {
172-
if (AWSR_firstAnchor) {
173-
AWSR_firstAnchor.click()
174-
}
168+
let AWSR_selectedAnchor = null;
169+
let AWSR_currentSelectedIndex = -1; // Track current selection index among visible items
170+
171+
// Get currently visible (filtered) role list items
172+
function getVisibleRoles() {
173+
return Array.from(document.querySelectorAll('#roleList > li')).filter(li => li.style.display !== 'none');
174+
}
175+
176+
// Update visual selection and scroll into view
177+
function updateSelection(visibleRoles, newIndex) {
178+
// Clear previous selection
179+
visibleRoles.forEach(li => li.classList.remove('selected'));
180+
AWSR_selectedAnchor = null;
181+
182+
// Set new selection if valid index
183+
if (newIndex >= 0 && newIndex < visibleRoles.length) {
184+
const selectedLi = visibleRoles[newIndex];
185+
selectedLi.classList.add('selected');
186+
AWSR_selectedAnchor = selectedLi.querySelector('a');
187+
AWSR_currentSelectedIndex = newIndex;
188+
189+
// Scroll into view if needed
190+
selectedLi.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
175191
} else {
176-
const lis = Array.from(document.querySelectorAll('#roleList > li'));
177-
let firstHitLi = null;
178-
lis.forEach(li => {
179-
const anchor = li.querySelector('a')
180-
const profileName = anchor.dataset.search;
181-
const hit = words.every(it => profileName.includes(it));
182-
li.style.display = hit ? 'block' : 'none';
183-
li.classList.remove('selected')
184-
if (hit && firstHitLi === null) firstHitLi = li;
185-
});
186-
187-
if (firstHitLi) {
188-
firstHitLi.classList.add('selected');
189-
AWSR_firstAnchor = firstHitLi.querySelector('a');
190-
} else {
191-
AWSR_firstAnchor = null;
192-
}
192+
AWSR_currentSelectedIndex = -1;
193193
}
194194
}
195+
196+
// Handle arrow keys, Enter, and Escape
197+
roleFilter.addEventListener('keydown', function(e) {
198+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === 'Escape') {
199+
e.preventDefault(); // Prevent default behavior
200+
201+
const visibleRoles = getVisibleRoles();
202+
203+
if (e.key === 'ArrowDown') {
204+
const newIndex = AWSR_currentSelectedIndex < visibleRoles.length - 1 ? AWSR_currentSelectedIndex + 1 : AWSR_currentSelectedIndex;
205+
updateSelection(visibleRoles, newIndex);
206+
} else if (e.key === 'ArrowUp') {
207+
const newIndex = AWSR_currentSelectedIndex > 0 ? AWSR_currentSelectedIndex - 1 : 0;
208+
updateSelection(visibleRoles, newIndex);
209+
} else if (e.key === 'Enter') {
210+
if (AWSR_selectedAnchor) {
211+
AWSR_selectedAnchor.click();
212+
}
213+
} else if (e.key === 'Escape') {
214+
// Clear filter and close popup
215+
this.value = '';
216+
// Trigger filtering to show all roles
217+
this.dispatchEvent(new Event('input'));
218+
window.close();
219+
}
220+
}
221+
});
222+
223+
// Handle text filtering (enhanced from existing logic)
224+
roleFilter.addEventListener('input', function(e) {
225+
const words = this.value.toLowerCase().split(' ');
226+
const lis = Array.from(document.querySelectorAll('#roleList > li'));
227+
let firstHitLi = null;
228+
229+
lis.forEach(li => {
230+
const anchor = li.querySelector('a');
231+
const profileName = anchor.dataset.search;
232+
const hit = words.every(it => profileName.includes(it));
233+
li.style.display = hit ? 'block' : 'none';
234+
li.classList.remove('selected');
235+
if (hit && firstHitLi === null) firstHitLi = li;
236+
});
237+
238+
// Reset selection and auto-select first visible item
239+
AWSR_currentSelectedIndex = -1;
240+
if (firstHitLi) {
241+
firstHitLi.classList.add('selected');
242+
AWSR_selectedAnchor = firstHitLi.querySelector('a');
243+
AWSR_currentSelectedIndex = 0;
244+
} else {
245+
AWSR_selectedAnchor = null;
246+
}
247+
});
195248

196249
roleFilter.focus()
197250
}

test/emulator/fixtures.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'path';
22
import util from 'util';
33
import { fileURLToPath } from 'url';
44
import { test as base, chromium } from '@playwright/test';
5+
import { popupInit } from './popup_init.js';
56

67
export const test = base.extend({
78
context: async ({}, use) => {
@@ -28,7 +29,7 @@ export const test = base.extend({
2829
},
2930
});
3031

31-
const sleep = util.promisify(setTimeout);
32+
export const sleep = util.promisify(setTimeout);
3233

3334
async function waitForWorkerReady(worker, maxRetries = 10) {
3435
for (let i = 0; i < maxRetries; i++) {
@@ -63,14 +64,15 @@ export const testInOptions = (message, beforeFunc, pageFunc, afterFunc) => {
6364
});
6465
};
6566

66-
export const testInPopup = (message, beforeFunc, pageFunc, afterFunc) => {
67+
export const testInPopup = (message, beforeFunc, pageFunc, afterFunc = () => {}) => {
6768
test(message, async ({ page, context, extensionId }) => {
6869
const [worker] = context.serviceWorkers();
6970
await waitForWorkerReady(worker);
7071

7172
const resultB = await worker.evaluate(beforeFunc);
7273
if (resultB !== undefined) console.log(resultB);
7374

75+
await page.addInitScript(popupInit);
7476
await page.goto(`chrome-extension://${extensionId}/popup.html`);
7577
const resultP = await pageFunc({ page, expect: test.expect });
7678
if (resultP !== undefined) console.log(resultP);
@@ -79,7 +81,6 @@ export const testInPopup = (message, beforeFunc, pageFunc, afterFunc) => {
7981

8082
const resultA = await worker.evaluate(afterFunc);
8183
if (resultA !== undefined) console.log(resultA);
82-
//await sleep(1000000);
8384
});
8485
};
8586

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Test edge cases for keyboard navigation functionality
3+
*/
4+
import { testInPopup } from './fixtures.js';
5+
6+
testInPopup(
7+
'keyboard navigation with empty filter results',
8+
async () => {
9+
await self.loadProfileSet({
10+
singles: [
11+
{
12+
name: 'dev',
13+
aws_account_id: '123456789012',
14+
role_name: 'DeveloperRole',
15+
color: 'ff5722'
16+
},
17+
{
18+
name: 'prod',
19+
aws_account_id: '123456789013',
20+
role_name: 'ProductionRole',
21+
color: '4caf50'
22+
}
23+
]
24+
});
25+
},
26+
// pageFunc - test keyboard navigation with no results
27+
async function({ page, expect }) {
28+
await page.waitForSelector('#roleList li', { timeout: 1000 });
29+
30+
const roleFilter = page.locator('#roleFilter');
31+
32+
// Type a filter that matches no roles
33+
await roleFilter.fill('nonexistent');
34+
await page.waitForTimeout(100);
35+
36+
// Verify no roles are visible
37+
const visibleRoles = page.locator('#roleList li[style*="block"], #roleList li:not([style*="none"])');
38+
const visibleCount = await visibleRoles.count();
39+
expect(visibleCount).toBe(0);
40+
41+
// Test arrow keys when no roles are visible
42+
await page.keyboard.press('ArrowDown');
43+
await page.keyboard.press('ArrowUp');
44+
45+
// Verify no selection is made
46+
const selectedRoles = page.locator('#roleList li.selected');
47+
await expect(selectedRoles).toHaveCount(0);
48+
49+
// Test Enter key with no selection (should not cause errors)
50+
await page.keyboard.press('Enter');
51+
},
52+
);
53+
54+
testInPopup(
55+
'keyboard navigation with single role',
56+
async () => {
57+
await self.loadProfileSet({
58+
singles: [
59+
{
60+
name: 'single',
61+
aws_account_id: '123456789012',
62+
role_name: 'SingleRole',
63+
color: 'ff5722'
64+
}
65+
]
66+
});
67+
},
68+
// pageFunc - test navigation with only one role
69+
async function({ page, expect }) {
70+
await page.waitForSelector('#roleList li', { timeout: 1000 });
71+
72+
const roleFilter = page.locator('#roleFilter');
73+
74+
// Filter shows single role
75+
await roleFilter.fill('single');
76+
await page.waitForTimeout(100);
77+
78+
// Should have exactly one visible role
79+
const visibleRoles = page.locator('#roleList li[style*="block"], #roleList li:not([style*="none"])');
80+
const visibleCount = await visibleRoles.count();
81+
expect(visibleCount).toBe(1);
82+
83+
// Test arrow navigation bounds (should stay on same role)
84+
await page.keyboard.press('ArrowDown');
85+
await page.keyboard.press('ArrowDown'); // Should not move beyond last role
86+
87+
let selectedRoles = page.locator('#roleList li.selected');
88+
await expect(selectedRoles).toHaveCount(1);
89+
90+
await page.keyboard.press('ArrowUp');
91+
await page.keyboard.press('ArrowUp'); // Should not move beyond first role
92+
93+
selectedRoles = page.locator('#roleList li.selected');
94+
await expect(selectedRoles).toHaveCount(1);
95+
},
96+
);
97+
98+
testInPopup(
99+
'keyboard navigation mixed with typing',
100+
async () => {
101+
await self.loadProfileSet({
102+
singles: [
103+
{
104+
name: 'alpha',
105+
aws_account_id: '111111111111',
106+
role_name: 'AlphaRole',
107+
color: 'ff5722'
108+
},
109+
{
110+
name: 'beta',
111+
aws_account_id: '222222222222',
112+
role_name: 'BetaRole',
113+
color: '2196f3'
114+
},
115+
{
116+
name: 'gamma',
117+
aws_account_id: '333333333333',
118+
role_name: 'GammaRole',
119+
color: '4caf50'
120+
}
121+
]
122+
});
123+
},
124+
// pageFunc - test typing after navigation
125+
async function({ page, expect }) {
126+
await page.waitForSelector('#roleList li', { timeout: 1000 });
127+
128+
const roleFilter = page.locator('#roleFilter');
129+
130+
// Start with broad filter
131+
await roleFilter.fill('a');
132+
await page.waitForTimeout(100);
133+
134+
// Navigate down
135+
await page.keyboard.press('ArrowDown');
136+
137+
// Now type more characters (should reset selection to first match)
138+
await roleFilter.fill('alpha');
139+
await page.waitForTimeout(100);
140+
141+
// Should auto-select first (and only) matching role
142+
const selectedRoles = page.locator('#roleList li.selected');
143+
await expect(selectedRoles).toHaveCount(1);
144+
145+
// Further navigation should work normally
146+
await page.keyboard.press('ArrowDown');
147+
},
148+
);

0 commit comments

Comments
 (0)