From cc4d8a0792f3f40a386f6f4e05952a53e7a52107 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:14:46 +0100 Subject: [PATCH 1/4] feat(web): keyboard row navigation on five more list pages through one kit module --- apps/web/src/page/alerts/hub/derive.ts | 42 +++++- .../alerts/hub/hub-list-keys.scene.test.ts | 132 +++++++++++++++++ apps/web/src/page/alerts/hub/message.ts | 3 + apps/web/src/page/alerts/hub/model.ts | 2 + apps/web/src/page/alerts/hub/subscription.ts | 15 ++ apps/web/src/page/alerts/hub/update.ts | 39 ++++- apps/web/src/page/alerts/hub/view-inbox.ts | 22 +-- apps/web/src/page/alerts/hub/view.ts | 19 +-- .../page/dashboards/list/list.scene.test.ts | 96 ++++++++++++- apps/web/src/page/dashboards/list/message.ts | 3 + apps/web/src/page/dashboards/list/model.ts | 2 + .../src/page/dashboards/list/subscription.ts | 15 ++ apps/web/src/page/dashboards/list/update.ts | 33 ++++- .../web/src/page/dashboards/list/view-list.ts | 8 +- apps/web/src/page/errors/hub/selection.ts | 12 +- .../src/page/investigations/list/derive.ts | 6 + .../web/src/page/investigations/list/index.ts | 2 + .../list/list-keys.scene.test.ts | 133 ++++++++++++++++++ .../src/page/investigations/list/message.ts | 3 + .../web/src/page/investigations/list/model.ts | 2 + .../page/investigations/list/subscription.ts | 23 +++ .../src/page/investigations/list/update.ts | 41 +++++- apps/web/src/page/investigations/list/view.ts | 13 +- apps/web/src/page/kit/grid-list-row.ts | 20 ++- apps/web/src/page/kit/list-keys.test.ts | 63 +++++++++ apps/web/src/page/kit/list-keys.ts | 94 +++++++++++++ .../page/metrics/browse/browse.scene.test.ts | 86 ++++++++++- apps/web/src/page/metrics/browse/index.ts | 2 + apps/web/src/page/metrics/browse/message.ts | 3 + apps/web/src/page/metrics/browse/model.ts | 2 + .../src/page/metrics/browse/subscription.ts | 22 +++ apps/web/src/page/metrics/browse/update.ts | 48 +++++-- .../web/src/page/metrics/browse/view-table.ts | 8 +- apps/web/src/page/services/list/derive.ts | 9 ++ apps/web/src/page/services/list/message.ts | 3 + apps/web/src/page/services/list/model.ts | 2 + .../page/services/list/services.scene.test.ts | 63 ++++++++- .../src/page/services/list/subscription.ts | 16 ++- apps/web/src/page/services/list/update.ts | 65 +++++++-- apps/web/src/page/services/list/view-table.ts | 7 + apps/web/src/page/traces/list/subscription.ts | 35 ++--- apps/web/src/page/traces/list/update.ts | 19 +-- apps/web/src/page/traces/list/view-row.ts | 5 +- packages/views/src/ui/list-row.styles.ts | 3 +- packages/views/src/ui/list-row.ts | 4 + packages/views/src/ui/row-focus.ts | 14 ++ packages/views/src/ui/table.styles.ts | 5 +- 47 files changed, 1139 insertions(+), 125 deletions(-) create mode 100644 apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts create mode 100644 apps/web/src/page/investigations/list/list-keys.scene.test.ts create mode 100644 apps/web/src/page/investigations/list/subscription.ts create mode 100644 apps/web/src/page/kit/list-keys.test.ts create mode 100644 apps/web/src/page/kit/list-keys.ts create mode 100644 apps/web/src/page/metrics/browse/subscription.ts create mode 100644 packages/views/src/ui/row-focus.ts diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 540a3bbd5e..fba08e30b4 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -1,4 +1,8 @@ -import type { AlertDestinationDocument, AlertIncidentDocument } from "@maple/domain/http" +import { + type AlertDestinationDocument, + type AlertIncidentDocument, + isManagedSignalType, +} from "@maple/domain/http" import { type AlertsOverviewReady, deriveOverview } from "@maple/web-data/alerts/overview" import { buildRuleStatesByRuleId, @@ -9,6 +13,7 @@ import { import { Option, Order, Array as Arr, Record as Rec, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" +import { sortIncidents } from "../shared/sort" import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" import type { ScopeSuggestions } from "../silence-dialog/dialog" @@ -100,6 +105,41 @@ export const anomalyOverviewOf = (model: Model): Option.Option signal === "anomaly" || signal === "anomalies" ? "anomaly" : null +/** + * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list + * (an org with only monitors or SLOs has no visible rules but may have incidents to show). + */ +export const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { + if (data.rules.length > 0) return false + if (tab === "rules") return true + return ( + resolveSignal(model.search.signal) === null && + !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && + !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + ) +} + +/** What still needs someone first, then what someone is on, then what waits on data. */ +export const inboxOrder = ( + openIncidents: ReadonlyArray, +): ReadonlyArray => { + const all = sortIncidents(openIncidents) + return [ + ...Arr.filter(all, (i) => i.holdReason === null && i.acknowledgedAt === null), + ...Arr.filter(all, (i) => i.holdReason === null && i.acknowledgedAt !== null), + ...Arr.filter(all, (i) => i.holdReason !== null), + ] +} + +/** The open incidents the Inbox lists, in its order: what the list keys step over. */ +export const inboxIncidentsOf = (model: Model): ReadonlyArray => + model.tab !== "inbox" || resolveSignal(model.search.signal) !== null + ? [] + : Option.match(overviewOf(model), { + onNone: () => [], + onSome: (data) => (isFirstRun(model, data, "inbox") ? [] : inboxOrder(data.openIncidents)), + }) + /** A signal view lists open incidents unless the link asks for resolved or all of them. */ export const resolveIncidentStatus = (status: string | undefined): IncidentStatusFilter => status === "resolved" || status === "all" ? status : "open" diff --git a/apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts b/apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts new file mode 100644 index 0000000000..26ebdc6cdc --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts @@ -0,0 +1,132 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Array as Arr, Schema } from "effect" +import { + Command, + expect as expectView, + expectOutMessage, + given, + scene, + selector, + Subscription, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { PageOutMessage, type PageContext } from "../../contract" +import { hubAt, type HubAt, incidentRow, NOW, ORG, ruleRow, stateRow } from "../../kit/alert-test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" +import { view as pageView } from "./view" + +// The Inbox's open incidents take the list keys: J / K / arrows step, Enter opens, Escape lets go. + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const incidentId = (n: number) => `0199a003-0000-7000-8000-00000000000${n}` +const iso = (minutesAgo: number) => new Date(NOW - minutesAgo * 60_000).toISOString() + +// Inbox order: the critical one, then the warning, then the one held for data. +const CRITICAL = incidentRow({ id: incidentId(1), rule_name: "Payment p95 latency" }) +const WARNING = incidentRow({ + id: incidentId(2), + rule_id: "0199a002-0000-7000-8000-000000000001", + incident_key: "rule-1:all", + dedupe_key: "rule-1:all", + rule_name: "Checkout p95 latency", + severity: "warning", +}) +const HELD = incidentRow({ + id: incidentId(3), + rule_id: "0199a002-0000-7000-8000-000000000003", + incident_key: "rule-3:all", + dedupe_key: "rule-3:all", + rule_name: "Search p95 latency", + hold_reason: "no_data", + held_since: iso(5), +}) + +const loaded = (at: HubAt = {}, incidents = [HELD, WARNING, CRITICAL]): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2), ruleRow(3)] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [stateRow(1), stateRow(2), stateRow(3)] }), + Message.UpdatedIncidents({ orgId: ORG, rows: incidents }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + ], + init(hubAt(at), context).model, + (model, message) => pageUpdate(model, message).model, + ) + +const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) +const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) +const focusedRow = selector("[data-focused]") +const pressed = (model: Model, ...keys: ReadonlyArray): Model => + Arr.reduce(keys, model, (next, key) => pageUpdate(next, Message.PressedListKey({ key })).model) + +describe("alerts inbox keys", () => { + test("J and K move the focus through the open incidents in the Inbox's order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Payment p95 latency"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("Checkout p95 latency"), + press("j"), + scrolled(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Search p95 latency"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Checkout p95 latency"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter opens the focused incident", () => { + scene( + { update: pageUpdate, view }, + given(pressed(loaded(), "j", "j")), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: `/alerts/incidents/${incidentId(2)}`, + isReplace: false, + }), + ), + ) + }) + + test("the focus stays on its incident when a new one lands above it", () => { + const focused = pressed(loaded({}, [WARNING]), "j") + const grown = pageUpdate( + focused, + Message.UpdatedIncidents({ orgId: ORG, rows: [WARNING, CRITICAL] }), + ).model + scene( + { update: pageUpdate, view }, + given(grown), + expectView(focusedRow).toContainText("Checkout p95 latency"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Payment p95 latency"), + ) + }) + + test("another section has no incident rows to step over, and leaving the Inbox drops the focus", () => { + expect(pressed(loaded({ tab: "rules" }), "j").focusedIncidentId).toBeNull() + const focused = pressed(loaded(), "j") + expect(focused.focusedIncidentId).toBe(incidentId(1)) + expect(routeChanged(focused, hubAt({ tab: "rules" }), context).model.focusedIncidentId).toBeNull() + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 71ba17656c..8e11c59d28 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -57,6 +57,9 @@ export const Message = defineMessageUnion({ UpdatedDestinations: rows(AlertDestinationRowSchema), FailedLoadAlerts: { orgId: Schema.String }, ClickedRetryLoad: {}, + /** A list key over the Inbox's open incidents: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, CompletedFetchDeliveries: { key: Schema.String, result: LoadOutcome(Schema.Array(AlertDeliveryEventDocument)), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 9348526d11..da370705bd 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -72,6 +72,8 @@ export const Model = Schema.Struct({ hasToggleFailed: Schema.Boolean, /** The rules or incidents stream stopped, or stalled, before its first answer. */ isLoadFailed: Schema.Boolean, + /** Inbox: the keyboard-focused open incident, by id (the live list reorders under it). */ + focusedIncidentId: Schema.NullOr(Schema.String), /** Routing: destinations with a test or delete in flight, and the one awaiting delete confirmation. */ testingDestinationIds: Schema.Array(AlertDestinationId), /** Inbox: incidents with an acknowledge or unacknowledge request in flight. */ diff --git a/apps/web/src/page/alerts/hub/subscription.ts b/apps/web/src/page/alerts/hub/subscription.ts index 99cc5e8399..1cf6a27a43 100644 --- a/apps/web/src/page/alerts/hub/subscription.ts +++ b/apps/web/src/page/alerts/hub/subscription.ts @@ -6,6 +6,8 @@ import * as DestinationDialog from "../destination/dialog" import * as ChatConnectStream from "../shared/chat-connect-stream" import { collectionLoadFailed } from "../../kit/collection-load" import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../../kit/alert-collections" +import { listKeys } from "../../kit/list-keys" +import { inboxIncidentsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -19,6 +21,19 @@ const HUB_LOAD_TIMEOUT_MS = 10_000 export const subscriptions = Subscription.make, Message, Resources>()( (entry) => ({ + /** The Inbox's list keys while it lists open incidents; an open menu keeps its arrows. */ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: inboxIncidentsOf(model).length > 0 && !model.newRuleMenu.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) + : Stream.empty, + }, + ), rules: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), dependenciesToStream: ({ orgId }) => diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index b98878bb1b..c87c3ffaeb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -27,7 +27,7 @@ import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" -import { alertsHref, createHref, ruleHref } from "../../kit/alert-links" +import { alertsHref, createHref, incidentHref, ruleHref } from "../../kit/alert-links" import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT, chainHref } from "../shared/escalation-chains" import { editScheduleHref, @@ -69,7 +69,8 @@ import { } from "./command" import { withAcknowledgement } from "../shared/commands" import { testSendFailureMessage } from "../shared/delivery-failure" -import { resolveStatus } from "./derive" +import { inboxIncidentsOf, resolveStatus } from "./derive" +import { focusedIndexOf, listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { Message, type RuleBucket } from "./message" import type { Model } from "./model" import { acceptGrants, canManageAlerts } from "../../kit/session-grants" @@ -314,6 +315,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { togglingRuleId: null, hasToggleFailed: false, isLoadFailed: false, + focusedIncidentId: null, testingDestinationIds: [], acknowledgingIncidentIds: [], deletingDestinationIds: [], @@ -385,7 +387,11 @@ export const init = (route: HubRoute, context: PageContext): Return => { } export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { - const sheet = followSilenceSheet({ ...model, ...fromRoute(route) }, context) + const next = fromRoute(route) + // Another tab or signal view is another row set: the Inbox focus starts over. + const focusedIncidentId = + next.tab === model.tab && next.search.signal === model.search.signal ? model.focusedIncidentId : null + const sheet = followSilenceSheet({ ...model, ...next, focusedIncidentId }, context) const routing = requestRoutingDeliveries(sheet.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") @@ -411,6 +417,31 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext } } +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollAlertsHubFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** The Inbox's list keys: Enter follows the focused incident's link. */ +const pressedListKey = (model: Model, key: string): Return => { + const ids = Array.map(inboxIncidentsOf(model), (incident) => incident.id) + const action = listKeyAction(key, focusedIndexOf(ids, model.focusedIncidentId), ids.length) + if (action._tag === "Focus") { + const focusedIncidentId = action.index === null ? null : (ids[action.index] ?? null) + return { + model: { ...model, focusedIncidentId }, + commands: focusedIncidentId === null ? [] : [ScrollFocusedRow()], + } + } + const id = action._tag === "Open" ? ids[action.index] : undefined + return id === undefined + ? { model } + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ url: incidentHref(id), isReplace: false }), + } +} + const navigate = (search: Model["search"], tab: Model["tab"]): PageOutMessage => PageOutMessage.RequestedNavigation({ url: alertsHref(search, tab), isReplace: false }) @@ -1092,6 +1123,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model: { ...model, isLoadFailed: true } } : { model }, // A stopped shape syncs again only on fresh collections. + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), ClickedRetryLoad: () => ({ model: { ...model, isLoadFailed: false }, outMessage: PageOutMessage.RequestedReconnectOrgCollections(), diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index deecc00eb2..d8b7a95056 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -33,7 +33,7 @@ import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table" import { severityBadge } from "../shared/badges" import { HEADROOM_FILL, HEADROOM_TEXT } from "../shared/headroom-tone" import { incidentHref, ruleHref } from "../../kit/alert-links" -import { sortIncidents } from "../shared/sort" +import { inboxOrder } from "./derive" import { msIso } from "../../kit/alert-time" import { gridListRow } from "../../kit/grid-list-row" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -70,16 +70,12 @@ export const inboxTab = ( h: HtmlBuilder, data: AlertsOverviewReady, acknowledge: InboxAcknowledge, + /** The keyboard-focused open incident, when the page has one. */ + focusedId: string | null = null, ): Html => { const { rules, incidents, openIncidents, derivedByRuleId, statesByRule, timelineRange } = data const now = timelineRange.max - const all = sortIncidents(openIncidents) - // What still needs someone first, then what someone is on, then what waits on data. - const sorted = [ - ...Arr.filter(all, (i) => i.holdReason === null && !isAcknowledged(i)), - ...Arr.filter(all, (i) => i.holdReason === null && isAcknowledged(i)), - ...Arr.filter(all, (i) => i.holdReason !== null), - ] + const sorted = inboxOrder(openIncidents) const spots = deriveBlindSpots(rules, derivedByRuleId) const lanes = fireLanes(incidents, timelineRange) const held = Arr.filter(sorted, (i) => i.holdReason !== null).length @@ -148,7 +144,7 @@ export const inboxTab = ( ]) : sections, ] - : [openIncidentsList(h, sorted, now, acknowledge), sections]), + : [openIncidentsList(h, sorted, now, acknowledge, focusedId), sections]), ], ) } @@ -170,6 +166,7 @@ const openIncidentsList = ( incidents: ReadonlyArray, now: number, acknowledge: InboxAcknowledge, + focusedId: string | null, ) => borderedBox(h, { overflow: "x-auto" }, [ h.div( @@ -194,7 +191,9 @@ const openIncidentsList = ( dividedList( h, { as: "ul", bordered: false }, - Arr.map(incidents, (incident) => openIncidentRow(h, incident, now, acknowledge)), + Arr.map(incidents, (incident) => + openIncidentRow(h, incident, now, acknowledge, incident.id === focusedId), + ), ), ], ), @@ -205,11 +204,12 @@ const openIncidentRow = ( incident: AlertIncidentDocument, now: number, acknowledge: InboxAcknowledge, + isFocused: boolean, ): Html => { const isHeld = incident.holdReason !== null const acked = acknowledgedLine(incident) const tone = incident.severity === "critical" ? "text-severity-error" : "text-severity-warn" - return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID }, [ + return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID, isFocused }, [ h.span([ h.AriaHidden(true), h.Class( diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ec31779fb5..1529e3bfef 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -1,4 +1,4 @@ -import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" +import type { AlertIncidentId } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" @@ -25,6 +25,7 @@ import { anomalyOverviewOf, destinationsOf, isAboutService, + isFirstRun, overviewOf, resolveIncidentStatus, resolveSignal, @@ -178,20 +179,6 @@ const primaryLink = (h: H, href: string, label: string): Html => const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) -/** - * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list - * (an org with only monitors or SLOs has no visible rules but may have incidents to show). - */ -const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { - if (data.rules.length > 0) return false - if (tab === "rules") return true - return ( - resolveSignal(model.search.signal) === null && - !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && - !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") - ) -} - /** * The header's one primary action: what the open section lists. A section whose empty state * already offers it shows none until it has rows, and a viewer who can't create it sees none. @@ -314,7 +301,7 @@ const hubBody = ( acknowledge, }), }) - : inboxTab(h, data, acknowledge) + : inboxTab(h, data, acknowledge, model.focusedIncidentId) return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), ...filters, body]) } return rulesTab(h, model, { diff --git a/apps/web/src/page/dashboards/list/list.scene.test.ts b/apps/web/src/page/dashboards/list/list.scene.test.ts index 9b6ba93730..0ad687e068 100644 --- a/apps/web/src/page/dashboards/list/list.scene.test.ts +++ b/apps/web/src/page/dashboards/list/list.scene.test.ts @@ -1,6 +1,17 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { + Command, + expect as expectView, + expectOutMessage, + given, + role, + scene, + selector, + Subscription, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import { PageOutMessage, type PageContext } from "../../contract" import { CreateDashboard, DeleteDashboard, SavePreferences, SaveTags } from "./command" @@ -8,7 +19,7 @@ import * as TagEditor from "../kit/tag-editor" import * as Combobox from "@maple/views/ui/combobox" import { Message } from "./message" import type { ListDashboard, Model } from "./model" -import { init, routeChanged, update } from "./update" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -228,3 +239,84 @@ describe("dashboards list", () => { ) }) }) + +describe("dashboards list keys", () => { + const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) + const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) + const focusedRow = selector("[data-focused]") + // Newest edit first: Payment, Platform, Checkout. + const boards = [ + dashboard({ updatedAt: "2026-09-29T12:00:00.000Z" }), + dashboard({ + id: "dash_payment", + name: "Payment service health", + updatedAt: "2026-10-01T11:00:00.000Z", + }), + dashboard({ id: "dash_checkout", name: "Checkout health", updatedAt: "2026-09-20T12:00:00.000Z" }), + ] + const pressed = (model: Model, ...keys: ReadonlyArray): Model => + Arr.reduce(keys, model, (next, key) => pageUpdate(next, Message.PressedListKey({ key })).model) + + test("J and K move the focus through the rows in their sorted order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded({}, boards)), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Payment service health"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("Platform overview"), + press("j"), + scrolled(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Checkout health"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Platform overview"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter follows the focused row's link", () => { + scene( + { update: pageUpdate, view }, + given(pressed(loaded({}, boards), "j", "j")), + expectView(role("link", { name: /Platform overview/ })).toHaveAttr( + "href", + "/dashboards/dash_platform", + ), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ url: "/dashboards/dash_platform", isReplace: false }), + ), + ) + }) + + test("starred rows come first, and the focus stays on its dashboard when a star moves it", () => { + const focused = pressed(loaded({}, boards), "j", "j", "j") + const starred = pageUpdate(focused, Message.ToggledFavorite({ id: "dash_checkout" })).model + scene( + { update: pageUpdate, view }, + given(starred), + expectView(focusedRow).toContainText("Checkout health"), + press("k"), + scrolled(), + // Already the first row: the starred section leads the list. + expectView(focusedRow).toContainText("Checkout health"), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Payment service health"), + ) + }) + + test("a new search or sort starts the focus over", () => { + const focused = pressed(loaded({}, boards), "j") + expect(focused.focusedId).toBe("dash_payment") + expect(routeChanged(focused, { search: { q: "health" } }).model.focusedId).toBeNull() + expect(routeChanged(focused, { search: { sort: "name" } }).model.focusedId).toBeNull() + }) +}) diff --git a/apps/web/src/page/dashboards/list/message.ts b/apps/web/src/page/dashboards/list/message.ts index 584b4ad5f1..76cc3738b3 100644 --- a/apps/web/src/page/dashboards/list/message.ts +++ b/apps/web/src/page/dashboards/list/message.ts @@ -35,6 +35,9 @@ export const Message = defineMessageUnion({ result: Schema.Result(Schema.Array(ListDashboard), Schema.String), }, ClickedRetrySync: {}, + /** A list key from the page: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, ChangedSearchDraft: { value: Schema.String }, ElapsedSearchDebounce: { generation: Schema.Number }, ChangedScope: { scope: Scope }, diff --git a/apps/web/src/page/dashboards/list/model.ts b/apps/web/src/page/dashboards/list/model.ts index 4e4bf10c4a..1ef63ebe31 100644 --- a/apps/web/src/page/dashboards/list/model.ts +++ b/apps/web/src/page/dashboards/list/model.ts @@ -75,5 +75,7 @@ export const Model = Schema.Struct({ isCreating: Schema.Boolean, /** The live list never answered within `COLLECTION_LOAD_TIMEOUT_MS`: the HTTP snapshot's state. */ fallback: Fallback, + /** The keyboard-focused row, by id: the live list reorders under it. None until J or K is pressed. */ + focusedId: Schema.NullOr(Schema.String), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/dashboards/list/subscription.ts b/apps/web/src/page/dashboards/list/subscription.ts index 6245f2f09e..0e9093ecd7 100644 --- a/apps/web/src/page/dashboards/list/subscription.ts +++ b/apps/web/src/page/dashboards/list/subscription.ts @@ -7,6 +7,8 @@ import type { Resources } from "../../../platform/resources" import type { PageSubscriptionInput } from "../../contract" import { orgLive } from "../../../data/collections/org-collections" import { loadTimeout } from "../kit/live-sync" +import { listKeys } from "../../kit/list-keys" +import { focusableOf } from "./update" import { Message } from "./message" import { toListDashboard } from "./derive" import type { ListDashboard, Model } from "./model" @@ -37,6 +39,19 @@ export const subscriptions = Subscription.make, Mes dependenciesToStream: ({ orgId }) => dashboards(orgId), }, ), + /** The list's keys while it shows rows; an open menu keeps its arrows. */ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: focusableOf(model).length > 0 && !model.sortMenu.isOpen && !model.rowMenu.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) + : Stream.empty, + }, + ), /** A live list still loading after the bounded wait has failed. */ loadWatchdog: entry( { orgId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/page/dashboards/list/update.ts b/apps/web/src/page/dashboards/list/update.ts index 47bae5aa09..93ba57755a 100644 --- a/apps/web/src/page/dashboards/list/update.ts +++ b/apps/web/src/page/dashboards/list/update.ts @@ -24,7 +24,8 @@ import { } from "./command" import { countLabel } from "@maple/ui/lib/format" import * as TagEditor from "../kit/tag-editor" -import { displayName, queryOf, tagsOf } from "./derive" +import { displayName, queryOf, sectionsOf, tagsOf } from "./derive" +import { focusedIndexOf, listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import * as Combobox from "@maple/views/ui/combobox" import { INITIAL_LOCALE } from "@maple/views/lib/locale" import { Message, type WriteFailure } from "./message" @@ -61,6 +62,7 @@ export const init = (route: Route, _context: PageContext): Return => ({ persistenceError: null, isCreating: false, fallback: { _tag: "Idle" }, + focusedId: null, }, commands: [ReadClock(), LoadPreferences()], }) @@ -72,6 +74,8 @@ export const routeChanged = (model: Model, route: Route): Return => { model: { ...model, search: route.search, + // A new search, tag, scope or sort is a new row set: the focus starts over. + focusedId: null, searchDraft: query === Str.trim(model.searchDraft) ? model.searchDraft : query, tagFilter: { ...model.tagFilter, selected: tagsOf(route.search) }, }, @@ -113,6 +117,31 @@ export const isLoadFailed = (model: Model): boolean => export const isReadOnly = (model: Model): boolean => model.persistenceError !== null || isDegraded(model) +/** The rows on screen, starred first: what the list keys step over. */ +export const focusableOf = (model: Model): ReadonlyArray => { + const listed = listedOf(model) + if (listed === null || model.nowMs === null || !model.isPrefsLoaded) return [] + const sections = sectionsOf(model, listed) + return [...sections.favorited, ...sections.others] +} + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollDashboardsFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter follows the focused row's link. */ +const pressedListKey = (model: Model, key: string): Return => { + const ids = Arr.map(focusableOf(model), (dashboard) => dashboard.id) + const action = listKeyAction(key, focusedIndexOf(ids, model.focusedId), ids.length) + if (action._tag === "Focus") { + const focusedId = action.index === null ? null : (ids[action.index] ?? null) + return { model: { ...model, focusedId }, commands: focusedId === null ? [] : [ScrollFocusedRow()] } + } + const id = action._tag === "Open" ? ids[action.index] : undefined + return id === undefined ? { model } : navigateTo(model, dashboardHref(id)) +} + const navigateWithToast = (model: Model, url: string, title: string): Return => ({ model, outMessage: PageOutMessage.RequestedNavigation({ @@ -313,6 +342,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model } : navigateSearch(model, { q: trimmed === "" ? undefined : trimmed }) }, + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), ChangedScope: ({ scope }) => navigateSearch(model, { scope: scope === "all" ? undefined : scope }), ClickedClearSearch: () => navigateSearch({ ...model, searchDraft: "" }, { q: undefined }), ClickedShowAllTags: () => navigateSearch(model, { tags: undefined }), diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index bb2b77b7f4..d7d559b318 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -15,6 +15,7 @@ import { import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { ROW_FOCUSED_CLASS, rowFocusAttributes } from "@maple/views/ui/row-focus" import { listSectionHeader, toolbarSearch } from "../kit/list-parts" import * as Menu from "@maple/views/ui/menu" import * as MenuView from "@maple/views/ui/menu-view" @@ -193,6 +194,7 @@ type RowContext = Readonly<{ model: Model; nowMs: number; timeZone: string }> /** One link for the whole row, then the star and the kebab above it. */ const dashboardRow = (h: H, context: RowContext, dashboard: ListDashboard, isFavorite: boolean): Html => { + const isFocused = context.model.focusedId === dashboard.id const isEmpty = dashboard.widgetCount === 0 const widgets = `${dashboard.widgetCount} ${pluralize(dashboard.widgetCount, "widget")}` const name = displayName(dashboard.name) @@ -200,8 +202,12 @@ const dashboardRow = (h: H, context: RowContext, dashboard: ListDashboard, isFav dashboard.id, [ h.Class( - "group relative flex items-center border-b border-border last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-within:bg-surface-sunken", + cn( + ROW_FOCUSED_CLASS, + "group relative flex items-center border-b border-border last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-within:bg-surface-sunken", + ), ), + ...rowFocusAttributes(h, isFocused), ], [ h.span( diff --git a/apps/web/src/page/errors/hub/selection.ts b/apps/web/src/page/errors/hub/selection.ts index 0c056fbff6..24ebb88be0 100644 --- a/apps/web/src/page/errors/hub/selection.ts +++ b/apps/web/src/page/errors/hub/selection.ts @@ -8,6 +8,7 @@ import * as Menu from "@maple/views/ui/menu" import * as QuickPick from "../kit/quick-pick" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { SEVERITY_ORDER } from "../../kit/issue-severity" +import { listStepOf, steppedFocus } from "../../kit/list-keys" import { FetchPage, FetchServiceCounts, RunHubWrite } from "./command" import { signalsOf } from "./derive" import { Message } from "./message" @@ -73,15 +74,8 @@ export const pressedListKey = ( case "K": case "ArrowUp": { if (ids.length === 0) return { model } - const isDown = key === "j" || key === "J" || key === "ArrowDown" - const next = isDown - ? current < 0 - ? 0 - : Math.min(current + 1, ids.length - 1) - : current <= 0 - ? 0 - : current - 1 - return { model: { ...model, focusedId: ids[next] ?? model.focusedId } } + const next = steppedFocus(current, listStepOf(key) ?? 1, ids.length) + return { model: { ...model, focusedId: ids[next ?? -1] ?? model.focusedId } } } case "Enter": { const focused = ids[current] diff --git a/apps/web/src/page/investigations/list/derive.ts b/apps/web/src/page/investigations/list/derive.ts index 680320f96e..66833f133b 100644 --- a/apps/web/src/page/investigations/list/derive.ts +++ b/apps/web/src/page/investigations/list/derive.ts @@ -45,6 +45,12 @@ export const isLoadingMoreOf = (model: Model): boolean => { return last !== undefined && (AsyncData.isLoading(last.page.data) || AsyncData.isIdle(last.page.data)) } +/** The rows on screen once the first page has answered: what the list keys step over. */ +export const focusableOf = (model: Model): ReadonlyArray => + AsyncData.isSuccess(model.firstPage.data) || AsyncData.isRefreshing(model.firstPage.data) + ? visibleOf(model, allOf(model)) + : [] + export const visibleOf = (model: Model, all: ReadonlyArray) => { const { search } = model const view = viewOf(search) diff --git a/apps/web/src/page/investigations/list/index.ts b/apps/web/src/page/investigations/list/index.ts index 0deb1e72de..0e92e5b427 100644 --- a/apps/web/src/page/investigations/list/index.ts +++ b/apps/web/src/page/investigations/list/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { subscriptions } from "./subscription" import { init, routeChanged, update } from "./update" import { view } from "./view" @@ -14,6 +15,7 @@ export const page = definePage( update, routeChanged, view, + subscriptions, breadcrumbs: () => [{ label: "Investigations", isPageHeading: true }], }, ) diff --git a/apps/web/src/page/investigations/list/list-keys.scene.test.ts b/apps/web/src/page/investigations/list/list-keys.scene.test.ts new file mode 100644 index 0000000000..94abd5e0ae --- /dev/null +++ b/apps/web/src/page/investigations/list/list-keys.scene.test.ts @@ -0,0 +1,133 @@ +import { OrgId } from "@maple/domain" +import { V2Investigation } from "@maple/domain/http/v2" +import { Array as Arr, Result, Schema } from "effect" +import { + Command, + expect as expectView, + expectOutMessage, + given, + Mount, + scene, + selector, + Subscription, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { MeasureTabIndicator, Message as TabsMessage } from "@maple/views/ui/tabs" +import { VERDICT_LAB_CASES } from "../../../lab/verdict-fixture" +import { type PageContext, PageOutMessage } from "../../contract" +import { MountInvestigationRows } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" +import { view as pageView } from "./view" + +// J / K / arrows step through the rows on screen, Enter opens the focused one, Escape lets go. + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_acme"), + pinnedNamespace: null, + timeZone: "UTC", +} +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const base = Arr.getUnsafe(VERDICT_LAB_CASES, 0).investigation +const decodeId = Schema.decodeSync(V2Investigation.fields.id) + +/** A diagnosed (Active) investigation; a later `minute` sorts it higher. */ +const investigation = (n: number, title: string, minute: number): V2Investigation => ({ + ...base, + id: decodeId(`${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111`), + snapshot: { ...base.snapshot, title }, + updated_at: `2026-09-18T09:${minute}:00.000Z`, +}) + +const newest = investigation(4, "Checkout latency regression", 30) +const older = investigation(5, "Search index lag", 20) +const oldest = investigation(6, "Export job retries", 10) + +const loaded = (): Model => { + const entered = init({ search: {} }, context).model + return pageUpdate( + entered, + Message.CompletedFetchFirstPage({ + key: entered.firstPage.key ?? "", + result: Result.succeed({ data: [oldest, newest, older], hasMore: false, nextCursor: null }), + }), + ).model +} + +const measured = () => + Mount.resolve( + MeasureTabIndicator, + TabsMessage.MeasuredIndicator({ + rect: { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 }, + }), + ) +const rowsMounted = () => Mount.resolve(MountInvestigationRows, Message.ClickedRow({ id: newest.id })) +const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) +const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) +const focusedRow = selector("[data-focused]") + +describe("investigations list keys", () => { + test("J and K move the focus through the rows in their sorted order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + measured(), + rowsMounted(), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Checkout latency regression"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("Search index lag"), + press("j"), + scrolled(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Export job retries"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Search index lag"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter opens the focused investigation as a click on its row does", () => { + const focused = Arr.reduce( + ["j", "j"], + loaded(), + (model, key) => pageUpdate(model, Message.PressedListKey({ key })).model, + ) + const entered = pageUpdate(focused, Message.PressedListKey({ key: "Enter" })) + expect(entered.outMessage).toEqual( + pageUpdate(focused, Message.ClickedRow({ id: older.id })).outMessage, + ) + scene( + { update: pageUpdate, view }, + given(focused), + measured(), + rowsMounted(), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ url: `/investigations/${older.id}`, isReplace: false }), + ), + ) + }) + + test("Enter with no focused row opens nothing", () => { + expect(pageUpdate(loaded(), Message.PressedListKey({ key: "Enter" })).outMessage).toBeUndefined() + }) + + test("a new view, search or sort starts the focus over", () => { + const focused = pageUpdate(loaded(), Message.PressedListKey({ key: "j" })).model + expect(focused.focusedIndex).toBe(0) + expect(routeChanged(focused, { search: { q: "search" } }, context).model.focusedIndex).toBeNull() + expect(routeChanged(focused, { search: { view: "history" } }, context).model.focusedIndex).toBeNull() + expect(routeChanged(focused, { search: { dir: "asc" } }, context).model.focusedIndex).toBeNull() + }) +}) diff --git a/apps/web/src/page/investigations/list/message.ts b/apps/web/src/page/investigations/list/message.ts index 2dafe6dbd6..3979c70d9f 100644 --- a/apps/web/src/page/investigations/list/message.ts +++ b/apps/web/src/page/investigations/list/message.ts @@ -28,6 +28,9 @@ export const Message = defineMessageUnion({ ElapsedSearchDebounce: { generation: Schema.Number }, ClickedClearFilters: {}, ClickedRow: { id: Schema.String }, + /** A list key from the page: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, CompletedLoadSignalPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, GotTabsMessage: { message: Tabs.Message }, GotKindSelectMessage: { message: Select.Message }, diff --git a/apps/web/src/page/investigations/list/model.ts b/apps/web/src/page/investigations/list/model.ts index 21b28fb134..72060938c1 100644 --- a/apps/web/src/page/investigations/list/model.ts +++ b/apps/web/src/page/investigations/list/model.ts @@ -49,5 +49,7 @@ export const Model = Schema.Struct({ tabs: Tabs.Model, kindSelect: Select.Model, sortSelect: Select.Model, + /** The keyboard-focused row; none until J or K is pressed. */ + focusedIndex: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/investigations/list/subscription.ts b/apps/web/src/page/investigations/list/subscription.ts new file mode 100644 index 0000000000..8750b97d79 --- /dev/null +++ b/apps/web/src/page/investigations/list/subscription.ts @@ -0,0 +1,23 @@ +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { PageSubscriptionInput } from "../../contract" +import { listKeys } from "../../kit/list-keys" +import { focusableOf } from "./derive" +import { Message } from "./message" +import type { Model } from "./model" + +// The list's keys, from anywhere on the page, while it shows rows. An open select keeps its arrows. + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: + focusableOf(model).length > 0 && !model.kindSelect.isOpen && !model.sortSelect.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) : Stream.empty, + }, + ), +})) diff --git a/apps/web/src/page/investigations/list/update.ts b/apps/web/src/page/investigations/list/update.ts index dec4b78ba0..a90b4b06f1 100644 --- a/apps/web/src/page/investigations/list/update.ts +++ b/apps/web/src/page/investigations/list/update.ts @@ -21,7 +21,9 @@ import { ReadInvestigationsGrants, WaitInvestigationsSearch, } from "./command" +import { listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { + focusableOf, isLoadingMoreOf, KIND_FILTER_VALUES, nextCursorOf, @@ -64,6 +66,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): grants: isClerkAuthEnabled ? [] : Policy.allPermissions, searchDraft: search.q ?? "", searchGeneration: 0, + focusedIndex: null, tabs: Tabs.init({ idBase: 0, values: VIEWS, selected: viewOf(search) }), kindSelect: Select.init("investigations-kind", { value: search.kind ?? "all", @@ -103,6 +106,8 @@ export const routeChanged = ( model: { ...model, search, + // A new view, filter, search or sort is a new row set: the focus starts over. + focusedIndex: null, searchDraft: (Str.trim(model.searchDraft) || undefined) === search.q ? model.searchDraft @@ -123,6 +128,32 @@ const navigate = (model: Model, search: Search): Return => ({ outMessage: PageOutMessage.RequestedNavigation({ url: searchHref(search), isReplace: false }), }) +const openedRow = (model: Model, id: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: hrefOf(AppRoute.Investigation({ id, search: {} })), + isReplace: false, + }), +}) + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollInvestigationsFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter opens the focused row exactly as a click on it does. */ +const pressedListKey = (model: Model, key: string): Return => { + const rows = focusableOf(model) + const action = listKeyAction(key, model.focusedIndex, rows.length) + if (action._tag === "Focus") + return { + model: { ...model, focusedIndex: action.index }, + commands: action.index === null ? [] : [ScrollFocusedRow()], + } + const row = action._tag === "Open" ? rows[action.index] : undefined + return row === undefined ? { model } : openedRow(model, row.id) +} + const isKind = (value: string): value is NonNullable => Arr.some(KIND_FILTER_VALUES, (kind) => kind !== "all" && kind === value) @@ -222,13 +253,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re : { model } }, ClickedClearFilters: () => navigate(model, { ...model.search, kind: undefined, q: undefined }), - ClickedRow: ({ id }) => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: hrefOf(AppRoute.Investigation({ id, search: {} })), - isReplace: false, - }), - }), + ClickedRow: ({ id }) => openedRow(model, id), + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), CompletedLoadSignalPresence: ({ result }) => ({ model: { ...model, presence: AsyncData.settle(model.presence, result) }, }), diff --git a/apps/web/src/page/investigations/list/view.ts b/apps/web/src/page/investigations/list/view.ts index f81184a2c3..e64d512d68 100644 --- a/apps/web/src/page/investigations/list/view.ts +++ b/apps/web/src/page/investigations/list/view.ts @@ -245,7 +245,13 @@ const rowFinding = (h: H, investigation: V2Investigation): Html => { ) } -const row = (h: H, investigation: V2Investigation, nowMs: number, timeZone: string): Html => { +const row = ( + h: H, + investigation: V2Investigation, + nowMs: number, + timeZone: string, + isFocused: boolean, +): Html => { const headline = investigationHeadline(investigation) const scope = investigationScope(investigation) return listRow( @@ -253,6 +259,7 @@ const row = (h: H, investigation: V2Investigation, nowMs: number, timeZone: stri { as: "li", divided: true, + isFocused, className: "cursor-pointer gap-3.5 py-3.5", leading: hstack(h, { align: "center", justify: "center", as: "span", className: "w-5" }, [ kindMarker(h, investigation.subject, investigation.seeded_by), @@ -565,7 +572,9 @@ export const view = Submodel.defineView((model, vstack( h, { as: "ul", attributes: [h.OnMount(MountInvestigationRows())] }, - Arr.map(visible, (investigation) => row(h, investigation, model.nowMs, timeZone)), + Arr.map(visible, (investigation, index) => + row(h, investigation, model.nowMs, timeZone, model.focusedIndex === index), + ), ), ...(hasMore ? [ diff --git a/apps/web/src/page/kit/grid-list-row.ts b/apps/web/src/page/kit/grid-list-row.ts index 3c74c56e55..d4b91d56b1 100644 --- a/apps/web/src/page/kit/grid-list-row.ts +++ b/apps/web/src/page/kit/grid-list-row.ts @@ -1,19 +1,33 @@ import { cn } from "@maple/ui/lib/utils" +import { ROW_FOCUSED_CLASS, rowFocusAttributes } from "@maple/views/ui/row-focus" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" /** * One `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on - * hover and press, positioned so a stretched row link covers it. + * hover and press, positioned so a stretched row link covers it. `isFocused` marks the list + * page's keyboard-focused row. */ export const gridListRow = ( h: HtmlBuilder, - options: Readonly<{ key: string; grid: string; attributes?: ReadonlyArray> }>, + options: Readonly<{ + key: string + grid: string + isFocused?: boolean + attributes?: ReadonlyArray> + }>, children: ReadonlyArray, ): Html => h.li( [ h.Key(options.key), - h.Class(cn(options.grid, "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken")), + h.Class( + cn( + options.grid, + ROW_FOCUSED_CLASS, + "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken", + ), + ), + ...rowFocusAttributes(h, options.isFocused), ...(options.attributes ?? []), ], children, diff --git a/apps/web/src/page/kit/list-keys.test.ts b/apps/web/src/page/kit/list-keys.test.ts new file mode 100644 index 0000000000..fc4c7765c0 --- /dev/null +++ b/apps/web/src/page/kit/list-keys.test.ts @@ -0,0 +1,63 @@ +import { Array as Arr } from "effect" +import { describe, expect, test } from "vitest" +import { focusedIndexOf, listKeyAction, listStepOf, steppedFocus } from "./list-keys" + +describe("steppedFocus", () => { + test("no focus goes to the first row in either direction", () => { + expect(steppedFocus(null, 1, 3)).toBe(0) + expect(steppedFocus(null, -1, 3)).toBe(0) + }) + + test("a step moves one row and holds at both ends", () => { + expect(steppedFocus(0, 1, 3)).toBe(1) + expect(steppedFocus(2, 1, 3)).toBe(2) + expect(steppedFocus(1, -1, 3)).toBe(0) + expect(steppedFocus(0, -1, 3)).toBe(0) + }) + + test("a focus past a shrunken list comes back to its last row", () => { + expect(steppedFocus(9, 1, 3)).toBe(2) + expect(steppedFocus(9, -1, 3)).toBe(2) + }) + + test("an empty list keeps what it had", () => { + expect(steppedFocus(null, 1, 0)).toBeNull() + expect(steppedFocus(4, -1, 0)).toBe(4) + }) +}) + +describe("listStepOf", () => { + test("J and the down arrow step forward, K and the up arrow back, anything else not at all", () => { + expect(Arr.map(["j", "J", "ArrowDown"], listStepOf)).toEqual([1, 1, 1]) + expect(Arr.map(["k", "K", "ArrowUp"], listStepOf)).toEqual([-1, -1, -1]) + expect(Arr.map(["Enter", "Escape", "x"], listStepOf)).toEqual([null, null, null]) + }) +}) + +describe("listKeyAction", () => { + test("a step key moves the focus, and does nothing over no rows", () => { + expect(listKeyAction("j", null, 2)).toEqual({ _tag: "Focus", index: 0 }) + expect(listKeyAction("ArrowDown", 0, 2)).toEqual({ _tag: "Focus", index: 1 }) + expect(listKeyAction("k", 1, 2)).toEqual({ _tag: "Focus", index: 0 }) + expect(listKeyAction("j", null, 0)).toEqual({ _tag: "None" }) + }) + + test("Enter opens the focused row, and nothing without one or past the rows", () => { + expect(listKeyAction("Enter", 1, 2)).toEqual({ _tag: "Open", index: 1 }) + expect(listKeyAction("Enter", null, 2)).toEqual({ _tag: "None" }) + expect(listKeyAction("Enter", 5, 2)).toEqual({ _tag: "None" }) + }) + + test("Escape clears the focus", () => { + expect(listKeyAction("Escape", 1, 2)).toEqual({ _tag: "Focus", index: null }) + }) +}) + +describe("focusedIndexOf", () => { + test("the focused id's place among the rows now, none once it left", () => { + expect(focusedIndexOf(["a", "b", "c"], "c")).toBe(2) + expect(focusedIndexOf(["c", "a"], "c")).toBe(0) + expect(focusedIndexOf(["a", "b"], "gone")).toBeNull() + expect(focusedIndexOf(["a", "b"], null)).toBeNull() + }) +}) diff --git a/apps/web/src/page/kit/list-keys.ts b/apps/web/src/page/kit/list-keys.ts new file mode 100644 index 0000000000..688637ba6d --- /dev/null +++ b/apps/web/src/page/kit/list-keys.ts @@ -0,0 +1,94 @@ +import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" +import { Array as Arr, Effect, Option, type Stream } from "effect" +import { Command, Dom } from "foldkit" +import type { CallableTaggedStruct } from "foldkit/schema" + +// Keyboard row navigation for a list page: the document key stream (J, K, the arrows, Enter and Escape), the focus step and the scroll that keeps the focused row in view. + +const NAVIGATION_KEYS = ["j", "J", "k", "K", "ArrowDown", "ArrowUp", "Enter", "Escape"] + +/** A focused control keeps its own Enter. */ +const keepsEnter = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) return false + const control = target.closest("a, button, [role='tab'], [role='menuitem'], [role='option'], tr") + return control instanceof HTMLElement && control.tabIndex >= 0 +} + +export type ListKeyOptions = Readonly<{ + toMessage: (key: string) => M + /** The page's own keys beside the navigation set. */ + extraKeys?: ReadonlyArray + /** Whether something else owns this key right now; by default an open dialog owns them all. */ + isBlocked?: (key: string) => boolean +}> + +/** The list's keys from anywhere on the page, while no field has focus and no modifier is held. */ +export const listKeys = (options: ListKeyOptions): Stream.Stream => { + const keys = new Set([...NAVIGATION_KEYS, ...(options.extraKeys ?? [])]) + const isBlocked = options.isBlocked ?? isDialogOpen + return Dom.streamFromEventFilterMapPreventDefault({ + target: () => document, + type: "keydown", + filterMapEvent: (event) => { + const key = pressedKey(event) + if (!keys.has(key) || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) + return Option.none() + if (isEditableTarget(event.target)) return Option.none() + if (key === "Enter" && keepsEnter(event.target)) return Option.none() + return isBlocked(key) ? Option.none() : Option.some(options.toMessage(key)) + }, + }) +} + +/** What a navigation key steps by: down and J forward, up and K back. */ +export const listStepOf = (key: string): 1 | -1 | null => + key === "j" || key === "J" || key === "ArrowDown" + ? 1 + : key === "k" || key === "K" || key === "ArrowUp" + ? -1 + : null + +/** The focus after a step over `count` rows: none goes to the first row, both ends hold. */ +export const steppedFocus = (current: number | null, step: 1 | -1, count: number): number | null => { + if (count <= 0) return current + if (current === null || current < 0) return 0 + return Math.max(0, Math.min(current + step, count - 1)) +} + +/** What a list key asks of the page that pressed it. */ +export type ListKeyAction = + | Readonly<{ _tag: "Focus"; index: number | null }> + | Readonly<{ _tag: "Open"; index: number }> + | Readonly<{ _tag: "None" }> + +/** J / K / arrows move the focus, Enter opens the focused row, Escape clears it. */ +export const listKeyAction = (key: string, focusedIndex: number | null, count: number): ListKeyAction => { + const step = listStepOf(key) + if (step !== null) + return count > 0 + ? { _tag: "Focus", index: steppedFocus(focusedIndex, step, count) } + : { _tag: "None" } + if (key === "Enter" && focusedIndex !== null && focusedIndex < count) + return { _tag: "Open", index: focusedIndex } + return key === "Escape" ? { _tag: "Focus", index: null } : { _tag: "None" } +} + +/** A live list reorders under its focus, so it keeps the row's id: that id's place among the rows now. */ +export const focusedIndexOf = (ids: ReadonlyArray, focusedId: string | null): number | null => + focusedId === null ? null : Option.getOrNull(Arr.findFirstIndex(ids, (id) => id === focusedId)) + +const FOCUSED_ROW = "[data-focused]" + +/** A page's scroll that keeps its focused row in view, under its own command name. */ +export const scrollFocusedRowCommand = ( + name: Name, + completed: CallableTaggedStruct>, +) => + Command.define(name, { + messages: [completed], + // Resolved after the commit that marks the row; a row that left the list has nothing to scroll. + execute: Dom.scrollIntoView(FOCUSED_ROW, { block: "nearest" }).pipe( + Effect.ignore, + Effect.as(completed()), + ), + }) diff --git a/apps/web/src/page/metrics/browse/browse.scene.test.ts b/apps/web/src/page/metrics/browse/browse.scene.test.ts index bd7480f27a..f86b4719a1 100644 --- a/apps/web/src/page/metrics/browse/browse.scene.test.ts +++ b/apps/web/src/page/metrics/browse/browse.scene.test.ts @@ -1,13 +1,26 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr, Record as Rec } from "effect" -import { expect as expectView, given, label, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { + Command, + expect as expectView, + expectOutMessage, + given, + label, + Mount, + role, + scene, + selector, + Subscription, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { PageOutMessage, type PageContext } from "../../contract" import type { MetricRow } from "../shared" import { Message } from "./message" import type { Model } from "./model" -import { init, routeChanged, update } from "./update" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -197,3 +210,72 @@ describe("metrics explorer", () => { ) }) }) + +describe("metrics table keys", () => { + const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) + const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) + const focusedRow = selector("[data-focused]") + const rows = [row("http.server.duration", "histogram"), row("process.cpu.time", "sum")] + + /** The table view with its rows in. */ + const tableLoaded = (): Model => { + const model = windowed({ view: "table" }) + return pageUpdate( + model, + Message.CompletedFetchTable({ key: model.table.key ?? "", result: Result.succeed(rows) }), + ).model + } + + test("J and K move the focus through the table rows, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(tableLoaded()), + themeResolved(), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("http.server.duration"), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("process.cpu.time"), + press("ArrowUp"), + scrolled(), + expectView(focusedRow).toContainText("http.server.duration"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter follows the focused row's link", () => { + scene( + { update: pageUpdate, view }, + given(tableLoaded()), + themeResolved(), + press("j"), + scrolled(), + expectView(role("link", { name: "http.server.duration" })).toHaveAttr( + "href", + "/metrics/http.server.duration?type=histogram", + ), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: "/metrics/http.server.duration?type=histogram", + isReplace: false, + }), + ), + ) + }) + + test("the grid view has no rows to step over", () => { + const pressed = pageUpdate(loaded(rows), Message.PressedListKey({ key: "j" })) + expect(pressed.model.focusedIndex).toBeNull() + }) + + test("a new search starts the focus over", () => { + const focused = pageUpdate(tableLoaded(), Message.PressedListKey({ key: "j" })).model + expect(focused.focusedIndex).toBe(0) + const searched = routeChanged(focused, { search: { view: "table", q: "cpu" } }, context).model + expect(searched.focusedIndex).toBeNull() + }) +}) diff --git a/apps/web/src/page/metrics/browse/index.ts b/apps/web/src/page/metrics/browse/index.ts index ed3bd2e361..3250a01a7f 100644 --- a/apps/web/src/page/metrics/browse/index.ts +++ b/apps/web/src/page/metrics/browse/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { subscriptions } from "./subscription" import { contextChanged, init, routeChanged, update } from "./update" import { view } from "./view" @@ -15,6 +16,7 @@ export const page = definePage( routeChanged, contextChanged, view, + subscriptions, breadcrumbs: () => [{ label: "Metrics", isPageHeading: true }], }, ) diff --git a/apps/web/src/page/metrics/browse/message.ts b/apps/web/src/page/metrics/browse/message.ts index 01c3eb7915..0655cb3fdc 100644 --- a/apps/web/src/page/metrics/browse/message.ts +++ b/apps/web/src/page/metrics/browse/message.ts @@ -31,6 +31,9 @@ export const Message = defineMessageUnion({ ClickedLoadMorePages: {}, ClickedLoadMoreTable: {}, ClickedMetricCard: { metricName: Schema.String, metricType: Schema.String }, + /** A list key over the table: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, ClickedClearFilters: {}, CompletedLoadSignalPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, ...ChartEnvMessages, diff --git a/apps/web/src/page/metrics/browse/model.ts b/apps/web/src/page/metrics/browse/model.ts index 2484352c27..48e599507b 100644 --- a/apps/web/src/page/metrics/browse/model.ts +++ b/apps/web/src/page/metrics/browse/model.ts @@ -59,5 +59,7 @@ export const Model = Schema.Struct({ ...ChartEnvFields, refreshVersion: Schema.Number, isReloading: Schema.Boolean, + /** The table's keyboard-focused row; none until J or K is pressed. */ + focusedIndex: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/metrics/browse/subscription.ts b/apps/web/src/page/metrics/browse/subscription.ts new file mode 100644 index 0000000000..5e37fc5d06 --- /dev/null +++ b/apps/web/src/page/metrics/browse/subscription.ts @@ -0,0 +1,22 @@ +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { PageSubscriptionInput } from "../../contract" +import { listKeys } from "../../kit/list-keys" +import { Message } from "./message" +import type { Model } from "./model" +import { tableRows } from "./update" + +// The table view's list keys, from anywhere on the page; the grid has none. An open select keeps its arrows. + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: tableRows(model).length > 0 && !model.typeSelect.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) : Stream.empty, + }, + ), +})) diff --git a/apps/web/src/page/metrics/browse/update.ts b/apps/web/src/page/metrics/browse/update.ts index 51c9e5ca2c..2a7087da84 100644 --- a/apps/web/src/page/metrics/browse/update.ts +++ b/apps/web/src/page/metrics/browse/update.ts @@ -17,7 +17,8 @@ import { SignalPresenceData } from "@maple/views/data/signal-presence" import { routeWindowStep, snapModeOf } from "../../kit/route-window" import * as TimeRangePicker from "../../kit/time-range-picker" import type { PresetValue } from "../../kit/time-controls" -import { SPARKLINE_METRIC_TYPES } from "../shared" +import { type MetricRow, SPARKLINE_METRIC_TYPES } from "../shared" +import { listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { CompleteReloadSpin, DebounceSearch, @@ -130,7 +131,7 @@ const requestAll = (model: Model, context: PageContext, window: TimeWindow, mode /** A new search, type or window remounts the grid or table. */ const remounted = (model: Model, isNewQuery: boolean): Model => { - const reset = { ...model, visiblePages: 1, tableLimit: TABLE_PAGE_SIZE } + const reset = { ...model, visiblePages: 1, tableLimit: TABLE_PAGE_SIZE, focusedIndex: null } // A new window keeps the previous rows on screen while it loads. return isNewQuery ? { ...reset, catalog: Keyed.idle(), table: Keyed.idle() } : reset } @@ -160,6 +161,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): sparklines: {}, table: Keyed.idle(), tableLimit: TABLE_PAGE_SIZE, + focusedIndex: null, summary: Keyed.idle(), searchInput: route.search.q ?? "", searchGeneration: 0, @@ -231,6 +233,38 @@ const metricHref = (model: Model, metricName: string, metricType: string) => /** The table's row link: the page's own time params plus the metric's type. */ export const metricLink = metricHref +const openedMetric = (model: Model, metricName: string, metricType: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: metricHref(model, metricName, metricType), + isReplace: false, + }), +}) + +/** The table's rows on screen (none in the grid view): what the list keys step over. */ +export const tableRows = (model: Model): ReadonlyArray => + (model.search.view ?? "grid") === "table" + ? Option.getOrElse(AsyncData.getData(model.table.data), () => []).slice(0, model.tableLimit) + : [] + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollMetricsFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter follows the focused row's link. */ +const pressedListKey = (model: Model, key: string): Return => { + const rows = tableRows(model) + const action = listKeyAction(key, model.focusedIndex, rows.length) + if (action._tag === "Focus") + return { + model: { ...model, focusedIndex: action.index }, + commands: action.index === null ? [] : [ScrollFocusedRow()], + } + const row = action._tag === "Open" ? rows[action.index] : undefined + return row === undefined ? { model } : openedMetric(model, row.metricName, row.metricType) +} + const liftTypeSelect = (model: Model, message: Select.Message): Return => { const next = Select.update(model.typeSelect, message) const lifted: Return = { @@ -320,13 +354,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re const next = { ...model, tableLimit: model.tableLimit + TABLE_PAGE_SIZE } return next.window === null ? { model: next } : requestAll(next, context, next.window, "Load") }, - ClickedMetricCard: ({ metricName, metricType }) => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: metricHref(model, metricName, metricType), - isReplace: false, - }), - }), + ClickedMetricCard: ({ metricName, metricType }) => openedMetric(model, metricName, metricType), + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), ClickedClearFilters: () => patchSearch(model, { ...model.search, q: undefined, type: undefined }), CompletedLoadSignalPresence: ({ result }) => ({ model: { ...model, presence: AsyncData.settle(model.presence, result) }, diff --git a/apps/web/src/page/metrics/browse/view-table.ts b/apps/web/src/page/metrics/browse/view-table.ts index eee1d3a380..d11a6ddd7b 100644 --- a/apps/web/src/page/metrics/browse/view-table.ts +++ b/apps/web/src/page/metrics/browse/view-table.ts @@ -15,6 +15,7 @@ import { tableRow, } from "@maple/views/ui/table" import { tableSkeleton } from "@maple/views/ui/table-skeleton" +import { rowFocusAttributes } from "@maple/views/ui/row-focus" import { truncatedText } from "@maple/views/ui/truncated-text" import { emptyValue, serviceDot } from "@maple/views/ui/values" import { metricTypeBadge } from "@maple/views/ui/metric-type" @@ -64,7 +65,7 @@ export const loadingTable = (h: H): Html => tableClassName: "table-fixed", }) -const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string): Html => +const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string, isFocused: boolean): Html => tableRow( h, [ @@ -126,6 +127,7 @@ const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string): Htm ), ], "hover:bg-surface-sunken active:bg-surface-sunken", + rowFocusAttributes(h, isFocused), ) /** The loaded table: rows up to `tableLimit`, dimmed while the next page loads. */ @@ -156,7 +158,9 @@ export const metricsTable = ( ]), tableBody( h, - Arr.map(metrics, (metric) => metricRow(h, model, metric, timeZone)), + Arr.map(metrics, (metric, index) => + metricRow(h, model, metric, timeZone, model.focusedIndex === index), + ), ), ]), ], diff --git a/apps/web/src/page/services/list/derive.ts b/apps/web/src/page/services/list/derive.ts index b7263bcd06..e5983dae43 100644 --- a/apps/web/src/page/services/list/derive.ts +++ b/apps/web/src/page/services/list/derive.ts @@ -141,6 +141,15 @@ export const groupServices = (services: ReadonlyArray, mode: GroupBy ) : [[NO_NAMESPACE, groupByEnvironment(services)]] +/** The rows in the order the table draws them, group by group: what the list keys step over. */ +export const orderedServices = ( + services: ReadonlyArray, + mode: GroupBy, +): ReadonlyArray => + Arr.flatMap(groupServices(services, mode), ([, environments]) => + Arr.flatMap(environments, ([, rows]) => rows), + ) + const MIN_DEPLOY_COMPARE_SPANS = 50 const ROLLOUT_MIN_SHARE_PCT = 5 diff --git a/apps/web/src/page/services/list/message.ts b/apps/web/src/page/services/list/message.ts index 45e2c86ad7..277293b0ec 100644 --- a/apps/web/src/page/services/list/message.ts +++ b/apps/web/src/page/services/list/message.ts @@ -62,6 +62,9 @@ export const Message = defineMessageUnion({ ClickedClearNamespacePin: {}, RemovedFilterChip: { param: FilterParam }, ClickedServiceRow: { serviceName: Schema.String, environment: Schema.String }, + /** A list key from the page: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, ClickedServiceLink: { url: Schema.String }, GotFilterSectionsMessage: { message: FilterSections.Message }, GotSparklineMessage: { message: Chart.Message }, diff --git a/apps/web/src/page/services/list/model.ts b/apps/web/src/page/services/list/model.ts index 50148800ac..4636ce08d5 100644 --- a/apps/web/src/page/services/list/model.ts +++ b/apps/web/src/page/services/list/model.ts @@ -94,5 +94,7 @@ export const Model = Schema.Struct({ /** Reloads so far (non-zero stops snapping) and the 600 ms spinner. */ refreshVersion: Schema.Number, isReloading: Schema.Boolean, + /** The keyboard-focused row, by its place in the table's order; none until J or K is pressed. */ + focusedIndex: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/services/list/services.scene.test.ts b/apps/web/src/page/services/list/services.scene.test.ts index 4223d4d2ae..38fe92b8d7 100644 --- a/apps/web/src/page/services/list/services.scene.test.ts +++ b/apps/web/src/page/services/list/services.scene.test.ts @@ -3,6 +3,7 @@ import { Result, Schema, Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import { click, + Command, expect as expectView, expectOutMessage, given, @@ -10,6 +11,8 @@ import { Mount, role, scene, + selector, + Subscription, text, withViewInputs, } from "foldkit/scene" @@ -28,7 +31,7 @@ import { } from "./command" import { Message } from "./message" import type { Model, ServiceRow } from "./model" -import { init, overviewInput, routeChanged, update } from "./update" +import { init, overviewInput, routeChanged, ScrollFocusedRow, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -297,3 +300,61 @@ describe("services list", () => { ) }) }) + +describe("services list keys", () => { + const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) + const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) + const focusedRow = selector("[data-focused]") + // Grouped by environment, production first: checkout, then the staging cart. + const rows = [row("cart", "staging"), row("checkout")] + + test("J and K move the focus through the rows in the table's order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded(rows)), + themeResolved(), + Mount.resolve(MountTable, Message.PaintedTable()), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("checkout"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("cart"), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("cart"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("checkout"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter opens the focused service as a click on its row does", () => { + const focused = pageUpdate(loaded(rows), Message.PressedListKey({ key: "j" })).model + const clicked = pageUpdate( + focused, + Message.ClickedServiceRow({ serviceName: "checkout", environment: "production" }), + ) + const entered = pageUpdate(focused, Message.PressedListKey({ key: "Enter" })) + expect(entered.outMessage).toEqual(clicked.outMessage) + expect(entered.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/services/checkout?environments=%5B%22production%22%5D", + isReplace: false, + }), + ) + }) + + test("Enter with no focused row opens nothing", () => { + expect(pageUpdate(loaded(rows), Message.PressedListKey({ key: "Enter" })).outMessage).toBeUndefined() + }) + + test("a new filter starts the focus over", () => { + const focused = pageUpdate(loaded(rows), Message.PressedListKey({ key: "j" })).model + const filtered = routeChanged(focused, { search: { environments: ["staging"] } }, context).model + expect(filtered.focusedIndex).toBeNull() + }) +}) diff --git a/apps/web/src/page/services/list/subscription.ts b/apps/web/src/page/services/list/subscription.ts index e33e733eed..0fea233817 100644 --- a/apps/web/src/page/services/list/subscription.ts +++ b/apps/web/src/page/services/list/subscription.ts @@ -1,13 +1,27 @@ -import { Schema } from "effect" +import { Schema, Stream } from "effect" import { AsyncData, Subscription } from "foldkit" import type { Resources } from "../../../platform/resources" import type { PageSubscriptionInput } from "../../contract" import { incidentsLoadFailedStream, incidentsStream } from "../../kit/service-health" import { Message } from "./message" import type { Model } from "./model" +import { listKeys } from "../../kit/list-keys" +import { focusableServices } from "./update" export const subscriptions = Subscription.make, Message, Resources>()( (entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model, context }) => ({ + hasRows: focusableServices(model, context).length > 0, + }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) + : Stream.empty, + }, + ), incidents: entry( { orgId: Schema.String }, { diff --git a/apps/web/src/page/services/list/update.ts b/apps/web/src/page/services/list/update.ts index 94ec04e971..810991f2cb 100644 --- a/apps/web/src/page/services/list/update.ts +++ b/apps/web/src/page/services/list/update.ts @@ -28,10 +28,19 @@ import { type OverviewInput, ResolveWindow, } from "./command" -import { commitsKey, displayedServices, healthSummary, isServiceHealth, type Search } from "./derive" +import { + commitsKey, + displayedServices, + healthSummary, + isServiceHealth, + orderedServices, + resolveGroupBy, + type Search, +} from "./derive" +import { listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { type Facet, Message } from "./message" import { editFacet, excludedValue, onlyValue, toggledValue } from "../../kit/facets" -import type { Model } from "./model" +import type { Model, ServiceRow } from "./model" import { anomaliesKey, failedIncidents, IncidentsData, receivedIncidents } from "../../kit/service-health" import { clickedCopySha, @@ -208,6 +217,43 @@ const serviceHref = (model: Model, serviceName: string, environment: string): st }), ) +const openedService = (model: Model, serviceName: string, environment: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: serviceHref(model, serviceName, environment), + isReplace: false, + }), +}) + +/** The rows the table draws, in its order: what the list keys step over. */ +export const focusableServices = (model: Model, context: PageContext): ReadonlyArray => { + const overview = AsyncData.getData(model.overview.data) + if (Option.isNone(overview)) return [] + const services = displayedServices(overview.value, summaryOf(model), model.search) + return orderedServices( + services, + resolveGroupBy(model.search.groupBy, services, context.pinnedNamespace !== null), + ) +} + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollServicesFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter opens the focused row exactly as a click on it does. */ +const pressedListKey = (model: Model, key: string, context: PageContext): Return => { + const rows = focusableServices(model, context) + const action = listKeyAction(key, model.focusedIndex, rows.length) + if (action._tag === "Focus") + return { + model: { ...model, focusedIndex: action.index }, + commands: action.index === null ? [] : [ScrollFocusedRow()], + } + const row = action._tag === "Open" ? rows[action.index] : undefined + return row === undefined ? { model } : openedService(model, row.serviceName, row.environment) +} + const navigate = (model: Model, search: Search): Return => ({ model, outMessage: PageOutMessage.RequestedNavigation({ @@ -240,6 +286,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): nowMs: 0, refreshVersion: 0, isReloading: false, + focusedIndex: null, overview: Keyed.idle(), sparklines: Keyed.idle(), rowRevision: 0, @@ -283,6 +330,7 @@ export const revive = (model: Model, route: { readonly search: Search }, context search: route.search, refreshVersion: 0, isReloading: false, + focusedIndex: null, rowRevision: model.rowRevision + 1, overview: Keyed.forgetFailure(model.overview), sparklines: Keyed.forgetFailure(model.sparklines), @@ -334,7 +382,8 @@ export const routeChanged = ( context: PageContext, ): Return => { if (searchHref(model.search) === searchHref(route.search)) return { model } - const next = { ...model, search: route.search, rowRevision: model.rowRevision + 1 } + // A new search is a new row set: the focus starts over. + const next = { ...model, search: route.search, rowRevision: model.rowRevision + 1, focusedIndex: null } // Re-resolves only on a window change; filters and grouping keep the window. if (!isWindowChange(model.search, route.search) && model.window !== null) return followData(context)(requestAll(next, context, model.window, "Load")) @@ -445,13 +494,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re navigate(model, { ...windowOnly(model.search), groupBy: model.search.groupBy }), ClickedGroupBy: ({ groupBy }) => groupBy === model.search.groupBy ? { model } : navigate(model, { ...model.search, groupBy }), - ClickedServiceRow: ({ serviceName, environment }) => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: serviceHref(model, serviceName, environment), - isReplace: false, - }), - }), + ClickedServiceRow: ({ serviceName, environment }) => openedService(model, serviceName, environment), + PressedListKey: ({ key }) => pressedListKey(model, key, context), + CompletedScrollFocusedRow: () => ({ model }), ClickedServiceLink: ({ url }) => ({ model, outMessage: PageOutMessage.RequestedNavigation({ url, isReplace: false }), diff --git a/apps/web/src/page/services/list/view-table.ts b/apps/web/src/page/services/list/view-table.ts index 8295e61805..6f2e8cd298 100644 --- a/apps/web/src/page/services/list/view-table.ts +++ b/apps/web/src/page/services/list/view-table.ts @@ -23,6 +23,7 @@ import { skeleton } from "@maple/views/ui/skeleton" import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" import { tableSkeleton } from "@maple/views/ui/table-skeleton" import { errorRateValue, latencyValue, serviceDot } from "@maple/views/ui/values" +import { rowFocusAttributes } from "@maple/views/ui/row-focus" import { signalEmptyState } from "../../kit/signal-empty-state" import { presenceOf } from "@maple/views/data/signal-presence" import { @@ -36,6 +37,7 @@ import { type HealthSummary, healthOf, NO_NAMESPACE, + orderedServices, resolveGroupBy, type ServiceHealth, } from "./derive" @@ -197,6 +199,7 @@ const serviceRow = ( subtitle: string, baselines: ReturnType | undefined, groupPath: string, + isFocused: boolean, ): Html => { const health = healthOf(ctx.summary, service) const series = FoldkitAsyncData.getData(ctx.model.sparklines.data).pipe( @@ -345,6 +348,7 @@ const serviceRow = ( ), [ h.Key(rowKey), + ...rowFocusAttributes(h, isFocused), h.Tabindex(0), h.OnClick(open), h.OnKeyDownPreventDefault((key) => @@ -423,6 +427,8 @@ const desktopTable = ( Option.map(FoldkitAsyncData.getData(ctx.model.baseline.data), baselineMapOf), ) const subtitleFor = (service: ServiceRow) => (mode === "namespace" ? "" : service.serviceNamespace) + const focused = + ctx.model.focusedIndex === null ? undefined : orderedServices(services, mode)[ctx.model.focusedIndex] const body = services.length === 0 ? [tableRow(h, [tableCell(h, [emptyState(h, ctx)], "p-0", [h.Attribute("colspan", "7")])])] @@ -472,6 +478,7 @@ const desktopTable = ( subtitleFor(service), baselines, `${namespace}/${environment}`, + service === focused, ), ), ]), diff --git a/apps/web/src/page/traces/list/subscription.ts b/apps/web/src/page/traces/list/subscription.ts index 81f7a830b9..e509aa9f82 100644 --- a/apps/web/src/page/traces/list/subscription.ts +++ b/apps/web/src/page/traces/list/subscription.ts @@ -1,7 +1,8 @@ -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" -import { Option, Schema, Stream } from "effect" -import { Dom, Subscription } from "foldkit" +import { isDialogOpen } from "@maple/ui/lib/keyboard" +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" import type { PageSubscriptionInput } from "../../contract" +import { listKeys } from "../../kit/list-keys" import { MOBILE_QUERY } from "./command" import { Message } from "./message" import type { Model } from "./model" @@ -9,29 +10,13 @@ import { allRows } from "./update" // List navigation over the rows and the peek sheet's keys (↑/↓/J/K step, Enter opens the page): document keys while no field has focus. The peek's keys work with its sheet open. -const KEYS = new Set(["j", "J", "k", "K", "ArrowDown", "ArrowUp", "Enter", "Escape", "f", "F"]) - -/** A focused control keeps its own Enter. */ -const keepsEnter = (target: EventTarget | null): boolean => { - if (!(target instanceof HTMLElement)) return false - const control = target.closest("a, button, [role='tab'], [role='menuitem'], [role='option'], tr") - return control instanceof HTMLElement && control.tabIndex >= 0 -} - const keys = (isPeeking: boolean): Stream.Stream => - Dom.streamFromEventFilterMapPreventDefault({ - target: () => document, - type: "keydown", - filterMapEvent: (event) => { - if (!KEYS.has(event.key) || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) - return Option.none() - if (isEditableTarget(event.target)) return Option.none() - if (event.key === "Enter" && keepsEnter(event.target)) return Option.none() - // The sheet's Escape is the Dialog's; without a peek, a dialog owns the keyboard. - if (isPeeking ? event.key === "Escape" : isDialogOpen()) return Option.none() - const isMobile = window.matchMedia(MOBILE_QUERY).matches - return Option.some(Message.PressedListKey({ key: event.key, isMobile })) - }, + listKeys({ + extraKeys: ["f", "F"], + // The sheet's Escape is the Dialog's; without a peek, a dialog owns the keyboard. + isBlocked: (key) => (isPeeking ? key === "Escape" : isDialogOpen()), + toMessage: (key) => + Message.PressedListKey({ key, isMobile: window.matchMedia(MOBILE_QUERY).matches }), }) export const subscriptions = Subscription.make, Message>()((entry) => ({ diff --git a/apps/web/src/page/traces/list/update.ts b/apps/web/src/page/traces/list/update.ts index adb6b70ba0..d78e6e38f9 100644 --- a/apps/web/src/page/traces/list/update.ts +++ b/apps/web/src/page/traces/list/update.ts @@ -40,6 +40,7 @@ import { Message } from "./message" import type { Model, Pagination, TraceRow } from "./model" import { durationDraftOf } from "./search-edits" import { appendedPage, fetchingNext, freshLaterPages, pagedRows } from "../../kit/infinite-pages" +import { listStepOf, steppedFocus } from "../../kit/list-keys" type Return = PageReturn type Cmd = Command.Command @@ -317,24 +318,14 @@ const openRow = (model: Model, index: number): Return => { /** List navigation over the rows, and the peek's own keys while it is open. */ const pressedListKey = (model: Model, key: string, isMobile: boolean, context: PageContext): Return => { - const isDown = key === "j" || key === "J" || key === "ArrowDown" - const isUp = key === "k" || key === "K" || key === "ArrowUp" + const step = listStepOf(key) if (model.search.peek !== undefined && !isMobile) { - if (isDown || isUp) return steppedPeek(model, isDown ? 1 : -1) + if (step !== null) return steppedPeek(model, step) return key === "Enter" ? openPeekedTrace(model) : { model } } const count = allRows(model).length - if ((isDown || isUp) && count > 0) { - const current = model.focusedIndex ?? -1 - const next = isDown - ? current < 0 - ? 0 - : Math.min(current + 1, count - 1) - : current <= 0 - ? 0 - : current - 1 - return { model: { ...model, focusedIndex: next } } - } + if (step !== null && count > 0) + return { model: { ...model, focusedIndex: steppedFocus(model.focusedIndex, step, count) } } if (key === "Enter" && model.focusedIndex !== null) return isMobile ? openRow(model, model.focusedIndex) : peekRow(model, model.focusedIndex) if (key === "Escape") return { model: { ...model, focusedIndex: null } } diff --git a/apps/web/src/page/traces/list/view-row.ts b/apps/web/src/page/traces/list/view-row.ts index b52d8c3c3c..09138d0cdc 100644 --- a/apps/web/src/page/traces/list/view-row.ts +++ b/apps/web/src/page/traces/list/view-row.ts @@ -16,6 +16,7 @@ import { servicePills, traceStatusBadge } from "@maple/views/ui/trace-badges" import { button } from "@maple/views/ui/button" import { laneTableCell, rowLink, tableCell, tableRow } from "@maple/views/ui/table" import { latencyValue } from "@maple/views/ui/values" +import { rowFocusAttributes } from "@maple/views/ui/row-focus" import { listRelativeTime } from "../../kit/relative-time" import { Message } from "./message" import type { TraceRow } from "./model" @@ -143,12 +144,12 @@ export const traceRow = ( ], cn( state.isActive && "bg-surface-strong", - "hover:bg-surface-sunken active:bg-surface-sunken data-[focused]:bg-surface-strong data-[focused]:ring-1 data-[focused]:ring-ring data-[focused]:ring-inset cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", + "hover:bg-surface-sunken active:bg-surface-sunken cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", ), [ // Keyed by index so a scrolled-in row is a new node the virtualizer measures. h.Key(`row-${index}`), - ...(state.isFocused ? [h.DataAttribute("focused", "true")] : []), + ...rowFocusAttributes(h, state.isFocused), h.DataAttribute("index", String(index)), h.Tabindex(0), h.OnKeyDownPreventDefault((key) => diff --git a/packages/views/src/ui/list-row.styles.ts b/packages/views/src/ui/list-row.styles.ts index 110e179c2a..cc60e271f4 100644 --- a/packages/views/src/ui/list-row.styles.ts +++ b/packages/views/src/ui/list-row.styles.ts @@ -1,5 +1,6 @@ import { cva } from "class-variance-authority" import { cn } from "@maple/ui/lib/utils" +import { ROW_FOCUSED_CLASS } from "./row-focus" // The 2px left lane a selectable row draws its selection in, reserved on every row and painted // only on the selected one so contents never shift as selection moves (the sidebar's idiom). @@ -12,7 +13,7 @@ export function rowSelectedClass(selected: boolean | undefined): string { } export const listRowVariants = cva( - "flex w-full min-w-0 items-center gap-3 text-left outline-none hover:bg-surface-sunken active:bg-surface-sunken focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", + `${ROW_FOCUSED_CLASS} flex w-full min-w-0 items-center gap-3 text-left outline-none hover:bg-surface-sunken active:bg-surface-sunken focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset`, { defaultVariants: { density: "default", divided: false }, variants: { diff --git a/packages/views/src/ui/list-row.ts b/packages/views/src/ui/list-row.ts index 6ab5aa84e0..17d0b66bc8 100644 --- a/packages/views/src/ui/list-row.ts +++ b/packages/views/src/ui/list-row.ts @@ -10,6 +10,7 @@ import { rowSelectedClass, } from "./list-row.styles" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { rowFocusAttributes } from "./row-focus" // Icon + title + meta + trailing row. @@ -20,6 +21,8 @@ export interface ListRowOptions { /** Hairline between rows (the last row drops it). */ readonly divided?: boolean readonly selected?: boolean + /** The list page's keyboard-focused row. */ + readonly isFocused?: boolean readonly leading?: Child readonly title: Child /** Secondary line under the title. */ @@ -58,6 +61,7 @@ export const listRow = ( ): Html => { const attributes = [ ...(options.selected === true ? [h.AriaCurrent("true"), h.DataAttribute("selected", "")] : []), + ...rowFocusAttributes(h, options.isFocused), h.Attribute( "class", cn( diff --git a/packages/views/src/ui/row-focus.ts b/packages/views/src/ui/row-focus.ts new file mode 100644 index 0000000000..1d8ef6b7d3 --- /dev/null +++ b/packages/views/src/ui/row-focus.ts @@ -0,0 +1,14 @@ +import type { Attribute, HtmlBuilder } from "foldkit/html" + +/** + * The keyboard-focused row: the `data-focused` marker a row kit sets and the look it draws for it. + * A list page moves it with J / K (`page/kit/list-keys`); it is not DOM focus. + */ +export const ROW_FOCUSED_CLASS = + "data-[focused]:bg-surface-strong data-[focused]:ring-1 data-[focused]:ring-ring data-[focused]:ring-inset" + +/** The marker on the focused row, nothing on the others. */ +export const rowFocusAttributes = ( + h: HtmlBuilder, + isFocused: boolean | undefined, +): ReadonlyArray> => (isFocused === true ? [h.DataAttribute("focused", "true")] : []) diff --git a/packages/views/src/ui/table.styles.ts b/packages/views/src/ui/table.styles.ts index 66cacaeb2b..6431ecd244 100644 --- a/packages/views/src/ui/table.styles.ts +++ b/packages/views/src/ui/table.styles.ts @@ -1,3 +1,5 @@ +import { ROW_FOCUSED_CLASS } from "./row-focus" + export const tableContainerStyles = "relative w-full" export const tableContainerScrollStyles = "overflow-x-auto" @@ -18,8 +20,7 @@ export const tableBodyStyles = // Body rows divide on the subtle hairline so the header rule reads as the stronger line. The hover is // unprefixed so a row's own `hover:bg-*` merges over it (card rows paint their cells instead). -export const tableRowStyles = - "relative border-b border-border-subtle in-data-[slot=table-header]:border-border in-data-[variant=bare]:border-0 in-data-[variant=bare]:hover:bg-transparent in-data-[variant=bare]:active:bg-transparent hover:bg-surface-sunken active:bg-surface-sunken data-[state=selected]:bg-surface-strong in-data-[variant=card]:hover:bg-transparent in-data-[variant=card]:active:bg-transparent in-data-[variant=card]:data-[state=selected]:bg-transparent" +export const tableRowStyles = `${ROW_FOCUSED_CLASS} relative border-b border-border-subtle in-data-[slot=table-header]:border-border in-data-[variant=bare]:border-0 in-data-[variant=bare]:hover:bg-transparent in-data-[variant=bare]:active:bg-transparent hover:bg-surface-sunken active:bg-surface-sunken data-[state=selected]:bg-surface-strong in-data-[variant=card]:hover:bg-transparent in-data-[variant=card]:active:bg-transparent in-data-[variant=card]:data-[state=selected]:bg-transparent` // Column labels share the eyebrow overline token (size, case, tracking, colour) with list headers. // Div-based tables (infra) use the label type alone. From 1df98d5a0c02ed101aaf6a00689b57ccf066fe48 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:22:41 +0100 Subject: [PATCH 2/4] feat(web): a lab entry that mounts the keyed list pages over fixture rows --- apps/web/src/lab/entries/list-keys.ts | 28 ++ apps/web/src/lab/list-keys-fixture.ts | 234 ++++++++++++++++ apps/web/src/lab/list-keys-lab.ts | 249 ++++++++++++++++++ apps/web/src/lab/registry.ts | 8 + .../page/dashboards/list/list.scene.test.ts | 2 +- packages/views/src/ui/INDEX.md | 2 + 6 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lab/entries/list-keys.ts create mode 100644 apps/web/src/lab/list-keys-fixture.ts create mode 100644 apps/web/src/lab/list-keys-lab.ts diff --git a/apps/web/src/lab/entries/list-keys.ts b/apps/web/src/lab/entries/list-keys.ts new file mode 100644 index 0000000000..acf9f574fb --- /dev/null +++ b/apps/web/src/lab/entries/list-keys.ts @@ -0,0 +1,28 @@ +import { defineShellLab } from "../shell-lab" +import { + breadcrumbs, + currentPath, + Flags, + flags, + init, + Message, + Model, + subscriptions, + update, + view, +} from "../list-keys-lab" + +// `/lab/list-keys`: the list pages that take the row keys, over fixture rows (`?page=`). + +export const lab = defineShellLab("List keys", { + Model, + Message, + Flags, + flags, + init, + update, + view, + subscriptions, + currentPath, + breadcrumbs, +}) diff --git a/apps/web/src/lab/list-keys-fixture.ts b/apps/web/src/lab/list-keys-fixture.ts new file mode 100644 index 0000000000..c65fc14c22 --- /dev/null +++ b/apps/web/src/lab/list-keys-fixture.ts @@ -0,0 +1,234 @@ +import * as Policy from "@maple/domain/policy" +import { V2Investigation } from "@maple/domain/http/v2" +import { Array as Arr, Result, Schema } from "effect" +import type { PageContext } from "../page/contract" +import * as Hub from "../page/alerts/hub/update" +import { Message as HubMessage } from "../page/alerts/hub/message" +import * as Dashboards from "../page/dashboards/list/update" +import { Message as DashboardsMessage } from "../page/dashboards/list/message" +import type { ListDashboard } from "../page/dashboards/list/model" +import * as Investigations from "../page/investigations/list/update" +import { Message as InvestigationsMessage } from "../page/investigations/list/message" +import { hubAt, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../page/kit/alert-test-rows" +import * as Metrics from "../page/metrics/browse/update" +import { Message as MetricsMessage } from "../page/metrics/browse/message" +import type { MetricRow } from "../page/metrics/shared" +import * as Services from "../page/services/list/update" +import { Message as ServicesMessage } from "../page/services/list/message" +import type { ServiceRow } from "../page/services/list/model" +import { VERDICT_LAB_CASES } from "./verdict-fixture" + +// `/lab/list-keys`: each list page's Model as its reads would leave it, over a handful of fixture rows. + +const WINDOW = { startTime: "2026-09-30 12:00:00", endTime: "2026-10-01 12:00:00" } + +const service = ( + serviceName: string, + environment: string, + p95LatencyMs: number, + errorRate: number, +): ServiceRow => ({ + serviceName, + serviceNamespace: "", + environment, + commits: [], + p50LatencyMs: p95LatencyMs / 3, + p95LatencyMs, + p99LatencyMs: p95LatencyMs * 1.8, + errorRate, + throughput: 1200 / p95LatencyMs, + tracedThroughput: 1200 / p95LatencyMs, + hasSampling: false, + samplingWeight: 1, + spanCount: 4200, +}) + +const SERVICES = [ + service("checkout", "production", 182, 0.012), + service("payments", "production", 240, 0), + service("search", "production", 96, 0.002), + service("orders", "production", 310, 0), + service("checkout", "staging", 205, 0), + service("payments", "staging", 260, 0.03), +] + +/** Feeds `messages` through a page's update, keeping only the Model. */ +const fold = ( + entered: Model, + update: (model: Model, message: Message) => { readonly model: Model }, + messages: (model: Model) => ReadonlyArray, +): Model => Arr.reduce(messages(entered), entered, (model, message) => update(model, message).model) + +export const servicesFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: ServicesMessage) => + Services.update(model, message, context) + const windowed = step( + Services.init({ search: {} }, context).model, + ServicesMessage.CompletedResolveWindow({ window: WINDOW, nowMs: NOW, mode: "Load" }), + ).model + return fold(windowed, step, (model) => [ + ServicesMessage.CompletedFetchOverview({ + key: model.overview.key ?? "", + result: Result.succeed(SERVICES), + }), + ServicesMessage.CompletedFetchSparklines({ + key: model.sparklines.key ?? "", + result: Result.succeed({}), + }), + ServicesMessage.CompletedFetchAnomalies({ + key: model.anomalies.key ?? "", + result: Result.succeed([]), + }), + ServicesMessage.CompletedFetchFacets({ + key: model.facets.key ?? "", + result: Result.succeed({ + environments: [ + { name: "production", count: 4 }, + { name: "staging", count: 2 }, + ], + namespaces: [], + commitShas: [], + }), + }), + ServicesMessage.UpdatedIncidents({ orgId: context.orgId, incidents: [] }), + ServicesMessage.CompletedLoadSignalPresence({ + result: Result.succeed({ traces: { status: "present", lastSeenMs: null } }), + }), + ]) +} + +const metric = (metricName: string, metricType: string, serviceName: string): MetricRow => ({ + metricName, + metricType, + serviceName, + metricDescription: "", + metricUnit: "", + dataPointCount: 18_400, + firstSeen: "2026-09-30 12:05:00", + lastSeen: "2026-10-01 11:58:00", + isMonotonic: false, +}) + +const METRICS = [ + metric("http.server.request.duration", "histogram", "checkout"), + metric("http.client.request.duration", "histogram", "payments"), + metric("process.cpu.time", "sum", "search"), + metric("process.memory.usage", "gauge", "orders"), + metric("db.client.connection.count", "gauge", "checkout"), + metric("queue.messages.published", "sum", "orders"), +] + +export const metricsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: MetricsMessage) => + Metrics.update(model, message, context) + const windowed = step( + Metrics.init({ search: { view: "table" } }, context).model, + MetricsMessage.CompletedResolveWindow({ window: WINDOW, nowMs: NOW, mode: "Load" }), + ).model + return fold(windowed, step, (model) => [ + MetricsMessage.CompletedFetchTable({ key: model.table.key ?? "", result: Result.succeed(METRICS) }), + MetricsMessage.CompletedFetchSummary({ key: model.summary.key ?? "", result: Result.succeed([]) }), + MetricsMessage.CompletedLoadSignalPresence({ + result: Result.succeed({ metrics: { status: "present", lastSeenMs: null } }), + }), + ]) +} + +const BASE_INVESTIGATION = Arr.getUnsafe(VERDICT_LAB_CASES, 0).investigation +const decodeInvestigationId = Schema.decodeSync(V2Investigation.fields.id) + +/** A diagnosed investigation; a later `minute` lists it higher. */ +const investigation = (n: number, title: string, minute: number): V2Investigation => ({ + ...BASE_INVESTIGATION, + id: decodeInvestigationId(`${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111`), + snapshot: { ...BASE_INVESTIGATION.snapshot, title }, + updated_at: `2026-10-01T11:${minute}:00.000Z`, +}) + +const INVESTIGATIONS = [ + investigation(4, "TimeoutError: payment capture exceeded 30s", 52), + investigation(5, "Checkout p95 latency doubled after deploy 4f2c1a", 41), + investigation(6, "Search index lag over 5 minutes", 30), + investigation(7, "Export job retries exhausted", 18), +] + +export const investigationsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: InvestigationsMessage) => + Investigations.update(model, message, context) + return fold(Investigations.init({ search: {} }, context).model, step, (model) => [ + InvestigationsMessage.ReadClock({ nowMs: NOW }), + InvestigationsMessage.CompletedReadGrants({ + orgId: context.orgId, + grants: Policy.grantsOf(["org:engineer"]), + }), + InvestigationsMessage.CompletedFetchFirstPage({ + key: model.firstPage.key ?? "", + result: Result.succeed({ data: INVESTIGATIONS, hasMore: false, nextCursor: null }), + }), + InvestigationsMessage.CompletedLoadSignalPresence({ + result: Result.succeed({ traces: { status: "present", lastSeenMs: null } }), + }), + ]) +} + +const dashboard = (id: string, name: string, description: string, day: number): ListDashboard => ({ + id, + name, + description, + tags: [], + widgetCount: 8, + readsShort: "Traces · Errors", + readsFull: "Traces · Errors", + glyph: "PulseIcon", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: `2026-09-${day}T12:00:00.000Z`, +}) + +const DASHBOARDS = [ + dashboard("dash_platform", "Platform overview", "Cross-service health.", 30), + dashboard("dash_payment", "Payment service health", "Capture latency and declines.", 28), + dashboard("dash_checkout", "Checkout funnel", "Cart to order, step by step.", 25), + dashboard("dash_search", "Search quality", "Latency, zero results and index lag.", 21), + dashboard("dash_queues", "Queues and workers", "Depth, age and retries.", 14), +] + +export const dashboardsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: DashboardsMessage) => + Dashboards.update(model, message, context) + return fold(Dashboards.init({ search: {} }, context).model, step, () => [ + DashboardsMessage.CompletedReadClock({ nowMs: NOW, locale: "en-US" }), + DashboardsMessage.CompletedLoadPreferences({ favorites: ["dash_payment"], sort: "updated" }), + DashboardsMessage.UpdatedDashboards({ orgId: context.orgId, dashboards: DASHBOARDS }), + ]) +} + +const incident = (n: number, rule_name: string, fields: Parameters[0] = {}) => + incidentRow({ + id: `0199a003-0000-7000-8000-00000000000${n}`, + rule_id: ruleId(n), + incident_key: `rule-${n}:all`, + dedupe_key: `rule-${n}:all`, + rule_name, + ...fields, + }) + +export const alertsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: HubMessage) => + Hub.update(model, message, context) + return fold(Hub.init(hubAt({}), context).model, step, () => [ + HubMessage.ReadClock({ nowMs: NOW }), + HubMessage.UpdatedRules({ orgId: ORG, rows: Arr.map([1, 2, 3, 4], (n) => ruleRow(n)) }), + HubMessage.UpdatedRuleStates({ orgId: ORG, rows: Arr.map([1, 2, 3, 4], (n) => stateRow(n)) }), + HubMessage.UpdatedIncidents({ + orgId: ORG, + rows: [ + incident(1, "Payment p95 latency"), + incident(2, "Checkout p95 latency", { severity: "warning", last_observed_value: 912 }), + incident(3, "Search p95 latency", { severity: "warning", last_observed_value: 860 }), + incident(4, "Orders p95 latency", { hold_reason: "no_data" }), + ], + }), + HubMessage.UpdatedDestinations({ orgId: ORG, rows: [] }), + HubMessage.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + ]) +} diff --git a/apps/web/src/lab/list-keys-lab.ts b/apps/web/src/lab/list-keys-lab.ts new file mode 100644 index 0000000000..5cb3946ab0 --- /dev/null +++ b/apps/web/src/lab/list-keys-lab.ts @@ -0,0 +1,249 @@ +import { OrgId } from "@maple/domain" +import { Array as Arr, Effect, Option, Schema } from "effect" +import { Command, Subscription, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as HubMessage } from "../page/alerts/hub/message" +import { Model as HubModel } from "../page/alerts/hub/model" +import { subscriptions as hubSubscriptions } from "../page/alerts/hub/subscription" +import { update as hubUpdate } from "../page/alerts/hub/update" +import { view as hubView } from "../page/alerts/hub/view" +import { Message as DashboardsMessage } from "../page/dashboards/list/message" +import { Model as DashboardsModel } from "../page/dashboards/list/model" +import { subscriptions as dashboardsSubscriptions } from "../page/dashboards/list/subscription" +import { update as dashboardsUpdate } from "../page/dashboards/list/update" +import { view as dashboardsView } from "../page/dashboards/list/view" +import { Message as InvestigationsMessage } from "../page/investigations/list/message" +import { Model as InvestigationsModel } from "../page/investigations/list/model" +import { subscriptions as investigationsSubscriptions } from "../page/investigations/list/subscription" +import { update as investigationsUpdate } from "../page/investigations/list/update" +import { view as investigationsView } from "../page/investigations/list/view" +import { Message as MetricsMessage } from "../page/metrics/browse/message" +import { Model as MetricsModel } from "../page/metrics/browse/model" +import { subscriptions as metricsSubscriptions } from "../page/metrics/browse/subscription" +import { update as metricsUpdate } from "../page/metrics/browse/update" +import { view as metricsView } from "../page/metrics/browse/view" +import { Message as ServicesMessage } from "../page/services/list/message" +import { Model as ServicesModel } from "../page/services/list/model" +import { subscriptions as servicesSubscriptions } from "../page/services/list/subscription" +import { update as servicesUpdate } from "../page/services/list/update" +import { view as servicesView } from "../page/services/list/view" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import type { PageContext } from "../page/contract" +import { ORG } from "../page/kit/alert-test-rows" +import type { Resources } from "../platform/resources" +import { readEffectiveTimeZone } from "../platform/time-zone" +import { + alertsFixture, + dashboardsFixture, + investigationsFixture, + metricsFixture, + servicesFixture, +} from "./list-keys-fixture" + +// `/lab/list-keys`: the list pages that take the row keys (J / K / arrows, Enter, Escape), each +// mounted over fixture rows with its own key subscription. `?page=` picks one: services, metrics, +// investigations, dashboards, alerts. Reads, writes and URL changes are dropped, so Enter and the +// filters go nowhere here. + +export const LabPage = Schema.Literals(["services", "metrics", "investigations", "dashboards", "alerts"]) +export type LabPage = typeof LabPage.Type + +export const Flags = Schema.Struct({ page: LabPage, timeZone: Schema.String }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.map(readEffectiveTimeZone, (timeZone) => ({ + page: Option.getOrElse( + Schema.decodeUnknownOption(LabPage)(new URLSearchParams(window.location.search).get("page")), + (): LabPage => "services", + ), + timeZone, +})) + +export const Model = Schema.Struct({ + page: LabPage, + timeZone: Schema.String, + services: ServicesModel, + metrics: MetricsModel, + investigations: InvestigationsModel, + dashboards: DashboardsModel, + alerts: HubModel, +}) +export type Model = typeof Model.Type + +/** The dashboards Message can carry a browser `File`, which has no codec: held as it is. */ +const DashboardsMessageValue = Schema.declare(Schema.is(DashboardsMessage)) + +export const Message = defineMessageUnion({ + GotServicesMessage: { message: ServicesMessage }, + GotMetricsMessage: { message: MetricsMessage }, + GotInvestigationsMessage: { message: InvestigationsMessage }, + GotDashboardsMessage: { message: DashboardsMessageValue }, + GotAlertsMessage: { message: HubMessage }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return + +const LAB_ORG = Schema.decodeSync(OrgId)(ORG) +const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone }) + +/** A page's reads, writes and clock are dropped: the fixture is all the data there is. */ +const DATA_COMMAND = + /^(Resolve|Fetch|Load(?!ChartRuntime)|Read|Create|Duplicate|Export|Import|Delete|Save|Toggle|End|Push|Dismiss)/ + +const keepLocal = ( + commands: ReadonlyArray> | undefined, + lift: (message: M) => Lifted, +) => + Command.mapMessages( + Arr.filter(commands ?? [], (command) => !DATA_COMMAND.test(command.name)), + lift, + ) + +const toServices = (message: ServicesMessage) => Message.GotServicesMessage({ message }) +const toMetrics = (message: MetricsMessage) => Message.GotMetricsMessage({ message }) +const toInvestigations = (message: InvestigationsMessage) => Message.GotInvestigationsMessage({ message }) +const toDashboards = (message: DashboardsMessage) => Message.GotDashboardsMessage({ message }) +const toAlerts = (message: HubMessage) => Message.GotAlertsMessage({ message }) + +export const init = (flags: Flags): Return => { + const context = contextOf(flags.timeZone) + return { + model: { + page: flags.page, + timeZone: flags.timeZone, + services: servicesFixture(context), + metrics: metricsFixture(context), + investigations: investigationsFixture(context), + dashboards: dashboardsFixture(context), + alerts: alertsFixture(context), + }, + // The services table paints once the chart runtime is in. + commands: Command.mapMessages([LoadChartRuntime()], toServices), + } +} + +export const update = (model: Model, message: Message): Return => { + const context = contextOf(model.timeZone) + return Message.match(message, { + GotServicesMessage: ({ message: inner }) => { + const next = servicesUpdate(model.services, inner, context) + return { + model: { ...model, services: next.model }, + commands: keepLocal(next.commands, toServices), + } + }, + GotMetricsMessage: ({ message: inner }) => { + const next = metricsUpdate(model.metrics, inner, context) + return { model: { ...model, metrics: next.model }, commands: keepLocal(next.commands, toMetrics) } + }, + GotInvestigationsMessage: ({ message: inner }) => { + const next = investigationsUpdate(model.investigations, inner, context) + return { + model: { ...model, investigations: next.model }, + commands: keepLocal(next.commands, toInvestigations), + } + }, + GotDashboardsMessage: ({ message: inner }) => { + const next = dashboardsUpdate(model.dashboards, inner, context) + return { + model: { ...model, dashboards: next.model }, + commands: keepLocal(next.commands, toDashboards), + } + }, + GotAlertsMessage: ({ message: inner }) => { + const next = hubUpdate(model.alerts, inner, context) + return { model: { ...model, alerts: next.model }, commands: keepLocal(next.commands, toAlerts) } + }, + }) +} + +const LABEL = { + services: "Services", + metrics: "Metrics", + investigations: "Investigations", + dashboards: "Dashboards", + alerts: "Alerts", +} satisfies Record + +export const breadcrumbs = (model: Model) => [{ label: LABEL[model.page], isPageHeading: true }] + +/** The page's own path, so the sidebar marks it. */ +export const currentPath = (model: Model): string => `/${model.page}` + +export const view = (model: Model, h: HtmlBuilder): Html => { + const viewInputs = { context: contextOf(model.timeZone) } + switch (model.page) { + case "services": + return h.submodel({ + slotId: "lab-list-keys-services", + model: model.services, + view: servicesView, + viewInputs, + toParentMessage: toServices, + }) + case "metrics": + return h.submodel({ + slotId: "lab-list-keys-metrics", + model: model.metrics, + view: metricsView, + viewInputs, + toParentMessage: toMetrics, + }) + case "investigations": + return h.submodel({ + slotId: "lab-list-keys-investigations", + model: model.investigations, + view: investigationsView, + viewInputs, + toParentMessage: toInvestigations, + }) + case "dashboards": + return h.submodel({ + slotId: "lab-list-keys-dashboards", + model: model.dashboards, + view: dashboardsView, + viewInputs, + toParentMessage: toDashboards, + }) + case "alerts": + return h.submodel({ + slotId: "lab-list-keys-alerts", + model: model.alerts, + view: hubView, + viewInputs, + toParentMessage: toAlerts, + }) + } +} + +/** A page's `{ model, context }` while it is the one on screen. */ +const shown = (model: Model, page: LabPage, pageModel: PageModel) => + model.page === page + ? Option.some({ model: pageModel, context: contextOf(model.timeZone) }) + : Option.none() + +/** Only each page's key stream: its live collections have nothing to sync here. */ +export const subscriptions = Subscription.aggregate()( + Subscription.lift({ servicesKeys: servicesSubscriptions.listKeys })({ + read: (model) => shown(model, "services", model.services), + toParentMessage: toServices, + }), + Subscription.lift({ metricsKeys: metricsSubscriptions.listKeys })({ + read: (model) => shown(model, "metrics", model.metrics), + toParentMessage: toMetrics, + }), + Subscription.lift({ investigationsKeys: investigationsSubscriptions.listKeys })({ + read: (model) => shown(model, "investigations", model.investigations), + toParentMessage: toInvestigations, + }), + Subscription.lift({ dashboardsKeys: dashboardsSubscriptions.listKeys })({ + read: (model) => shown(model, "dashboards", model.dashboards), + toParentMessage: toDashboards, + }), + Subscription.lift({ alertsKeys: hubSubscriptions.listKeys })({ + read: (model) => shown(model, "alerts", model.alerts), + toParentMessage: toAlerts, + }), +) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 2c67573b32..0e509f4086 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/list-keys", + title: "List keys", + description: + "The services, metrics table, investigations, dashboards and alerts Inbox lists over fixture rows, each taking the row keys: J / K / arrows move the focus, Enter opens, Escape clears (`?page=services|metrics|investigations|dashboards|alerts`).", + kind: "lab", + session: "none", + }, { path: "/lab/alerts-hub", title: "Alerts hub", diff --git a/apps/web/src/page/dashboards/list/list.scene.test.ts b/apps/web/src/page/dashboards/list/list.scene.test.ts index 0ad687e068..5efd1f0c46 100644 --- a/apps/web/src/page/dashboards/list/list.scene.test.ts +++ b/apps/web/src/page/dashboards/list/list.scene.test.ts @@ -317,6 +317,6 @@ describe("dashboards list keys", () => { const focused = pressed(loaded({}, boards), "j") expect(focused.focusedId).toBe("dash_payment") expect(routeChanged(focused, { search: { q: "health" } }).model.focusedId).toBeNull() - expect(routeChanged(focused, { search: { sort: "name" } }).model.focusedId).toBeNull() + expect(routeChanged(focused, { search: { sort: "name-asc" } }).model.focusedId).toBeNull() }) }) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 5d7c6d634e..a31ffc9dff 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -138,6 +138,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/resizable.ts` `panelId`, `handleId`, `resize`, `view` (stateful: Model, init, update): `react-resizable-panels` 4 (`@maple/ui/resizable`): one panel group, sizes in percent. - `packages/views/src/ui/reveal-icon.ts` `revealIcon`: `revealIcon`: a muted trailing icon (an external-link or "go" arrow) hidden until its `group` row is hovered or keyboard-focused. - `packages/views/src/ui/row-actions-menu.ts` `rowActionsMenu`: A row's overflow menu of actions. +- `packages/views/src/ui/row-focus.ts` `rowFocusAttributes`: The keyboard-focused row: the `data-focused` marker a row kit sets and the look it draws for it. - `packages/views/src/ui/rule.ts` `stripClass`, `strip`, `dividedList`, `fillRule`: Hairline rules: a `strip` (a band inside a panel or list, ruled off from its neighbours), a `dividedList` (a bordered box with a rule between its items) and a `fillRule`. - `packages/views/src/ui/scroll-area.ts` `applyScrollOverflow`, `trackScrollbar`, `scrollArea`: Base UI hides the viewport's native scrollbar with this class and an injected rule (styles.css). - `packages/views/src/ui/scroll-region.ts` `scrollRegion`, `fillRegion`: `ScrollRegion`: the rest of a flex column's height as a plain native-scrollbar overflow region (`min-h-0 flex-1 overflow-y-auto`); `scrollArea` is the one with the custom scroll... @@ -245,6 +246,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/k8s-chart.ts` `k8sChart`: The Kubernetes metric chart and the detail-chart wrappers around it: one metric at a time, 280 tall, series legend, in a chart card. - `apps/web/src/page/kit/keyed-step.ts` `keyed`, `keyedSlot`, `applySteps`, `runSteps`, `seriesSteps`, `slotStep`, `chain`, `chainAll`: One request slot's step and the way steps compose: the Model threaded through, the Commands concatenated, the last OutMessage kept. - `apps/web/src/page/kit/last-by.ts` `lastBy`: A derivation that views call on every message, recomputed only when one of its inputs changes. +- `apps/web/src/page/kit/list-keys.ts` `listKeys`, `listStepOf`, `steppedFocus`, `listKeyAction`, `focusedIndexOf`, `scrollFocusedRowCommand`: Keyboard row navigation for a list page: the document key stream (J, K, the arrows, Enter and Escape), the focus step and the scroll that keeps the focused row in view. - `apps/web/src/page/kit/list-toolbar.ts` `listToolbar`, `toolbarCount`, `toolbarStat`: A list page's row of view tabs, then search, filters and the count readout. - `apps/web/src/page/kit/markdown.ts` `escapeDirectives`, `parseMarkdownTexts`: The one markdown parse every page uses (chat, agent sessions). - `apps/web/src/page/kit/mcp-client-config.ts` `isMcpClient`, `mcpClientConfig`, `claudeCodeCommand`, `cursorInstallUrl`, `vscodeInstallUrl`: The `mcpServers` entry each client reads. From 071d69be53251c9c865850e31b1ab3fe71f21724 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:44:05 +0100 Subject: [PATCH 3/4] fix(web): the services list below md marks the focused row too Below md the table is hidden and a list of links is drawn instead; only the table marked the keyboard-focused row, so J and K moved a focus no one could see. selectableRow takes isFocused, the list passes it, and the scroll goes to the marked row that has a box, since both are marked. --- apps/web/src/page/kit/list-keys.ts | 18 ++++++++++++------ .../page/services/list/services.scene.test.ts | 7 ++++++- apps/web/src/page/services/list/view-table.ts | 8 ++++++-- packages/views/src/ui/selectable-row.ts | 6 +++++- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/web/src/page/kit/list-keys.ts b/apps/web/src/page/kit/list-keys.ts index 688637ba6d..203da3a6a5 100644 --- a/apps/web/src/page/kit/list-keys.ts +++ b/apps/web/src/page/kit/list-keys.ts @@ -1,6 +1,6 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Array as Arr, Effect, Option, type Stream } from "effect" -import { Command, Dom } from "foldkit" +import { Command, Dom, Render } from "foldkit" import type { CallableTaggedStruct } from "foldkit/schema" // Keyboard row navigation for a list page: the document key stream (J, K, the arrows, Enter and Escape), the focus step and the scroll that keeps the focused row in view. @@ -86,9 +86,15 @@ export const scrollFocusedRowCommand = Command.define(name, { messages: [completed], - // Resolved after the commit that marks the row; a row that left the list has nothing to scroll. - execute: Dom.scrollIntoView(FOCUSED_ROW, { block: "nearest" }).pipe( - Effect.ignore, - Effect.as(completed()), - ), + // After the commit that marks the row. A page that draws its rows twice (a table, and a list + // below `md`) marks both, so the one with a box is the one to scroll; none has nothing to do. + execute: Effect.gen(function* () { + yield* Render.afterCommit + const visible = Arr.findFirst( + [...document.querySelectorAll(FOCUSED_ROW)], + (row) => row.getClientRects().length > 0, + ) + if (Option.isSome(visible)) visible.value.scrollIntoView({ block: "nearest" }) + return completed() + }), }) diff --git a/apps/web/src/page/services/list/services.scene.test.ts b/apps/web/src/page/services/list/services.scene.test.ts index 38fe92b8d7..8cf1100841 100644 --- a/apps/web/src/page/services/list/services.scene.test.ts +++ b/apps/web/src/page/services/list/services.scene.test.ts @@ -304,7 +304,9 @@ describe("services list", () => { describe("services list keys", () => { const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) - const focusedRow = selector("[data-focused]") + const focusedRow = selector("tr[data-focused]") + // Below `md` the same rows draw as a list of links; it marks the same one. + const focusedListRow = selector("a[data-focused]") // Grouped by environment, production first: checkout, then the staging cart. const rows = [row("cart", "staging"), row("checkout")] @@ -318,9 +320,11 @@ describe("services list keys", () => { press("j"), scrolled(), expectView(focusedRow).toContainText("checkout"), + expectView(focusedListRow).toContainText("checkout"), press("ArrowDown"), scrolled(), expectView(focusedRow).toContainText("cart"), + expectView(focusedListRow).toContainText("cart"), press("j"), scrolled(), expectView(focusedRow).toContainText("cart"), @@ -329,6 +333,7 @@ describe("services list keys", () => { expectView(focusedRow).toContainText("checkout"), press("Escape"), expectView(focusedRow).toBeAbsent(), + expectView(focusedListRow).toBeAbsent(), ) }) diff --git a/apps/web/src/page/services/list/view-table.ts b/apps/web/src/page/services/list/view-table.ts index 6f2e8cd298..2e31e2eed8 100644 --- a/apps/web/src/page/services/list/view-table.ts +++ b/apps/web/src/page/services/list/view-table.ts @@ -505,8 +505,10 @@ const mobileList = ( ctx: TableContext, services: ReadonlyArray, mode: "namespace" | "environment", -): Html => - h.div( +): Html => { + const focused = + ctx.model.focusedIndex === null ? undefined : orderedServices(services, mode)[ctx.model.focusedIndex] + return h.div( [h.Class("overflow-hidden rounded-md border md:hidden")], services.length === 0 ? [emptyState(h, ctx)] @@ -564,6 +566,7 @@ const mobileList = ( h, { isSelected: false, + isFocused: service === focused, href: serviceHref(ctx.model, service), className: "flex min-h-11 items-center justify-between gap-3 border-b px-3 py-2.5 last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", @@ -700,6 +703,7 @@ const mobileList = ( ), ), ) +} const SKELETON_COLUMNS = [ { header: "Service", skeleton: "w-32" }, diff --git a/packages/views/src/ui/selectable-row.ts b/packages/views/src/ui/selectable-row.ts index accc3adbd8..b9715b2510 100644 --- a/packages/views/src/ui/selectable-row.ts +++ b/packages/views/src/ui/selectable-row.ts @@ -1,5 +1,6 @@ import { cn } from "@maple/ui/lib/utils" import { rowSelectedClass } from "./list-row.styles" +import { ROW_FOCUSED_CLASS, rowFocusAttributes } from "./row-focus" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" // `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection @@ -7,6 +8,8 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" export type SelectableRowOptions = Readonly<{ isSelected: boolean + /** The list page's keyboard-focused row. */ + isFocused?: boolean /** The row is a link to this URL. */ href?: string /** A click anywhere on the row, for a row whose stretched `pressable` sits under columns with their own controls. */ @@ -21,8 +24,9 @@ export const selectableRow = ( children: ReadonlyArray, ): Html => { const attributes = [ - h.Class(cn(rowSelectedClass(options.isSelected), options.className)), + h.Class(cn(rowSelectedClass(options.isSelected), ROW_FOCUSED_CLASS, options.className)), h.DataAttribute("slot", "selectable-row"), + ...rowFocusAttributes(h, options.isFocused), ...(options.onClick === undefined ? [] : [h.OnClick(options.onClick)]), ...(options.attributes ?? []), ] From 7c2189e19e9793ddbecde44eba5da2e594756b68 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 19:56:23 +0100 Subject: [PATCH 4/4] fix(web): one focused row at a time, and a select's Escape stays the select's A row holding DOM focus (a Tab or a click) keeps Enter for itself, so a J or K left two focused rows and Enter opened the wrong one. A step now releases a list row's DOM focus; rows of a keyed list carry data-list-row so the keys can tell them from other controls. A page stops the key stream while its own select is open and starts it as the select closes, and the Escape that closed it was still travelling up to the document: it cleared the row focus too. The stream ignores a key that was already in flight when it started. --- apps/web/src/page/kit/list-keys.ts | 19 +++++++++++++++++-- packages/views/src/ui/row-focus.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/kit/list-keys.ts b/apps/web/src/page/kit/list-keys.ts index 203da3a6a5..441d6ed903 100644 --- a/apps/web/src/page/kit/list-keys.ts +++ b/apps/web/src/page/kit/list-keys.ts @@ -1,4 +1,5 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" +import { LIST_ROW_SELECTOR } from "@maple/views/ui/row-focus" import { Array as Arr, Effect, Option, type Stream } from "effect" import { Command, Dom, Render } from "foldkit" import type { CallableTaggedStruct } from "foldkit/schema" @@ -22,10 +23,22 @@ export type ListKeyOptions = Readonly<{ isBlocked?: (key: string) => boolean }> +/** + * A row that holds DOM focus (from a Tab or a click) keeps Enter for itself, so a step would leave + * two focused rows and Enter on the wrong one. Stepping hands the list the only focus. + */ +const releaseRowFocus = (): void => { + const active = document.activeElement + if (active instanceof HTMLElement && active.closest(LIST_ROW_SELECTOR) !== null) active.blur() +} + /** The list's keys from anywhere on the page, while no field has focus and no modifier is held. */ export const listKeys = (options: ListKeyOptions): Stream.Stream => { const keys = new Set([...NAVIGATION_KEYS, ...(options.extraKeys ?? [])]) const isBlocked = options.isBlocked ?? isDialogOpen + // A page stops this stream while its own select is open and starts it again as the select closes: + // the Escape that closed it is still on its way up to the document, and is not the list's. + const startedAt = performance.now() return Dom.streamFromEventFilterMapPreventDefault({ target: () => document, type: "keydown", @@ -33,9 +46,11 @@ export const listKeys = (options: ListKeyOptions): Stream.Stream => { const key = pressedKey(event) if (!keys.has(key) || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return Option.none() - if (isEditableTarget(event.target)) return Option.none() + if (event.timeStamp < startedAt || isEditableTarget(event.target)) return Option.none() if (key === "Enter" && keepsEnter(event.target)) return Option.none() - return isBlocked(key) ? Option.none() : Option.some(options.toMessage(key)) + if (isBlocked(key)) return Option.none() + if (listStepOf(key) !== null) releaseRowFocus() + return Option.some(options.toMessage(key)) }, }) } diff --git a/packages/views/src/ui/row-focus.ts b/packages/views/src/ui/row-focus.ts index 1d8ef6b7d3..c4aa806614 100644 --- a/packages/views/src/ui/row-focus.ts +++ b/packages/views/src/ui/row-focus.ts @@ -7,8 +7,14 @@ import type { Attribute, HtmlBuilder } from "foldkit/html" export const ROW_FOCUSED_CLASS = "data-[focused]:bg-surface-strong data-[focused]:ring-1 data-[focused]:ring-ring data-[focused]:ring-inset" -/** The marker on the focused row, nothing on the others. */ +/** `data-list-row` on every row of a keyed list, so the keys can tell one that holds DOM focus. */ +export const LIST_ROW_SELECTOR = "[data-list-row]" + +/** A keyed list's row: `data-list-row`, and `data-focused` on the focused one. Nothing for a row no list keys. */ export const rowFocusAttributes = ( h: HtmlBuilder, isFocused: boolean | undefined, -): ReadonlyArray> => (isFocused === true ? [h.DataAttribute("focused", "true")] : []) +): ReadonlyArray> => + isFocused === undefined + ? [] + : [h.DataAttribute("list-row", ""), ...(isFocused ? [h.DataAttribute("focused", "true")] : [])]