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, }; }