Skip to content
This repository has been archived by the owner on Feb 8, 2024. It is now read-only.

Add keyboard support to Connections popover #651

Merged
merged 3 commits into from
Mar 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type KeyboardShortcutType =
| 'tab-new'
| 'tab-previous'
| 'tab-next'
| 'focus-global-search';
| 'focus-global-search'
| 'toggle-connections';

export type KeyboardShortcutsConfig = Record<KeyboardShortcutType, string>;

Expand All @@ -42,6 +43,7 @@ export const keyboardShortcutsConfigProvider: ConfigServiceProvider<KeyboardShor
'tab-previous': 'Control-Shift-Tab',
'tab-next': 'Control-Tab',
'focus-global-search': 'F1',
'toggle-connections': 'Command-O'
};

const linuxShortcuts: KeyboardShortcutsConfig = {
Expand All @@ -59,6 +61,7 @@ export const keyboardShortcutsConfigProvider: ConfigServiceProvider<KeyboardShor
'tab-previous': 'Ctrl-PageUp',
'tab-next': 'Ctrl-PageDown',
'focus-global-search': 'F1',
'toggle-connections': 'Ctrl-O'
};

switch (platform) {
Expand Down
16 changes: 13 additions & 3 deletions packages/teleterm/src/ui/TopBar/Connections/Connections.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import React, { useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Popover from 'design/Popover';
import styled from 'styled-components';
import { Box } from 'design';
import { useConnections } from './useConnections';
import { ConnectionsIcon } from './ConnectionsIcon/ConnectionsIcon';
import { ConnectionsFilterableList } from './ConnectionsFilterableList/ConnectionsFilterableList';
import { useKeyboardShortcuts } from 'teleterm/ui/services/keyboardShortcuts';

export function Connections() {
const iconRef = useRef();
const [isPopoverOpened, setIsPopoverOpened] = useState(false);
const connections = useConnections();

function togglePopover(): void {
const togglePopover = useCallback(() => {
setIsPopoverOpened(wasOpened => !wasOpened);
}
}, [setIsPopoverOpened]);

useKeyboardShortcuts(
useMemo(
() => ({
'toggle-connections': togglePopover,
}),
[togglePopover]
)
);

function activateItem(id: string): void {
setIsPopoverOpened(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import React from 'react';
import { ButtonIcon, Flex, Text } from 'design';
import { CircleStop, CircleCross } from 'design/Icon';
import { CircleCross, CircleStop } from 'design/Icon';
import { TrackedConnection } from 'teleterm/ui/services/connectionTracker';
import { ListItem } from 'teleterm/ui/components/ListItem';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { useKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation';

interface ConnectionItemProps {
index: number;
item: TrackedConnection;

onActivate(id: string): void;
onActivate(): void;

onRemove(id: string): void;
onRemove(): void;

onDisconnect(id: string): void;
onDisconnect(): void;
}

export function ConnectionItem(props: ConnectionItemProps) {
const offline = !props.item.connected;
const color = !offline ? 'text.primary' : 'text.placeholder';
const { isActive } = useKeyboardArrowsNavigation({
index: props.index,
onRunActiveItem: props.onActivate,
});

const actionIcons = {
disconnect: {
Expand All @@ -35,7 +41,7 @@ export function ConnectionItem(props: ConnectionItemProps) {
const actionIcon = offline ? actionIcons.remove : actionIcons.disconnect;

return (
<ListItem onClick={() => props.onActivate(props.item.id)}>
<ListItem onClick={() => props.onActivate()} isActive={isActive}>
<ConnectionStatusIndicator mr={2} connected={props.item.connected} />
<Flex
alignItems="center"
Expand All @@ -53,7 +59,7 @@ export function ConnectionItem(props: ConnectionItemProps) {
title={actionIcon.title}
onClick={e => {
e.stopPropagation();
actionIcon.action(props.item.id);
actionIcon.action();
}}
>
<actionIcon.Icon fontSize={12} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FilterableList } from 'teleterm/ui/components/FilterableList';
import { TrackedConnection } from 'teleterm/ui/services/connectionTracker';
import { ConnectionItem } from './ConnectionItem';
import { Box } from 'design';
import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation';

interface ConnectionsFilterableListProps {
items: TrackedConnection[];
Expand All @@ -19,19 +20,22 @@ export function ConnectionsFilterableList(
) {
return (
<Box width="200px">
<FilterableList<TrackedConnection>
items={props.items}
filterBy="title"
placeholder="Search Connections"
Node={({ item }) =>
ConnectionItem({
item,
onActivate: () => props.onActivateItem(item.id),
onRemove: () => props.onRemoveItem(item.id),
onDisconnect: () => props.onDisconnectItem(item.id),
})
}
/>
<KeyboardArrowsNavigation>
<FilterableList<TrackedConnection>
items={props.items}
filterBy="title"
placeholder="Search Connections"
Node={({ item, index }) => (
<ConnectionItem
item={item}
index={index}
onActivate={() => props.onActivateItem(item.id)}
onRemove={() => props.onRemoveItem(item.id)}
onDisconnect={() => props.onDisconnectItem(item.id)}
/>
)}
/>
</KeyboardArrowsNavigation>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export function useConnections() {

connectionTracker.useState();

const items = connectionTracker.getConnections();
// connected first
const items = [...connectionTracker.getConnections()].sort((a, b) => {
return a.connected === b.connected ? 0 : a.connected ? -1 : 1;
});

return {
isAnyConnectionActive: items.some(c => c.connected),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface FilterableListProps<T> {
filterBy: keyof T;
placeholder?: string;

Node(props: { item: T }): ReactNode;
Node(props: { item: T; index: number }): ReactNode;
}

const maxItemsToShow = 10;
Expand All @@ -32,7 +32,7 @@ export function FilterableList<T>(props: FilterableListProps<T>) {
/>
<UnorderedList>
{filteredItems.map((item, index) => (
<Fragment key={index}>{props.Node({ item })}</Fragment>
<Fragment key={index}>{props.Node({ item, index })}</Fragment>
))}
</UnorderedList>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import { fireEvent, render } from 'design/utils/testing';
import { KeyboardArrowsNavigation } from './KeyboardArrowsNavigation';
import { useKeyboardArrowsNavigation } from './useKeyboardArrowsNavigation';

test('context should render provided children', () => {
const { getByText } = render(
<KeyboardArrowsNavigation>
<span>Children</span>
</KeyboardArrowsNavigation>
);

expect(getByText('Children')).toBeVisible();
});

describe('should go through navigation items', () => {
function createTextItem(index: number, isActive: boolean) {
return `Index: ${index} active ${isActive.toString()}`;
}

function TestItem(props: { index: number }) {
const { isActive } = useKeyboardArrowsNavigation({
index: props.index,
onRunActiveItem: useCallback(() => {}, []),
});

return <>{createTextItem(props.index, isActive)}</>;
}

function getAllItemsText(activeIndex: number, length: number) {
return Array.from(new Array(length))
.fill(0)
.map((_, index) => createTextItem(index, index === activeIndex))
.join('');
}

test('in down direction', () => {
const { container } = render(
<KeyboardArrowsNavigation>
<TestItem index={0} />
<TestItem index={1} />
<TestItem index={2} />
</KeyboardArrowsNavigation>
);

expect(container).toHaveTextContent(getAllItemsText(0, 3));

fireEvent.keyDown(window, { key: 'ArrowDown' });
expect(container).toHaveTextContent(getAllItemsText(1, 3));

fireEvent.keyDown(window, { key: 'ArrowDown' });
expect(container).toHaveTextContent(getAllItemsText(2, 3));

fireEvent.keyDown(window, { key: 'ArrowDown' });
expect(container).toHaveTextContent(getAllItemsText(0, 3));
});

test('in up direction', () => {
const { container } = render(
<KeyboardArrowsNavigation>
<TestItem index={0} />
<TestItem index={1} />
<TestItem index={2} />
</KeyboardArrowsNavigation>
);

expect(container).toHaveTextContent(getAllItemsText(0, 3));

fireEvent.keyDown(window, { key: 'ArrowUp' });
expect(container).toHaveTextContent(getAllItemsText(2, 3));

fireEvent.keyDown(window, { key: 'ArrowUp' });
expect(container).toHaveTextContent(getAllItemsText(1, 3));

fireEvent.keyDown(window, { key: 'ArrowUp' });
expect(container).toHaveTextContent(getAllItemsText(0, 3));
});
});

test('should fire action on active item when Enter is pressed', () => {
const firstItemCallback = jest.fn();

function TestItem(props: { index: number; onRunActiveItem(): void }) {
useKeyboardArrowsNavigation({
index: props.index,
onRunActiveItem: props.onRunActiveItem,
});

return <>Test item</>;
}

render(
<KeyboardArrowsNavigation>
<TestItem index={0} onRunActiveItem={firstItemCallback} />
</KeyboardArrowsNavigation>
);
fireEvent.keyDown(window, { key: 'Enter' });
expect(firstItemCallback).toHaveBeenCalledWith();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { createContext, FC, useEffect, useState } from 'react';

export type RunActiveItemHandler = () => void;

export const KeyboardArrowsNavigationContext = createContext<{
activeIndex: number;
addItem(index: number, onRunActiveItem: RunActiveItemHandler): void;
removeItem(index: number): void;
}>(null);

enum KeyboardArrowNavigationKeys {
ArrowDown = 'ArrowDown',
ArrowUp = 'ArrowUp',
Enter = 'Enter',
}

export const KeyboardArrowsNavigation: FC = props => {
const [items, setItems] = useState<RunActiveItemHandler[]>([]);
const [activeIndex, setActiveIndex] = useState<number>(0);

function addItem(index: number, onRunActiveItem: RunActiveItemHandler): void {
setItems(prevItems => {
const newItems = [...prevItems];
if (newItems[index] === onRunActiveItem) {
throw new Error(
'Tried to override an index with the same `onRunActiveItem()` callback.'
);
}
newItems[index] = onRunActiveItem;
return newItems;
});
}

function removeItem(index: number): void {
setItems(prevItems => {
const newItems = [...prevItems];
newItems[index] = undefined;
return newItems;
});
setActiveIndex(0);
}

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (Object.keys(KeyboardArrowNavigationKeys).includes(event.key)) {
event.stopPropagation();
event.preventDefault();
}

switch (event.key) {
case 'ArrowDown':
setActiveIndex(getNextIndex(items, activeIndex));
break;
case 'ArrowUp':
setActiveIndex(getPreviousIndex(items, activeIndex));
break;
case 'Enter':
items[activeIndex]();
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, setActiveIndex, activeIndex]);

return (
<KeyboardArrowsNavigationContext.Provider
value={{ addItem, removeItem, activeIndex }}
>
{props.children}
</KeyboardArrowsNavigationContext.Provider>
);
};

function getNextIndex(
items: RunActiveItemHandler[],
currentIndex: number
): number {
for (let i = currentIndex + 1; i < items.length; ++i) {
if (items[i]) {
return i;
}
}

// if there was no item after the current index, start from the beginning
for (let i = 0; i < currentIndex; i++) {
if (items[i]) {
return i;
}
}

return currentIndex;
}

function getPreviousIndex(
items: RunActiveItemHandler[],
currentIndex: number
): number {
for (let i = currentIndex - 1; i >= 0; --i) {
if (items[i]) {
return i;
}
}

// if there was no item before the current index, start from the end
for (let i = items.length - 1; i > currentIndex; i--) {
if (items[i]) {
return i;
}
}

return currentIndex;
}
Loading