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 3bab1f1cff12f..2bbe1f4120284 100644 --- a/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClusterItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Clusters/ClustersFilterableList/ClusterItem.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import { useRef, useEffect } from 'react'; import { Flex, Label, Text } from 'design'; import styled from 'styled-components'; @@ -31,15 +31,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 ( { + useOpenConnections(); + + return ; + }, + ], }; -export function ExpanderConnections() { +export function Story() { const appContext = new 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.activateItem = async () => {}; - appContext.connectionTracker.disconnectItem = async () => {}; - appContext.connectionTracker.removeItem = async () => {}; - appContext.connectionTracker.useState = () => null; + prepareAppContext(appContext); return ( @@ -71,3 +44,115 @@ export function ExpanderConnections() { ); } + +export function WithScroll() { + const appContext = new MockAppContext(); + prepareAppContext(appContext); + appContext.connectionTracker.getConnections = () => [ + { + 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 Empty() { + const appContext = new MockAppContext({ platform: 'win32' }); + + return ( + + + + ); +} + +const makeConnections = (index = 0): ExtendedTrackedConnection[] => { + 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 = () => makeConnections(); + appContext.connectionTracker.activateItem = async () => {}; + appContext.connectionTracker.disconnectItem = async () => {}; + appContext.connectionTracker.removeItem = async () => {}; + appContext.connectionTracker.useState = () => null; +}; + +const useOpenConnections = () => { + useLayoutEffect(() => { + const areConnectionsOpen = !!document.querySelector( + 'input[role=searchbox]' + ); + + if (areConnectionsOpen) { + return; + } + + const button = document.querySelector( + 'button[title~="connections"i]' + ) as HTMLButtonElement; + + button?.click(); + }); +}; 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 a76676451906d..d0b0b3bcec9f6 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import { useEffect, useRef } from 'react'; import { ButtonIcon, Flex, Text } from 'design'; import { Trash, Unlink } from 'design/Icon'; @@ -39,7 +39,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, }); @@ -58,11 +58,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} @@ -42,7 +42,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 47e2828f063dd..e15c07f4f88b0 100644 --- a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx +++ b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx @@ -28,15 +28,14 @@ interface FilterableListProps { onFilterChange?(filter: string): void; } -const maxItemsToShow = 10; - -export function FilterableList(props: FilterableListProps) { +export function FilterableList( + props: React.PropsWithChildren> +) { const { items } = props; const [searchValue, setSearchValue] = useState(); const filteredItems = useMemo( - () => - filterItems(searchValue, items, props.filterBy).slice(0, maxItemsToShow), + () => filterItems(searchValue, items, props.filterBy), [items, searchValue] ); @@ -79,7 +78,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 68c9ae3c9dd25..ac16b7218befe 100644 --- a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/useKeyboardArrowsNavigation.ts +++ b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/useKeyboardArrowsNavigation.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useContext, useEffect } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { KeyboardArrowsNavigationContext, @@ -46,8 +46,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, }; }