diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 8e83eab..9208fdf 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -125,6 +125,8 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { checkEntrypointEdit(ctx, t, page, page+"/events") checkRecentEvents(ctx, t, page) checkEventLog(ctx, t, page+"/events", event.ID, target.Name) + checkEventSelection(ctx, t, page+"/events", event.ID) + checkEventKeyboard(ctx, t, page+"/events", event.ID) checkMobileMenu(ctx, t, page) assert.Empty(t, problems(), "the browser reported problems") @@ -610,21 +612,23 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) { // checkEventLog loads the event log and checks an event's row. Clicking // its ID expands the event, and in there clicking its delivery shows the // delivery's attempts and clicking again hides them. Clicking the row's -// caret collapses the event. Selecting the ID with the mouse leaves the -// event collapsed, and the caret's next click expands it. Clicking the -// ID again collapses it. From the keyboard, Enter on the row expands -// the event and Space collapses it. +// caret collapses the event, clicking it again expands it, and clicking +// the ID again collapses it. While the event is expanded the row says so +// and its caret is turned up, and while it is collapsed neither. func checkEventLog( ctx context.Context, t *testing.T, url, eventID, targetName string, ) { t.Helper() - // The event's row shows its ID and ends with its caret, and its - // Resubmit form is in the part that expands. The delivery's row - // there shows the target's name. + // The event's row shows its ID and ends with its caret, which turns + // up with Tailwind's rotate-180 class, and its Resubmit form is in + // the part that expands. The delivery's row there shows the target's + // name. id := `//span[text()="` + eventID + `"]` row := id + `/ancestor::div[@role="button"]` caret := row + `//*[local-name()="svg"]` + caretUp := caret + `[contains(@class, "rotate-180")]` + caretDown := caret + `[not(contains(@class, "rotate-180"))]` expanded := `form[action$="/` + eventID + `/resubmit"]` deliveryRow := `//span[text()="` + targetName + `"]` attempt := `//span[text()="Attempt 1"]` @@ -636,6 +640,10 @@ func checkEventLog( click(ctx, t, id) assert.True(t, shown(ctx, expanded), "clicking the event's ID does not expand it") + assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`), + "the expanded event's row does not say it is expanded") + assert.True(t, shown(ctx, caretUp), + "the expanded event's caret does not turn up") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown") @@ -650,12 +658,41 @@ func checkEventLog( click(ctx, t, caret) assert.True(t, hidden(ctx, expanded), "clicking the caret does not collapse the event") + assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`), + "the collapsed event's row does not say it is collapsed") + assert.True(t, shown(ctx, caretDown), + "the collapsed event's caret stays turned up") + + click(ctx, t, caret) + assert.True(t, shown(ctx, expanded), + "clicking the caret again does not expand the event") + + click(ctx, t, id) + assert.True(t, hidden(ctx, expanded), + "clicking the event's ID again does not collapse it") +} + +// checkEventSelection loads the event log and checks that selecting an +// event's ID with the mouse leaves the event as it was: dragging over the +// ID leaves it collapsed, the caret's next click still expands it, and a +// triple-click on the ID then leaves it expanded. +func checkEventSelection( + ctx context.Context, t *testing.T, url, eventID string, +) { + t.Helper() + + id := `//span[text()="` + eventID + `"]` + row := id + `/ancestor::div[@role="button"]` + caret := row + `//*[local-name()="svg"]` + expanded := `form[action$="/` + eventID + `/resubmit"]` var ( selected, state string hasState bool ) + require.NoError(t, chromedp.Run(ctx, loadPage(url))) + selectText(ctx, t, id) require.NoError(t, chromedp.Run( ctx, @@ -668,19 +705,50 @@ func checkEventLog( require.True(t, hasState, "the event's row does not say if it is expanded") assert.Equal(t, "false", state, "selecting the event's ID expands it") + // The caret's click also clears the selection, so the triple-click's + // first click finds nothing selected, as a person's would. click(ctx, t, caret) assert.True(t, shown(ctx, expanded), "clicking the caret after selecting the ID does not expand the event") - click(ctx, t, id) - assert.True(t, hidden(ctx, expanded), - "clicking the event's ID again does not collapse it") + tripleClick(ctx, t, id) + require.NoError(t, chromedp.Run( + ctx, + chromedp.Evaluate(`window.getSelection().toString()`, &selected), + chromedp.AttributeValue( + row, "aria-expanded", &state, &hasState, chromedp.BySearch, + ), + )) + assert.Contains(t, selected, eventID, + "a triple-click does not select the event's ID") + assert.Equal(t, "true", state, + "a triple-click selecting the event's ID collapses it") +} + +// checkEventKeyboard loads the event log and checks that Tab from the +// page's Back link reaches the event's row, the first after it, and that +// Enter then expands the event and Space collapses it. +func checkEventKeyboard( + ctx context.Context, t *testing.T, url, eventID string, +) { + t.Helper() + + back := `//a[contains(text(), "Back to")]` + expanded := `form[action$="/` + eventID + `/resubmit"]` + + var focused string require.NoError(t, chromedp.Run( ctx, - chromedp.Focus(row, chromedp.BySearch), - chromedp.KeyEvent(kb.Enter), + loadPage(url), + chromedp.Focus(back, chromedp.BySearch), + chromedp.KeyEvent(kb.Tab), + chromedp.Evaluate(`document.activeElement.textContent`, &focused), )) + require.Contains(t, focused, eventID, + "Tab from the Back link does not reach the event's row") + + require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter))) assert.True(t, shown(ctx, expanded), "Enter does not expand the event") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" "))) @@ -714,6 +782,31 @@ func selectText(ctx context.Context, t *testing.T, xpath string) { )) } +// tripleClick clicks three times in a row in the middle of the element +// matching an XPath expression, as a person does to select a whole line +// of text. The browser selects a word on the second click and the whole +// paragraph on the third. +func tripleClick(ctx context.Context, t *testing.T, xpath string) { + t.Helper() + + var box *dom.BoxModel + + require.NoError(t, chromedp.Run( + ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch), + )) + + // The content box's corners, clockwise from its top left. + x := (box.Content[0] + box.Content[2]) / 2 + y := (box.Content[1] + box.Content[5]) / 2 + + require.NoError(t, chromedp.Run( + ctx, + chromedp.MouseClickXY(x, y, chromedp.ClickCount(1)), + chromedp.MouseClickXY(x, y, chromedp.ClickCount(2)), + chromedp.MouseClickXY(x, y, chromedp.ClickCount(3)), + )) +} + // checkMobileMenu loads a page in a phone-sized window and checks that // the menu button opens and closes the mobile menu. func checkMobileMenu(ctx context.Context, t *testing.T, url string) { diff --git a/static/js/app.js b/static/js/app.js index 3776b50..8c07871 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -76,17 +76,31 @@ document.addEventListener("alpine:init", function () { window.Alpine.data("collapsible", function () { return { open: false, + // Whether it was open before its latest single click, which + // may be the first of a double- or triple-click. + wasOpen: false, init() { this.open = this.$root.hasAttribute("data-open"); }, toggle() { this.open = !this.open; }, - // Toggles, except on a click that ends a text selection, such - // as selecting an event's ID to copy it. - toggleUnlessSelecting() { - if (window.getSelection().toString() === "") { + // Toggles on a click, except one that selects text, such as + // selecting an event's ID to copy it. A double- or + // triple-click selects its text on its second click, after + // its first may have toggled, so a later click that finds + // text selected puts back the state from before the first. + toggleUnlessSelecting(event) { + const selecting = window.getSelection().toString() !== ""; + + if (event.detail === 1) { + this.wasOpen = this.open; + } + + if (!selecting) { this.toggle(); + } else if (event.detail > 1) { + this.open = this.wasOpen; } }, get closed() {