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
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"title": "The fullscreen readout turns with the phone",
"body": "In a landscape holder the fullscreen readout now lies the right way, and it keeps to Android's auto-rotate setting."
},
{
"id": "2026-09-27-picker-search-focus",
"date": "2026-09-27",
"where": "both",
"title": "Type straight away in the target picker",
"body": "Opening the target picker puts the cursor in its search. In the app it opens on an empty search every time."
},
{
"id": "2026-09-26-broker-presets-shown",
"date": "2026-09-26",
Expand Down
26 changes: 26 additions & 0 deletions app/src/__tests__/sheetfocus.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect, vi } from 'vitest'
import { closeSheet } from '../sheetfocus.js'

// A stand-in for a sheet and its toggle: the sheet holds a set of elements.
const setup = (focusInside) => {
const field = { id: 'ts-search' }, outside = { id: 'map' }
const sheet = { hidden: false, contains: (x) => x === field }
const toggle = { focus: vi.fn() }
const doc = { activeElement: focusInside ? field : outside }
return { sheet, toggle, doc }
}

describe('closeSheet (#714)', () => {
it('hands the focus back to the toggle when it was inside the sheet', () => {
const { sheet, toggle, doc } = setup(true)
closeSheet(sheet, toggle, doc)
expect(sheet.hidden).toBe(true)
expect(toggle.focus).toHaveBeenCalledTimes(1)
})
it('leaves the focus where it is when it was elsewhere', () => {
const { sheet, toggle, doc } = setup(false)
closeSheet(sheet, toggle, doc)
expect(sheet.hidden).toBe(true)
expect(toggle.focus).not.toHaveBeenCalled()
})
})
41 changes: 41 additions & 0 deletions app/src/__tests__/targetlist.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { createTargetList } from '../targetlist.js'

// #714. The target chip opened the sheet and focused nothing, so reaching a
// node by name took a press on the field between the chip and the first
// keystroke. And reset() reset paging only: reopening the sheet brought back
// the last query and a list narrowed by it, with nothing on screen saying why.
// No jsdom in this suite; the elements are the fakes the list touches.
function fakes(query) {
const listEl = { scrollTop: 0, children: [], addEventListener() {}, replaceChildren(...c) { this.children = c } }
const searchEl = { value: query, focusedWith: null, listeners: {}, addEventListener(type, fn) { this.listeners[type] = fn }, focus(opts) { this.focusedWith = opts || {} } }
const browseEl = { hidden: false }
const list = createTargetList(listEl, { searchEl, browseEl })
return { list, listEl, searchEl, browseEl }
}

describe('opening the target sheet (#714)', () => {
it('puts the caret in the search field, without scrolling the page', () => {
const f = fakes('')
f.list.open()
expect(f.searchEl.focusedWith).toEqual({ preventScroll: true })
})

it('starts from an empty query every time, and repaints at once', () => {
// A query that matches nothing paints the "No senders match." row, which
// is the one element render builds with no rows at all.
globalThis.document = { createElement: () => ({ className: '', textContent: '' }) }
try {
const f = fakes('dikke')
f.list.render([], new Set(), Date.now(), null)
expect(f.listEl.children).toHaveLength(1)
// A query hides the browse chrome (Top and the list header).
expect(f.browseEl.hidden).toBe(true)
f.list.open()
expect(f.searchEl.value).toBe('')
expect(f.listEl.children).toHaveLength(0)
// What a user sees come back: the browse chrome, with the query gone.
expect(f.browseEl.hidden).toBe(false)
} finally { delete globalThis.document }
})
})
64 changes: 31 additions & 33 deletions app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { fabRingSvg } from './fabring.js'
import { SOUND_MODES, nextSoundMode, receptionCue, createSoundEngine } from './sound.js'
import { parseVersion, isUpdateAvailable } from './update.js'
import { fetchMe, postAuth, validateRegistration, buildRegisterBody, buildLoginBody, buildLinkBody, accountDisplayState, submitLabelForMode } from './auth.js'
import { closeSheet } from './sheetfocus.js'

