Skip to content

Commit cfc164c

Browse files
committed
Improve view event list focus management
Instead of mixing real DOM focus with JS state, we now do full JS state, mirroring focus state back with ARIA tracking etc. This matches the recommended ARIA behaviour for virtual lists, and means things like "select, scroll 1000 rows away, press up" now work as expected. Keeps cross-tab scroll & selected id perservation in all cases, I think.
1 parent 63cfe5e commit cfc164c

File tree

3 files changed

+81
-93
lines changed

3 files changed

+81
-93
lines changed

src/components/view/view-event-list.tsx

Lines changed: 68 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as _ from 'lodash';
22
import * as React from 'react';
33
import { inject, observer, Observer } from 'mobx-react';
4-
import { action, computed } from 'mobx';
4+
import { action } from 'mobx';
55

66
import AutoSizer from 'react-virtualized-auto-sizer';
77
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
@@ -63,7 +63,7 @@ interface ViewEventListProps {
6363
onSelected: (event: CollectedEvent | undefined) => void;
6464
}
6565

66-
const ListContainer = styled.div<{ role: 'table' }>`
66+
const ListContainer = styled.div<{ role: 'grid' }>`
6767
display: block;
6868
flex-grow: 1;
6969
position: relative;
@@ -83,12 +83,24 @@ const ListContainer = styled.div<{ role: 'table' }>`
8383
pointer-events: none;
8484
}
8585
86+
/* Disable default outline */
8687
& > div > div[tabindex="0"]:focus {
88+
outline: none;
89+
}
90+
91+
/* When focus-visible (keyboard interaction) and no focus is shown on a
92+
row within (activedescendant) - show focus on the list instead */
93+
& > div > div[tabindex="0"]:focus-visible:not([aria-activedescendant]) {
94+
outline: thin dotted ${p => p.theme.popColor};
95+
}
96+
97+
/* When the list is focused, highlight the active row */
98+
& > div > div[tabindex="0"]:focus [aria-selected="true"] {
8799
outline: thin dotted ${p => p.theme.popColor};
88100
}
89101
`;
90102

91-
const Column = styled.div<{ role: 'cell' | 'columnheader' }>`
103+
const Column = styled.div<{ role: 'gridcell' | 'columnheader' }>`
92104
display: block;
93105
overflow: hidden;
94106
text-overflow: ellipsis;
@@ -256,10 +268,6 @@ const EventListRow = styled.div<{ role: 'row' }>`
256268
fill: ${p => p.theme.highlightColor};
257269
}
258270
}
259-
260-
&:focus {
261-
outline: thin dotted ${p => p.theme.popColor};
262-
}
263271
`;
264272

