Skip to content

Commit 3af2202

Browse files
committed
Add keyboard controls & better focus UI for Send tabs
1 parent ad88f5e commit 3af2202

File tree

5 files changed

+178
-53
lines changed

5 files changed

+178
-53
lines changed

src/components/common/icon-button.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ export const IconButton = styled((p: {
1111
icon: IconProp,
1212
disabled?: boolean,
1313
fixedWidth?: boolean,
14-
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
14+
tabIndex?: number,
15+
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void,
16+
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void
1517
}) =>
1618
<UnstyledButton
1719
className={p.className}
1820
title={p.title}
19-
tabIndex={p.disabled ? -1 : 0}
21+
tabIndex={p.tabIndex ?? (p.disabled ? -1 : 0)}
2022
disabled={p.disabled}
2123
onClick={p.onClick}
24+
onKeyDown={p.onKeyDown}
2225
>
2326
<Icon
2427
icon={p.icon}

src/components/send/send-page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { inject, observer } from 'mobx-react';
33
import * as portals from 'react-reverse-portal';
44

55
import { styled } from '../../styles';
6+
import { useHotkeys } from '../../util/ui';
67

78
import { SendStore } from '../../model/send/send-store';
89

@@ -25,6 +26,20 @@ const TabContentContainer = styled.div`
2526
box-shadow: 0 0 10px 0 rgba(0,0,0,${p => p.theme.boxShadowAlpha});
2627
`;
2728

29+
const SendPageKeyboardShortcuts = (props: {
30+
onMoveSelection: (distance: number) => void
31+
}) => {
32+
useHotkeys('Ctrl+Tab, Cmd+Tab', (event) => {
33+
props.onMoveSelection(1);
34+
}, [props.onMoveSelection]);
35+
36+
useHotkeys('Ctrl+Shift+Tab, Cmd+Shift+Tab', (event) => {
37+
props.onMoveSelection(-1);
38+
}, [props.onMoveSelection]);
39+
40+
return null;
41+
};
42+
2843
@inject('sendStore')
2944
@observer
3045
export class SendPage extends React.Component<{
@@ -42,6 +57,7 @@ export class SendPage extends React.Component<{
4257
const {
4358
sendRequests,
4459
selectRequest,
60+
moveSelection,
4561
deleteRequest,
4662
sendRequest,
4763
selectedRequest,
@@ -53,10 +69,15 @@ export class SendPage extends React.Component<{
5369
sendRequests={sendRequests}
5470
selectedTab={selectedRequest}
5571
onSelectTab={selectRequest}
72+
onMoveSelection={moveSelection}
5673
onCloseTab={deleteRequest}
5774
onAddTab={addRequestInput}
5875
/>
5976

77+
<SendPageKeyboardShortcuts
78+
onMoveSelection={moveSelection}
79+
/>
80+
6081
<TabContentContainer
6182
id='send-tabpanel'
6283
role='tabpanel'

src/components/send/send-request-line.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,15 @@ const MethodSelectContainer = styled.div`
6565
flex-grow: 0;
6666
flex-basis: 105px;
6767
68-
&:focus-within > svg {
69-
color: ${p => p.theme.popColor};
70-
opacity: 1;
68+
&:focus-within {
69+
> svg {
70+
color: ${p => p.theme.popColor};
71+
opacity: 1;
72+
}
73+
74+
> select {
75+
font-weight: bold;
76+
}
7177
}
7278
`;
7379

@@ -96,6 +102,11 @@ const SendButton = styled(Button)`
96102
> svg {
97103
padding: 0;
98104
}
105+
106+
&:focus {
107+
outline: none;
108+
background-color: ${p => p.theme.popColor};
109+
}
99110
`;
100111

101112
export const SendRequestLine = (props: {

src/components/send/send-tabs.tsx

Lines changed: 129 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import * as React from 'react';
2+
import { observer } from 'mobx-react-lite';
23

34
import { css, styled } from '../../styles';
4-
import { UnstyledButton } from '../common/inputs';
5+
56
import { SendRequest } from '../../model/send/send-request-model';
67
import { getMethodColor } from '../../model/events/categorization';
7-
import { observer } from 'mobx-react';
8+
9+
import { UnstyledButton } from '../common/inputs';
810
import { IconButton } from '../common/icon-button';
9-
import { noPropagation } from '../component-utils';
1011

1112
export const TAB_BAR_HEIGHT = '38px';
1213

@@ -62,23 +63,6 @@ const TabContainer = styled.div<{ selected: boolean }>`
6263
}
6364
`;
6465

65-
const TabButton = styled(UnstyledButton).attrs((p: { selected: boolean }) => ({
66-
role: 'tab',
67-
'aria-selected': p.selected.toString(),
68-
'tabindex': p.selected ? '0' : '-1'
69-
}))`
70-
flex-basis: 100%;
71-
flex-grow: 1;
72-
flex-shrink: 1;
73-
74-
text-align: left;
75-
text-overflow: ellipsis;
76-
overflow: hidden;
77-
white-space: nowrap;
78-
79-
padding: 0 10px;
80-
`;
81-
8266
const TabMethodMarker = styled.span<{ method: string }>`
8367
color: ${p => getMethodColor(p.method)};
8468
font-size: ${p => p.theme.textInputFontSize};
@@ -101,51 +85,148 @@ const AddTabButton = styled(IconButton)`
10185
align-self: center;
10286
`;
10387

88+
const TabButton = styled(UnstyledButton).attrs((p: { selected: boolean }) => ({
89+
role: 'tab',
90+
'aria-selected': p.selected.toString(),
91+
'tabindex': p.selected ? '0' : '-1'
92+
}))`
93+
flex-basis: 100%;
94+
flex-grow: 1;
95+
flex-shrink: 1;
96+
97+
text-align: left;
98+
text-overflow: ellipsis;
99+
overflow: hidden;
100+
white-space: nowrap;
101+
102+
padding: 0 10px;
103+
104+
:focus-visible {
105+
outline: none;
106+
font-weight: bold;
107+
108+
& + ${CloseTabButton} {
109+
color: ${p => p.theme.popColor};
110+
}
111+
}
112+
`;
113+
114+
const SendTab = observer((props: {
115+
sendRequest: SendRequest,
116+
isSelectedTab: boolean,
117+
onSelectTab: (request: SendRequest) => void,
118+
onCloseTab: (request: SendRequest) => void
119+
}) => {
120+
const { id, request } = props.sendRequest;
121+
122+
const onTabClick = React.useCallback(() => {
123+
props.onSelectTab(props.sendRequest)
124+
}, [props.onSelectTab, props.sendRequest]);
125+
126+
const onTaxAuxClick = React.useCallback((event) => {
127+
if (event.button === 1) { // Middle mouse click
128+
props.onCloseTab(props.sendRequest);
129+
}
130+
}, [props.onCloseTab, props.sendRequest]);
131+
132+
const onCloseClick = React.useCallback((event: React.SyntheticEvent) => {
133+
props.onCloseTab(props.sendRequest);
134+
event.stopPropagation();
135+
}, [props.onCloseTab, props.sendRequest]);
136+
137+
return <TabContainer
138+
key={id}
139+
selected={props.isSelectedTab}
140+
onClick={onTabClick}
141+
onAuxClick={onTaxAuxClick}
142+
>
143+
<TabButton
144+
selected={props.isSelectedTab}
145+
tabIndex={props.isSelectedTab ? 0 : -1}
146+
>
147+
<TabMethodMarker method={request.method}>
148+
{ request.method }
149+
</TabMethodMarker>
150+
151+
<TabName>{
152+
request.url.replace(/^https?:\/\//, '') || ''
153+
}</TabName>
154+
</TabButton>
155+
156+
{
157+
props.isSelectedTab && <CloseTabButton
158+
title='Close this tab'
159+
icon={['fas', 'times']}
160+
onClick={onCloseClick}
161+
tabIndex={-1} // No focus - keyboard closes via 'Delete' instead
162+
/>
163+
}
164+
</TabContainer>;
165+
});
166+
104167
export const SendTabs = observer((props: {
105168
sendRequests: Array<SendRequest>;
106169
selectedTab: SendRequest;
107170
onSelectTab: (sendRequest: SendRequest) => void;
171+
onMoveSelection: (distance: number) => void;
108172
onCloseTab: (sendRequest: SendRequest) => void;
109173
onAddTab: () => void;
110174
}) => {
175+
const containerRef = React.useRef<HTMLDivElement>(null);
176+
177+
const focusSelectedEvent = React.useCallback(() => {
178+
const container = containerRef.current;
179+
if (!container) return;
180+
181+
const selectedTab = container.querySelector('[role=tab][aria-selected=true]') as HTMLButtonElement;
182+
if (!selectedTab) return;
183+
selectedTab.focus();
184+
}, [containerRef]);
185+
186+
const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
187+
// Note that selected tab === focused tab so no worries differentiating the two
188+
if (event.key === 'Delete') {
189+
props.onCloseTab(props.selectedTab);
190+
} else if (event.key === 'ArrowRight') {
191+
props.onMoveSelection(1);
192+
} else if (event.key === 'ArrowLeft') {
193+
props.onMoveSelection(-1);
194+
} else if (event.key === 'Home') {
195+
props.onMoveSelection(-Infinity);
196+
} else if (event.key === 'End') {
197+
props.onMoveSelection(Infinity);
198+
} else {
199+
return;
200+
}
201+
202+
// In all the above cases, we want to update the focus to match:
203+
requestAnimationFrame(() => focusSelectedEvent());
204+
}, [props.onCloseTab, props.selectedTab, props.onMoveSelection, focusSelectedEvent]);
111205

206+
const onAddButtonKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
207+
event.stopPropagation();
208+
}, []);
112209

113-
return <TabsContainer>
210+
return <TabsContainer
211+
ref={containerRef}
212+
onKeyDown={onKeyDown}
213+
>
114214
{
115215
props.sendRequests.map((sendRequest) => {
116-
const { id, request } = sendRequest;
117-
118-
const isSelected = props.selectedTab === sendRequest;
119-
120-
return <TabContainer
121-
key={id}
122-
selected={isSelected}
123-
onClick={() => props.onSelectTab(sendRequest)}
124-
>
125-
<TabButton selected={isSelected}>
126-
<TabMethodMarker method={request.method}>
127-
{ request.method }
128-
</TabMethodMarker>
129-
130-
<TabName>{
131-
request.url.replace(/^https?:\/\//, '') || ''
132-
}</TabName>
133-
</TabButton>
134-
135-
{
136-
isSelected && <CloseTabButton
137-
title='Close this tab'
138-
icon={['fas', 'times']}
139-
onClick={noPropagation(() => props.onCloseTab(sendRequest))}
140-
/>
141-
}
142-
</TabContainer>;
216+
const isSelectedTab = props.selectedTab === sendRequest;
217+
return <SendTab
218+
sendRequest={sendRequest}
219+
isSelectedTab={isSelectedTab}
220+
onSelectTab={props.onSelectTab}
221+
onCloseTab={props.onCloseTab}
222+
/>
143223
})
144224
}
145225

146226
<AddTabButton
147227
title='Add another tab to send a new request'
148228
icon={['fas', 'plus']}
229+
onKeyDown={onAddButtonKeyDown}
149230
onClick={() => props.onAddTab()}
150231
/>
151232
</TabsContainer>

src/model/send/send-store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ export class SendStore {
9494
this.selectedRequest = sendRequest;
9595
}
9696

97+
@action.bound
98+
moveSelection(distance: number) {
99+
const currentIndex = this.sendRequests.indexOf(this.selectedRequest);
100+
if (currentIndex === -1) throw new Error("Selected request is somehow not in Send requests list");
101+
102+
const newIndex = _.clamp(currentIndex + distance, 0, this.sendRequests.length - 1);
103+
this.selectRequest(this.sendRequests[newIndex]);
104+
}
105+
97106
@action.bound
98107
deleteRequest(sendRequest: SendRequest) {
99108
const index = this.sendRequests.indexOf(sendRequest);

0 commit comments

Comments
 (0)