// ---------------------------------------------------------------------------
// State
Expand Down Expand Up @@ -2575,7 +2576,7 @@ function buildFilterSheet() {
drawOnce()
})

el('fs-close').addEventListener('click', () => { sheet.hidden = true })
el('fs-close').addEventListener('click', () => closeSheet(sheet, el('filter-pill')))
}

function buildTargetSheet() {
Expand Down Expand Up @@ -2618,7 +2619,7 @@ function buildTargetSheet() {
document.dispatchEvent(new CustomEvent('hunt:isolate-sender', { detail: null }))
})

el('ts-close').addEventListener('click', () => { sheet.hidden = true })
el('ts-close').addEventListener('click', () => closeSheet(sheet, el('target-chip')))
}

function renderIgnoreList(listEl) {
Expand Down Expand Up @@ -2857,7 +2858,7 @@ function buildSettingsSheet() {
el('ss-conn-btn').addEventListener('click', () => {
if (state.connected) {
disconnectAll()
sheet.hidden = true
closeSheet(sheet, el('settings-btn'))
} else {
state.wakeLock.enable()
connectAll()
Expand Down Expand Up @@ -2914,7 +2915,7 @@ function buildSettingsSheet() {
})


el('ss-close').addEventListener('click', () => { sheet.hidden = true })
el('ss-close').addEventListener('click', () => closeSheet(sheet, el('settings-btn')))

// The brokers page lives inside the settings sheet (#554), so a tap in it is
// a tap inside the sheet for the outside-click dismissal below.
Expand Down Expand Up @@ -2953,7 +2954,7 @@ function buildSettingsSheet() {
// Replaces the old topbar "?" button (#281): closes the sheet so the
// walkthrough it re-opens isn't hidden behind it.
el('ss-about-howto').addEventListener('click', () => {
el('settings-sheet').hidden = true
closeSheet(el('settings-sheet'), el('settings-btn'))
state.showOnboarding = true
refreshSplash()
})
Expand Down Expand Up @@ -3921,42 +3922,39 @@ window.addEventListener('DOMContentLoaded', async () => {
})
el('filter-pill').addEventListener('click', () => {
const sheet = el('filter-sheet')
sheet.hidden = !sheet.hidden
if (!sheet.hidden) {
el('settings-sheet').hidden = true
el('target-sheet').hidden = true
renderIgnoreList(el('ss-ignore-list'))
}
if (!sheet.hidden) { closeSheet(sheet, el('filter-pill')); return }
sheet.hidden = false
closeSheet(el('settings-sheet'), el('settings-btn'))
closeSheet(el('target-sheet'), el('target-chip'))
renderIgnoreList(el('ss-ignore-list'))
})

el('settings-btn').addEventListener('click', () => {
const sheet = el('settings-sheet')
sheet.hidden = !sheet.hidden
if (!sheet.hidden) {
el('filter-sheet').hidden = true
el('target-sheet').hidden = true
// Always open on the tabs, not on wherever the brokers page was left.
state.brokerSheet.close()
refreshConnState()
refreshAccount()
checkForUpdate()
refreshWhatsNewBadge()
// Before the badge refresh this would read the flag the previous open
// left behind; after it, state.unseenChangelog is the current answer.
settingsSelectTab(initialSettingsTab(state))
}
if (!sheet.hidden) { closeSheet(sheet, el('settings-btn')); return }
sheet.hidden = false
closeSheet(el('filter-sheet'), el('filter-pill'))
closeSheet(el('target-sheet'), el('target-chip'))
// Always open on the tabs, not on wherever the brokers page was left.
state.brokerSheet.close()
refreshConnState()
refreshAccount()
checkForUpdate()
refreshWhatsNewBadge()
// Before the badge refresh this would read the flag the previous open
// left behind; after it, state.unseenChangelog is the current answer.
settingsSelectTab(initialSettingsTab(state))
})

// Target chip tap → open the target dropdown
el('target-chip').addEventListener('click', () => {
const sheet = el('target-sheet')
sheet.hidden = !sheet.hidden
if (!sheet.hidden) {
el('filter-sheet').hidden = true
el('settings-sheet').hidden = true
el('ts-clear').hidden = !state.filter.sender
state.targetList.reset()
}
if (!sheet.hidden) { closeSheet(sheet, el('target-chip')); return }
sheet.hidden = false
closeSheet(el('filter-sheet'), el('filter-pill'))
closeSheet(el('settings-sheet'), el('settings-btn'))
el('ts-clear').hidden = !state.filter.sender
state.targetList.open()
})

// Tap outside an open sheet (on the map/backdrop) closes it — standard
Expand All @@ -3977,7 +3975,7 @@ window.addEventListener('DOMContentLoaded', async () => {
for (const { sheet, toggle } of dismissableSheets) {
if (sheet.hidden) continue
if (sheet.contains(e.target) || toggle.contains(e.target)) continue
sheet.hidden = true
closeSheet(sheet, toggle)
}
syncPopoverTriggers()
})
Expand Down
10 changes: 10 additions & 0 deletions app/src/sheetfocus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// closeSheet hides a bottom sheet, and when the focus was inside it hands
// the focus back to the sheet's toggle (#714), as web/multiselect.js does:
// not left on a hidden field, with the caret and on a phone the keyboard,
// and not dropped on the body, where a keyboard user loses their place.
// Every path that hides a sheet goes through here: its close button, its
// toggle, another sheet opening, a tap outside.
export function closeSheet(sheet, toggle, doc = document) {
if (sheet.contains(doc.activeElement)) toggle.focus()
sheet.hidden = true
}
19 changes: 17 additions & 2 deletions app/src/targetlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,28 @@ export function createTargetList(listEl, { onSelect, pinnedEl, pinnedLabelEl, se
listEl.replaceChildren(...items.map((rec) => row(rec, nowMs, onSelect, lastSelected)))
}

// Reset back to the first page — call when the sheet is (re)opened.
// Back to the first page.
function reset() {
visible = PAGE_SIZE
_lastSig = null
_lastPinnedSig = null
}

// open: what the sheet does each time it opens (#714). The first page of
// the full list, an empty query, and the caret in the field, so the next
// keystroke searches. A query left from the last open used to come back
// with a list narrowed by it and nothing saying why. The list repaints at
// once when there was a query, not on the next tick, so the narrowed list
// never shows. The focus has to land in the tap that opened the sheet: a
// phone raises its keyboard only then.
function open() {
const hadQuery = !!(searchEl && searchEl.value)
if (searchEl) searchEl.value = ''
reset()
if (hadQuery) render(lastRows, lastIgnore, Date.now(), lastSelected)
if (searchEl) searchEl.focus({ preventScroll: true })
}

// Typing is a new list: page from the top of the matches rather than from
// wherever the unfiltered list had been scrolled to.
if (searchEl) {
Expand All @@ -144,5 +159,5 @@ export function createTargetList(listEl, { onSelect, pinnedEl, pinnedLabelEl, se
render(lastRows, lastIgnore, Date.now(), lastSelected)
})

return { render, reset }
return { render, open }
}
7 changes: 7 additions & 0 deletions changelog.d/2026-09-27-07-picker-search-focus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"id": "2026-09-27-picker-search-focus",
"date": "2026-09-27",
"where": "both",
"title": "Type straight away in the target picker",
"body": "Opening the target picker puts the cursor in its search. In the app it opens on an empty search every time."
}
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"title": "The fullscreen readout turns with the phone",
"body": "In a landscape holder the fullscreen readout now lies the right way, and it keeps to Android's auto-rotate setting."
},
{
"id": "2026-09-27-picker-search-focus",
"date": "2026-09-27",
"where": "both",
"title": "Type straight away in the target picker",
"body": "Opening the target picker puts the cursor in its search. In the app it opens on an empty search every time."
},
{
"id": "2026-09-26-broker-presets-shown",
"date": "2026-09-26",
Expand Down
18 changes: 18 additions & 0 deletions web/e2e/targetpicker.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ test('opening the picker lists senders from the currently loaded points', async
await expect(page.locator('#tp-list')).toContainText('Charlie')
})

// #714: the caret is in the search the moment the picker opens, so the next
// keystroke searches without a press on the field; Escape hands the focus back
// to the toggle rather than leaving it on a hidden field.
test('opening the picker puts the caret in its search, and closing hands it back', async ({ page }) => {
await page.route('**/api/points*', (r) => r.fulfill({ json: { points: [A, B] } }))
await page.goto('/?mode=points')
await openPicker(page, '#sp-toggle', '#sender-picker')
await expect(page.locator('#f-sender')).toBeFocused()
await page.keyboard.type('cc')
await expect(page.locator('#f-sender')).toHaveValue('cc')
await page.keyboard.press('Escape')
await expect(page.locator('#sender-picker')).toBeHidden()
await expect(page.locator('#sp-toggle')).toBeFocused()
// Escape in a search field clears it in Chromium and WebKit; here it only
// closes, so the typed prefix, which filters the map, stays.
await expect(page.locator('#f-sender')).toHaveValue('cc')
})

const sendersOf = (u) => new URL(u).searchParams.getAll('senders')

test('a picked selection is sent to the server as repeated senders= params', async ({ page }) => {
Expand Down
3 changes: 3 additions & 0 deletions web/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -2515,6 +2515,9 @@ syncTargetToggleLabel()
wirePopover({
toggleEl: spToggle, panelEl: senderPicker, wrapEl: spToggle.closest('.ms-wrap'), wrapSelector: '.ms-wrap',
onOpen: () => { targetPicker.reset(); refresh() }, // back to page 1; the next redraw repopulates
// The caret in the search (#714). Its value stays: on web it is the sender
// filter itself, bound to ?sender=.
focusEl: document.getElementById('f-sender'),
})

// Hunter picker (#290): generalizes the sender picker's pattern to #f-hunter,
Expand Down
14 changes: 12 additions & 2 deletions web/multiselect.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,24 +221,32 @@ export function placePopover(toggleEl, panelEl, { align = 'left', viewport } = {
// can't tell them apart, so this takes the actual element and requires the
// click's nearest wrapSelector ancestor to be THIS wrap, not merely any wrap.
// onOpen lets a caller reset paging / refresh data each time the panel opens.
// focusEl (#714) is the field the caret goes to on open, so the next keystroke
// searches; closing with the focus inside hands it back to the toggle rather
// than leaving it on a hidden field.
//
// Click detection is capture-phase, not bubble: a row click's own handler
// replaces the clicked button via listEl.replaceChildren() synchronously, so
// by the time a bubble-phase document listener would run, e.target is already
// detached and closest(wrapSelector) wrongly returns null, closing the panel
// after every pick. Capture runs before that mutation happens.
export function wirePopover({ toggleEl, panelEl, wrapEl, wrapSelector, onOpen, align = 'left' }) {
export function wirePopover({ toggleEl, panelEl, wrapEl, wrapSelector, onOpen, focusEl, align = 'left' }) {
function open() {
panelEl.hidden = false
toggleEl.setAttribute('aria-expanded', 'true')
if (onOpen) onOpen()
// After onOpen: it repopulates the rows, so the panel's height is only
// final once it has run (#372).
placePopover(toggleEl, panelEl, { align })
// In the same tap as the open, which a phone needs to raise its keyboard.
// preventScroll: the panel is placed already, the page must not jump.
if (focusEl) focusEl.focus({ preventScroll: true })
}
function close() {
const inside = panelEl.contains(document.activeElement)
panelEl.hidden = true
toggleEl.setAttribute('aria-expanded', 'false')
if (inside) toggleEl.focus()
}
// #bar wraps, on a resize or when late content grows it, and that moves
// the toggle to another row, so the panel has to follow (#405: the one bar
Expand All @@ -253,6 +261,8 @@ export function wirePopover({ toggleEl, panelEl, wrapEl, wrapSelector, onOpen, a
if (e.target.closest(wrapSelector) === wrapEl) return
close()
}, true)
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !panelEl.hidden) close() })
// preventDefault: with the caret in a search field (#714), the browser's own
// Escape would clear it, and a cleared search is a changed filter.
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !panelEl.hidden) { e.preventDefault(); close() } })
return { open, close }
}
Loading
Loading