diff --git a/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClusterItem.tsx b/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClusterItem.tsx
index 622f1ccc76fc3..faef48a3a9c68 100644
--- a/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClusterItem.tsx
+++ b/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClusterItem.tsx
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import React from 'react';
+import { useRef, useEffect } from 'react';
import { Flex, Label, Text } from 'design';
import styled from 'styled-components';
@@ -33,15 +33,21 @@ interface ClusterItemProps {
}
export function ClusterItem(props: ClusterItemProps) {
- const { isActive } = useKeyboardArrowsNavigation({
+ const { isActive, scrollIntoViewIfActive } = useKeyboardArrowsNavigation({
index: props.index,
onRun: props.onSelect,
});
+ const ref = useRef();
const clusterName = props.item.name;
+ useEffect(() => {
+ scrollIntoViewIfActive(ref.current);
+ }, [scrollIntoViewIfActive]);
+
return (
[
+ {
+ connected: false,
+ kind: 'connection.server' as const,
+ title: 'last-item',
+ id: 'last-item',
+ serverUri: '/clusters/foo/servers/last-item',
+ login: 'item',
+ clusterName: 'teleport.example.sh',
+ },
+ ...Array(10)
+ .fill(undefined)
+ .flatMap((_, index) => makeConnections(index)),
+ ];
+
+ return (
+
+
+
+
+
+
+
+ Manipulate window height to simulate how the list behaves in Connect.
+
+
+ );
+}
+
export function WithoutVnet() {
const appContext = new MockAppContext({ platform: 'win32' });
prepareAppContext(appContext);
@@ -87,40 +130,44 @@ export function EmptyWithoutVnet() {
);
}
+const makeConnections = (index = 0) => {
+ const suffix = index === 0 ? '' : `-${index}`;
+
+ return [
+ {
+ connected: true,
+ kind: 'connection.server' as const,
+ title: 'ansible' + suffix,
+ id: 'e9c4fbc2' + suffix,
+ serverUri: '/clusters/foo/servers/ansible' + suffix,
+ login: 'casey',
+ clusterName: 'teleport.example.sh',
+ },
+ {
+ connected: true,
+ kind: 'connection.gateway' as const,
+ title: 'postgres' + suffix,
+ targetName: 'postgres',
+ id: '68b6a281' + suffix,
+ targetUri: '/clusters/foo/dbs/brock' + suffix,
+ port: '22',
+ gatewayUri: '/gateways/empty',
+ clusterName: 'teleport.example.sh',
+ },
+ {
+ connected: false,
+ kind: 'connection.server' as const,
+ title: 'ansible-staging' + suffix,
+ id: '949651ed' + suffix,
+ serverUri: '/clusters/foo/servers/ansible-staging' + suffix,
+ login: 'casey',
+ clusterName: 'teleport.example.sh',
+ },
+ ];
+};
+
const prepareAppContext = (appContext: MockAppContext) => {
- appContext.connectionTracker.getConnections = () => {
- return [
- {
- connected: true,
- kind: 'connection.server',
- title: 'ansible',
- id: 'e9c4fbc2',
- serverUri: '/clusters/foo/servers/ansible',
- login: 'casey',
- clusterName: 'teleport.example.sh',
- },
- {
- connected: true,
- kind: 'connection.gateway',
- title: 'postgres',
- targetName: 'postgres',
- id: '68b6a281',
- targetUri: '/clusters/foo/dbs/brock',
- port: '22',
- gatewayUri: '/gateways/empty',
- clusterName: 'teleport.example.sh',
- },
- {
- connected: false,
- kind: 'connection.server',
- title: 'ansible-staging',
- id: '949651ed',
- serverUri: '/clusters/foo/servers/ansible-staging',
- login: 'casey',
- clusterName: 'teleport.example.sh',
- },
- ];
- };
+ appContext.connectionTracker.getConnections = () => makeConnections();
appContext.connectionTracker.activateItem = async () => {};
appContext.connectionTracker.disconnectItem = async () => {};
appContext.connectionTracker.removeItem = async () => {};
@@ -130,6 +177,14 @@ const prepareAppContext = (appContext: MockAppContext) => {
const useOpenConnections = () => {
useLayoutEffect(() => {
+ const areConnectionsOpen = !!document.querySelector(
+ 'input[role=searchbox]'
+ );
+
+ if (areConnectionsOpen) {
+ return;
+ }
+
const button = document.querySelector(
'button[title~="connections"i]'
) as HTMLButtonElement;
diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx
index 8e259de97d9f0..d01130c0007ea 100644
--- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx
+++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import React from 'react';
+import { useEffect, useRef } from 'react';
import { ButtonIcon, Flex, Text } from 'design';
import { Trash, Unlink } from 'design/Icon';
@@ -41,7 +41,7 @@ interface ConnectionItemProps {
export function ConnectionItem(props: ConnectionItemProps) {
const offline = !props.item.connected;
- const { isActive } = useKeyboardArrowsNavigation({
+ const { isActive, scrollIntoViewIfActive } = useKeyboardArrowsNavigation({
index: props.index,
onRun: props.onActivate,
});
@@ -60,11 +60,17 @@ export function ConnectionItem(props: ConnectionItemProps) {
};
const actionIcon = offline ? actionIcons.remove : actionIcons.disconnect;
+ const ref = useRef();
+
+ useEffect(() => {
+ scrollIntoViewIfActive(ref.current);
+ }, [scrollIntoViewIfActive]);
return (
{item.title};
}
-test('render first 10 items by default', () => {
+test('render all items by default', () => {
render(
items={mockedItems}
@@ -44,7 +44,7 @@ test('render first 10 items by default', () => {
);
const items = screen.getAllByRole('listitem');
- expect(items).toHaveLength(10);
+ expect(items).toHaveLength(30);
items.forEach((item, index) => {
expect(item).toHaveTextContent(mockedItems[index].title);
});
diff --git a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx
index e4ea227eec265..a58bf70930eb1 100644
--- a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx
+++ b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx
@@ -30,8 +30,6 @@ interface FilterableListProps {
onFilterChange?(filter: string): void;
}
-const maxItemsToShow = 10;
-
export function FilterableList(
props: React.PropsWithChildren>
) {
@@ -39,8 +37,7 @@ export function FilterableList(
const [searchValue, setSearchValue] = useState();
const filteredItems = useMemo(
- () =>
- filterItems(searchValue, items, props.filterBy).slice(0, maxItemsToShow),
+ () => filterItems(searchValue, items, props.filterBy),
[items, searchValue]
);
@@ -84,7 +81,7 @@ const UnorderedList = styled.ul`
`;
const StyledInput = styled(Input)`
- background: inherit;
+ background-color: inherit;
border-radius: 51px;
margin-bottom: 8px;
font-size: 14px;
diff --git a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/useKeyboardArrowsNavigation.ts b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/useKeyboardArrowsNavigation.ts
index 7847e386f3221..c37e67482d5c4 100644
--- a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/useKeyboardArrowsNavigation.ts
+++ b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/useKeyboardArrowsNavigation.ts
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import { useContext, useEffect } from 'react';
+import { useCallback, useContext, useEffect } from 'react';
import {
KeyboardArrowsNavigationContext,
@@ -48,8 +48,35 @@ export function useKeyboardArrowsNavigation({
return () => navigationContext.removeItem(index);
}, [index, onRun, navigationContext.addItem, navigationContext.removeItem]);
+ const isActive = index === navigationContext.activeIndex;
+
+ const scrollIntoViewIfActive = useCallback(
+ (el: HTMLElement | undefined) => {
+ if (!isActive || !el) {
+ return;
+ }
+
+ // By default, scrollIntoView uses 'start'. This is a problem in two cases:
+ //
+ // 1. When scrolling from the last to the first element, the top of the scrollable area gets
+ // aligned to the top of the first active element, not to the top of the parent container.
+ // 2. When scrolling from any other element to the next one, the scrollable area gets aligned
+ // to the top of the active element, meaning that the previous element immediately disappears.
+ //
+ // 'center' fixes both problems, while being closer than 'nearest' to how the browser adjusts
+ // the scrollable area when tabbing through focusable elements. It ensures that you see what's
+ // after and before the active element.
+ //
+ // Compared to 'nearest', it also makes sure that the scrollable area is aligned to its bottom
+ // when scrolling to the last element – 'nearest' aligns it to the bottom of the active item.
+ el.scrollIntoView({ block: 'center' });
+ },
+ [isActive]
+ );
+
return {
- isActive: index === navigationContext.activeIndex,
+ isActive,
+ scrollIntoViewIfActive,
};
}