265273
const TrafficEventListRow = styled(EventListRow)`
@@ -439,16 +447,17 @@ const ExchangeRow = inject('uiStore')(observer(({
439447
}`
440448
}
441449
aria-rowindex={index + 1}
450+
id={`event-row-${exchange.id}`}
442451
data-event-id={exchange.id}
443-
tabIndex={isSelected ? 0 : -1}
452+
aria-selected={isSelected}
444453
onContextMenu={contextMenuBuilder.getContextMenuCallback(exchange)}
445454
className={isSelected ? 'selected' : ''}
446455
style={style}
447456
>
448457
<RowPin aria-label={pinned ? 'Pinned' : undefined} pinned={pinned}/>
449-
<RowMarker role='cell' category={category} title={describeEventCategory(category)} />
450-
<Method role='cell' pinned={pinned}>{ request.method }</Method>
451-
<Status role='cell'>
458+
<RowMarker role='gridcell' category={category} title={describeEventCategory(category)} />
459+
<Method role='gridcell' pinned={pinned}>{ request.method }</Method>
460+
<Status role='gridcell'>
452461
{
453462
response === 'aborted'
454463
? <StatusCode status={'aborted'} />
@@ -471,7 +480,7 @@ const ExchangeRow = inject('uiStore')(observer(({
471480
/>
472481
}
473482
</Status>
474-
<Source role='cell'>
483+
<Source role='gridcell'>
475484
<Icon
476485
title={request.source.summary}
477486
{...request.source.icon}
@@ -492,10 +501,10 @@ const ExchangeRow = inject('uiStore')(observer(({
492501
/>
493502
}
494503
</Source>
495-
<Host role='cell' title={ request.parsedUrl.host }>
504+
<Host role='gridcell' title={ request.parsedUrl.host }>
496505
{ request.parsedUrl.host }
497506
</Host>
498-
<PathAndQuery role='cell' title={ request.parsedUrl.pathname + request.parsedUrl.search }>
507+
<PathAndQuery role='gridcell' title={ request.parsedUrl.pathname + request.parsedUrl.search }>
499508
{ request.parsedUrl.pathname + request.parsedUrl.search }
500509
</PathAndQuery>
501510
</TrafficEventListRow>;
@@ -537,24 +546,25 @@ const RTCConnectionRow = observer(({
537546
}`
538547
}
539548
aria-rowindex={index + 1}
549+
id={`event-row-${event.id}`}
540550
data-event-id={event.id}
541-
tabIndex={isSelected ? 0 : -1}
551+
aria-selected={isSelected}
542552

543553
className={isSelected ? 'selected' : ''}
544554
style={style}
545555
>
546556
<RowPin pinned={pinned}/>
547-
<RowMarker role='cell' category={category} title={describeEventCategory(category)} />
548-
<EventTypeColumn role='cell'>
557+
<RowMarker role='gridcell' category={category} title={describeEventCategory(category)} />
558+
<EventTypeColumn role='gridcell'>
549559
{ !event.closeState && <ConnectedSpinnerIcon /> } WebRTC
550560
</EventTypeColumn>
551-
<Source role='cell' title={event.source.summary}>
561+
<Source role='gridcell' title={event.source.summary}>
552562
<Icon
553563
{...event.source.icon}
554564
fixedWidth={true}
555565
/>
556566
</Source>
557-
<RTCConnectionDetails role='cell'>
567+
<RTCConnectionDetails role='gridcell'>
558568
{
559569
event.clientURL
560570
} <ArrowIcon direction='right' /> {
@@ -605,32 +615,33 @@ const RTCStreamRow = observer(({
605615
}`
606616
}
607617
aria-rowindex={index + 1}
618+
id={`event-row-${event.id}`}
608619
data-event-id={event.id}
609-
tabIndex={isSelected ? 0 : -1}
620+
aria-selected={isSelected}
610621

611622
className={isSelected ? 'selected' : ''}
612623
style={style}
613624
>
614625
<RowPin pinned={pinned}/>
615-
<RowMarker role='cell' category={category} title={describeEventCategory(category)} />
616-
<EventTypeColumn role='cell'>
626+
<RowMarker role='gridcell' category={category} title={describeEventCategory(category)} />
627+
<EventTypeColumn role='gridcell'>
617628
{ !event.closeState && <ConnectedSpinnerIcon /> } WebRTC {
618629
event.isRTCDataChannel()
619630
? 'Data'
620631
: // RTCMediaTrack:
621632
'Media'
622633
}
623634
</EventTypeColumn>
624-
<Source role='cell' title={event.rtcConnection.source.summary}>
635+
<Source role='gridcell' title={event.rtcConnection.source.summary}>
625636
<Icon
626637
{...event.rtcConnection.source.icon}
627638
fixedWidth={true}
628639
/>
629640
</Source>
630-
<RTCEventLabel role='cell'>
641+
<RTCEventLabel role='gridcell'>
631642
<ArrowIcon direction='right' /> { event.rtcConnection.remoteURL }
632643
</RTCEventLabel>
633-
<RTCEventDetails role='cell'>
644+
<RTCEventDetails role='gridcell'>
634645
{
635646
event.isRTCDataChannel()
636647
? <>
@@ -694,25 +705,26 @@ const BuiltInApiRow = observer((p: {
694705
}`
695706
}
696707
aria-rowindex={p.index + 1}
708+
id={`event-row-${p.exchange.id}`}
697709
data-event-id={p.exchange.id}
698-
tabIndex={p.isSelected ? 0 : -1}
710+
aria-selected={p.isSelected}
699711

700712
onContextMenu={p.contextMenuBuilder.getContextMenuCallback(p.exchange)}
701713
className={p.isSelected ? 'selected' : ''}
702714
style={p.style}
703715
>
704716
<RowPin pinned={pinned}/>
705-
<RowMarker role='cell' category={category} title={describeEventCategory(category)} />
706-
<EventTypeColumn role='cell'>
717+
<RowMarker role='gridcell' category={category} title={describeEventCategory(category)} />
718+
<EventTypeColumn role='gridcell'>
707719
{ api.service.shortName }: { apiOperationName }
708720
</EventTypeColumn>
709-
<Source role='cell' title={request.source.summary}>
721+
<Source role='gridcell' title={request.source.summary}>
710722
<Icon
711723
{...request.source.icon}
712724
fixedWidth={true}
713725
/>
714726
</Source>
715-
<BuiltInApiRequestDetails role='cell'>
727+
<BuiltInApiRequestDetails role='gridcell'>
716728
{ apiRequestDescription }
717729
</BuiltInApiRequestDetails>
718730
</TrafficEventListRow>
@@ -734,8 +746,9 @@ const RawTunnelRow = observer((p: {
734746
role="row"
735747
aria-label={`Non-HTTP connection to ${connectionTarget}`}
736748
aria-rowindex={p.index + 1}
749+
id={`event-row-${event.id}`}
737750
data-event-id={event.id}
738-
tabIndex={p.isSelected ? 0 : -1}
751+
aria-selected={p.isSelected}
739752

740753
className={p.isSelected ? 'selected' : ''}
741754
style={p.style}
@@ -771,8 +784,9 @@ const TlsRow = observer((p: {
771784
role="row"
772785
aria-label={`${description} connection to ${connectionTarget}`}
773786
aria-rowindex={p.index + 1}
787+
id={`event-row-${tlsEvent.id}`}
774788
data-event-id={tlsEvent.id}
775-
tabIndex={p.isSelected ? 0 : -1}
789+
aria-selected={p.isSelected}
776790

777791
className={p.isSelected ? 'selected' : ''}
778792
style={p.style}
@@ -790,14 +804,13 @@ const TlsRow = observer((p: {
790804
@observer
791805
export class ViewEventList extends React.Component<ViewEventListProps> {
792806

793-
@computed
794807
get selectedEventId() {
795808
return this.props.selectedEvent
796809
? this.props.selectedEvent.id
797810
: undefined;
798811
}
799812

800-
@computed get listItemData(): EventRowProps['data'] {
813+
get listItemData(): EventRowProps['data'] {
801814
return {
802815
selectedEvent: this.props.selectedEvent,
803816
events: this.props.filteredEvents,
@@ -819,25 +832,36 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
819832
}
820833
};
821834

835+
private get activeRowDomId(): string | undefined {
836+
const id = this.selectedEventId;
837+
if (!id) return undefined;
838+
839+
const listBody = this.listBodyRef.current;
840+
if (!listBody) return undefined;
841+
if (!listBody.querySelector(`[data-event-id='${id}']`)) return undefined;
842+
843+
return `event-row-${id}`;
844+
}
845+
822846
private KeyBoundListWindow = observer(
823847
React.forwardRef<HTMLDivElement>(
824848
(props: any, ref) => <div
825849
{...props}
826850
style={Object.assign({}, props.style, { 'overflowY': 'scroll' })}
827851
ref={ref}
828852

829-
onFocus={this.focusSelectedEvent}
830853
onKeyDown={this.onKeyDown}
831854
onMouseDown={this.onListMouseDown}
832-
tabIndex={this.isSelectedEventVisible() ? -1 : 0}
855+
aria-activedescendant={this.activeRowDomId}
856+
tabIndex={0}
833857
/>
834858
)
835859
);
836860

837861
render() {
838862
const { events, filteredEvents, isPaused } = this.props;
839863

840-
return <ListContainer role="table">
864+
return <ListContainer role="grid">
841865
<TableHeaderRow role="row">
842866
<MarkerHeader role="columnheader" aria-label="Category" />
843867
<Method role="columnheader">Method</Method>
@@ -888,41 +912,16 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
888912
</ListContainer>;
889913
}
890914

891-
private isSelectedEventVisible = () => {
892-
if (!this.selectedEventId) return false;
893-
894-
const listBody = this.listBodyRef.current;
895-
if (!listBody) return false;
896-
897-
return !!listBody.querySelector(`[data-event-id='${this.selectedEventId}']`);
898-
}
899-
900-
private focusEvent(event?: CollectedEvent) {
901-
const listBody = this.listBodyRef.current;
902-
if (!listBody) return;
903-
904-
if (event) {
905-
const rowElement = listBody.querySelector(
906-
`[data-event-id='${event.id}']`
907-
) as HTMLDivElement;
908-
rowElement?.focus();
909-
} else {
910-
const listWindow = listBody.parentElement!;
911-
listWindow.focus();
912-
}
913-
}
914-
915-
private focusSelectedEvent = () => {
916-
this.focusEvent(this.props.selectedEvent);
915+
focusListWindow() {
916+
const listWindow = this.listBodyRef.current?.parentElement;
917+
listWindow?.focus();
917918
}
918919

919920
focusList() {
921+
this.focusListWindow();
920922
const { selectedEvent } = this.props;
921923
if (selectedEvent) {
922924
this.scrollToEvent(selectedEvent);
923-
} else {
924-
const listWindow = this.listBodyRef.current?.parentElement;
925-
listWindow?.focus();
926925
}
927926
}
928927

@@ -954,21 +953,11 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
954953

955954
private hasRestoredScrollState = false;
956955

957-
componentDidUpdate(prevProps: ViewEventListProps) {
958-
if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) {
959-
// If we previously had something here focused, and we've updated, update
960-
// the focus too, to make sure it's in the right place.
961-
this.focusSelectedEvent();
962-
}
963-
956+
componentDidUpdate() {
964957
// If we previously were scrolled to the bottom of the list, but now we're not,
965958
// scroll there again ourselves now.
966959
if (this.wasListAtBottom && !this.isListAtBottom()) {
967960
this.listRef.current?.scrollToItem(this.props.events.length - 1);
968-
} else if (prevProps.selectedEvent !== this.props.selectedEvent && this.props.selectedEvent) {
969-
// If the selected event changed and we have a selected event, scroll to it
970-
// This handles restoring the selected event when returning to the tab
971-
this.scrollToEvent(this.props.selectedEvent);
972961
}
973962
}
974963

@@ -992,8 +981,6 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
992981
if (targetIndex === -1) return;
993982

994983
this.listRef.current?.scrollToItem(targetIndex);
995-
996-
requestAnimationFrame(() => this.focusEvent(event));
997984
}
998985

999986
public scrollToCenterEvent(event: CollectedEvent) {
@@ -1016,8 +1003,8 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
10161003
const targetOffset = rowOffset - halfHeight + rowHeight / 2;
10171004
list.scrollTo(_.clamp(targetOffset, 0, maxOffset));
10181005

1019-
// Focus the row, after the UI has updated, to make it extra obvious:
1020-
requestAnimationFrame(() => this.focusEvent(event));
1006+
// Focus the list so keyboard navigation works from here
1007+
listWindow.focus();
10211008
}
10221009

10231010
public scrollToEnd() {

0 commit comments

Comments
 (0)