diff --git a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx index 75be094fb..79599e11e 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx @@ -328,3 +328,70 @@ describe('Connectors page: the tile surface', () => { expect(screen.queryByRole('region', { name: 'Discord' })).toBeNull(); }); }); + +describe('Connectors page: phone layout (d215 frame, 390)', () => { + const realMatchMedia = window.matchMedia; + beforeEach(() => { + window.matchMedia = jest.fn().mockImplementation(() => ({ + matches: true, addEventListener: jest.fn(), removeEventListener: jest.fn(), + })); + }); + afterEach(() => { window.matchMedia = realMatchMedia; }); + + it('is one grid with no group titles, the filter under the head and search behind an icon', async () => { + mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]); + renderPage(); + + await findTile('GitHub'); + expect(document.querySelectorAll('.v2-cx-grid')).toHaveLength(1); + expect(document.querySelector('.v2-cx-group__title')).toBeNull(); + expect(document.querySelector('.v2-cx-head__meta')).toBeNull(); + // the filter sits in the body under the 52px head, not in it + expect(document.querySelector('.v2-cx-head .v2-cx-seg')).toBeNull(); + expect(document.querySelector('.v2-cx-body .v2-cx-seg')).not.toBeNull(); + // channels first, tools next, the add tile last + const names = Array.from(document.querySelectorAll('.v2-cx-tile')).map((node) => node.getAttribute('title')); + expect(names).toEqual(['Telegram', 'Slack', 'GitHub', 'Linear', 'Channel']); + expect(names[names.length - 1]).toBe('Channel'); + + expect(screen.queryByRole('searchbox')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + }); + + it('folds every inactive tile under one +N off', async () => { + mockCatalog([ + channel('discord', 'Discord', { available: false }), + tool('github', 'GitHub'), + tool('linear', 'Linear', { available: false, connections: [] }), + ]); + renderPage(); + + await findTile('GitHub'); + const toggles = document.querySelectorAll('.v2-cx-off'); + expect(toggles).toHaveLength(1); + expect(toggles[0]).toHaveTextContent('+2 off'); + expect(document.querySelector('.v2-cx-tile[title="Discord"]')).toBeNull(); + fireEvent.click(toggles[0]); + expect(await findTile('Discord')).toBeInTheDocument(); + expect(await findTile('Linear')).toBeInTheDocument(); + }); + + it('counts +N off for the chosen filter only, and keeps the search button honest', async () => { + mockCatalog([channel('telegram', 'Telegram'), tool('github', 'GitHub'), tool('linear', 'Linear', { available: false, connections: [] })]); + renderPage(); + + await findTile('GitHub'); + expect(document.querySelector('.v2-cx-off')).toHaveTextContent('+1 off'); + fireEvent.click(screen.getByRole('button', { name: 'Channels' })); + expect(document.querySelector('.v2-cx-off')).toBeNull(); + + const searchButton = screen.getByRole('button', { name: 'Search' }); + expect(searchButton).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(searchButton); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'tele' } }); + fireEvent.click(searchButton); + expect(searchButton).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 80d9cacf2..0e6e7a9d1 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2513,6 +2513,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(cx).toMatch(/\.v2-feature--connectors > \.v2-feature__body \{[^}]*padding: 0;/); }); + it('the phone page is a 52px head, then a 12px-padded body of 104px tiles', () => { + expect(mediaBlock(640)).toMatch(/\.v2-cx-head \{[^}]*height: 52px/); + expect(mediaBlock(640)).toMatch(/\.v2-cx-body \{[^}]*padding: 12px/); + expect(mediaBlock(640)).toMatch(/\.v2-cx-tile \{[^}]*height: 104px/); + }); + it('the grid is six columns, three on a phone', () => { expect(baseBody('.v2-cx-grid')).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))'); expect(mediaBlock(640)).toMatch(/\.v2-cx-grid \{[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/); diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index 9ceb4b450..d637ba514 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -14,6 +14,7 @@ import type { ToolCatalogEntry } from './toolModel'; import { useChannelConnectors } from './useChannelConnectors'; import { useToolConnectors } from './useToolConnectors'; import { useToolPanels } from './ToolPanels'; +import { usePhone } from './usePhone'; import ChannelPanel from './ChannelPanel'; import ConnectorDrawer from './ConnectorDrawer'; import ConnectorLogo from './ConnectorLogo'; @@ -52,6 +53,9 @@ const ConnectorsPage: React.FC = () => { const [pop, setPop] = useState(null); const [toolKey, setToolKey] = useState(null); const [offOpen, setOffOpen] = useState({ channels: false, tools: false }); + const phone = usePhone(); + const [searchOpen, setSearchOpen] = useState(false); + const searchRef = useRef(null); const [container, setContainer] = useState(null); const tileRefs = useRef>({}); @@ -111,6 +115,12 @@ const ConnectorsPage: React.FC = () => { const toolEntry = toolKey ? m.catalog.find((entry) => entry.installableId === toolKey) || null : null; const toolDrawerOpen = !channelDrawerOpen && Boolean(m.draft || m.githubAppSetup || toolEntry || m.selected); + // Opening the phone search puts the cursor in the box. + useEffect(() => { if (searchOpen) searchRef.current?.focus(); }, [searchOpen]); + + // Crossing the phone breakpoint remounts every tile, so a reveal's anchor is gone. + useEffect(() => { setPop(null); }, [phone]); + // A drawer and a reveal never show together, and a reveal that opened a drawer // stays closed when the drawer does. useEffect(() => { @@ -452,29 +462,54 @@ const ConnectorsPage: React.FC = () => { ) : null); + const filterSeg = ( +
+ {FILTERS.map((value) => ( + + ))} +
+ ); + const searchBox = ( + + ); + // Phone: one grid, channels then tools, the add tile last, one "+N off" under it. + const offTotal = (showChannels ? offChannels.length : 0) + (showTools ? offTools.length + orphanHidden.length : 0); + const phoneOffOpen = offOpen.channels || offOpen.tools; + return (

{t('connectors.title', { defaultValue: 'Connectors' })}

- {!c.loading && {meta}} + {!phone && !c.loading && {meta}} - -
- {FILTERS.map((value) => ( - - ))} -
+ aria-expanded={searchOpen || Boolean(q)} + onClick={() => setSearchOpen((open) => !open)} + > + + + ) : ( + <> + {searchBox} + {filterSeg} + + )}
{c.loading &&
{t('connectors.loading', { defaultValue: 'Loading connectors…' })}
} @@ -482,9 +517,44 @@ const ConnectorsPage: React.FC = () => { {!c.loading && (
+ {phone && filterSeg} + {phone && (searchOpen || q) && searchBox} {!channelDrawerOpen && !toolDrawerOpen && noticeBlock} - {showChannels && ( + {phone && ( + <> +
+ {showChannels && liveChannels.map((item) => renderChannelTile(item))} + {showTools && liveTools.map((entry) => renderToolTile(entry))} + {phoneOffOpen && showChannels && offChannels.map((item) => renderChannelTile(item, true))} + {phoneOffOpen && showTools && offTools.map((entry) => renderToolTile(entry, true))} + {showChannels && availableProviders.length > 0 && !q && ( + + )} +
+ {offTotal > 0 && ( + + )} + {showTools && orphanGrants.length > 0 &&
{orphanGrants.map(tp.renderRow)}
} + {showTools && phoneOffOpen && orphanHidden.length > 0 && ( +
{orphanHidden.map(tp.renderHiddenConnection)}
+ )} + + )} + + {!phone && showChannels && (

{t('connectors.channelsGroup', { defaultValue: 'Channels' })} @@ -505,7 +575,7 @@ const ConnectorsPage: React.FC = () => {

)} - {showTools && ( + {!phone && showTools && (

{t('tools.title', { defaultValue: 'Tools' })} diff --git a/frontend/src/v2/connectors/connectors.css b/frontend/src/v2/connectors/connectors.css index b535f7277..5e0bf3afb 100644 --- a/frontend/src/v2/connectors/connectors.css +++ b/frontend/src/v2/connectors/connectors.css @@ -23,6 +23,9 @@ .v2-cx-head h1 { margin: 0; font: 800 24px/1 var(--v2-font-display, inherit); letter-spacing: -0.03em; white-space: nowrap; } .v2-cx-head__meta { font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-muted); white-space: nowrap; } .v2-cx-head__sp { flex: 1; } +.v2-cx-searchbtn { all: unset; box-sizing: border-box; display: none; cursor: pointer; place-items: center; width: 44px; height: 44px; margin-right: -8px; border-radius: 8px; color: var(--v2-text-secondary); } +.v2-cx-searchbtn svg { width: 18px; height: 18px; } +.v2-cx-searchbtn:focus-visible { box-shadow: 0 0 0 2px var(--v2-accent); } .v2-cx-search { flex: none; width: 260px; height: 34px; border-radius: 10px; background: var(--v2-bg-subtle); box-shadow: inset 0 0 0 1px var(--v2-border-soft); display: flex; align-items: center; gap: 7px; padding: 0 10px; color: var(--v2-text-placeholder); min-width: 0; } .v2-cx-search svg { flex: none; width: 15px; height: 15px; } .v2-cx-search input { all: unset; flex: 1; min-width: 0; font-size: 13px; color: var(--v2-text-primary); } @@ -89,10 +92,14 @@ /* phone: three to a row, a tab bar below (the shell's), the reveal as a sheet */ @media (max-width: 640px) { - .v2-cx-head { min-height: 52px; padding: 10px 10px 0 12px; gap: 8px; flex-wrap: wrap; } + .v2-cx-head { min-height: 52px; height: 52px; padding: 0 12px; gap: 8px; border-bottom: 0; } .v2-cx-head h1 { font-size: 21px; } - .v2-cx-search { width: auto; flex: 1 1 100%; order: 3; margin: 0 0 10px; } + .v2-cx-searchbtn { display: grid; } .v2-cx-body { padding: 12px; } + .v2-cx-body .v2-cx-seg { display: flex; width: 100%; box-sizing: border-box; margin: 0 0 10px; } + .v2-cx-body .v2-cx-seg button { flex: 1 1 0; } + .v2-cx-body .v2-cx-search { width: auto; margin: 0 0 10px; } + .v2-cx-off--phone { display: block; margin: 10px 0 0 4px; font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); } .v2-cx-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } .v2-cx-tile { height: 104px; } } diff --git a/frontend/src/v2/connectors/usePhone.ts b/frontend/src/v2/connectors/usePhone.ts new file mode 100644 index 000000000..8e2af35a9 --- /dev/null +++ b/frontend/src/v2/connectors/usePhone.ts @@ -0,0 +1,26 @@ +// True at phone width (the page's 640px breakpoint). The phone page is laid out +// differently from the wide one (one merged grid, a search icon, the filter below +// the head), so it is a render decision, not only CSS. +import { useEffect, useState } from 'react'; + +const QUERY = '(max-width: 640px)'; + +const read = (): boolean => ( + typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(QUERY).matches +); + +export const usePhone = (): boolean => { + const [phone, setPhone] = useState(read); + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined; + const list = window.matchMedia(QUERY); + const onChange = () => setPhone(list.matches); + onChange(); + // Safari before 14 only has the old addListener pair. + if (list.addEventListener) list.addEventListener('change', onChange); else list.addListener(onChange); + return () => { + if (list.removeEventListener) list.removeEventListener('change', onChange); else list.removeListener(onChange); + }; + }, []); + return phone; +};