diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx index f134d6819..4b2d7e7c3 100644 --- a/client/src/components/Collab/CollabLinks.test.tsx +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -63,7 +63,8 @@ describe('CollabLinks', () => { ); render(); expect(await screen.findByText('Ferry timetable')).toBeInTheDocument(); - expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument(); + // The chip shows the host, not the whole address, and links to the address itself. + expect(screen.getByText('ferries.example')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable'); }); @@ -144,17 +145,19 @@ describe('CollabLinks', () => { await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' })); await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument()); expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument(); - expect(screen.getByText('https://ferries.example/2026')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /ferry timetable 2026/i })).toHaveAttribute('href', 'https://ferries.example/2026'); }); - it('FE-COMP-LINKS-010: the external-link glyph opens the address in a new tab', async () => { + it('FE-COMP-LINKS-010: the chip is the link and opens the address in a new tab', async () => { server.use( http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), ); render(); - const open = await screen.findByRole('link', { name: /open link|collab\.links\.open/i }); + const open = await screen.findByRole('link', { name: /ferry timetable/i }); expect(open).toHaveAttribute('href', 'https://ferries.example/timetable'); expect(open).toHaveAttribute('target', '_blank'); + // The host stands beside the title; the whole address would not fit a chip. + expect(screen.getByText('ferries.example')).toBeInTheDocument(); }); it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index 43f285c67..be716b693 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -18,12 +18,20 @@ interface CollabLink { pinned: boolean | number } -function LinkIcon({ url, title }: { url: string; title: string }) { +function LinkIcon({ url }: { url: string }) { const [failed, setFailed] = useState(false) let favicon = '' try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ } - if (!favicon || failed) return - return setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} /> + return ( + + ) +} + +/** The part of the address worth a chip's width: the host, without a leading www. */ +function hostOf(url: string): string { + try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } } /** @@ -211,35 +219,36 @@ export default function CollabLinks({ tripId }: { tripId: number }) { )} - {/* Content */} -
+ {/* Content: one chip per link, flowing like tags (see .collab-link-chip). The chip + is the link, with the host beside the title; the actions unfold from its tail. */} +
{links.length === 0 ? ( ) : ( -
+
{links.map(link => ( -
- - -
{link.title}
-
{link.url}
+
+ + + + {link.title} + {hostOf(link.url)} + + - {/* The glyph used to be decoration next to a row that was already a link, - which read as a button that did nothing (#2414). */} - - - {canEdit && <> - - - - } + {canEdit && ( + + + + + + )}
))}
diff --git a/client/src/index.css b/client/src/index.css index 56b6f5440..31c4f658e 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1375,6 +1375,111 @@ img[alt="TREK"] { justify-content: center; } +/* Shared links in the Collab tab: one chip per link, flowing like tags. The chip is + the link; edit, pin and delete sit folded into its tail and unfold on hover or + keyboard focus, and stay unfolded where there is no hover to speak of. */ +.collab-link-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 5px 8px 5px 5px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-faint); + box-shadow: var(--shadow-sm); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; +} +.collab-link-chip:hover, .collab-link-chip:focus-within { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + border-color: var(--border-primary); +} +.collab-link-chip--pinned { + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.collab-link-chip__main { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + color: var(--text-primary); + text-decoration: none; + outline: none; +} +.collab-link-chip__icon { + position: relative; + display: grid; + place-items: center; + width: 24px; + height: 24px; + flex: 0 0 24px; + border-radius: 50%; + overflow: hidden; + background: var(--bg-card); + color: var(--text-faint); +} +.collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; } +.collab-link-chip__text { display: flex; align-items: baseline; gap: 6px; min-width: 0; } +.collab-link-chip__title { + font-size: calc(12.5px * var(--fs-scale-body, 1)); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} +.collab-link-chip__host { + font-size: calc(10.5px * var(--fs-scale-caption, 1)); + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} +.collab-link-chip__open { + flex: 0 0 auto; + color: var(--text-faint); + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; +} +.collab-link-chip:hover .collab-link-chip__open, .collab-link-chip:focus-within .collab-link-chip__open { opacity: 1; } +.collab-link-chip__main:hover .collab-link-chip__open { color: var(--text-primary); } +.collab-link-chip__actions { + display: flex; + align-items: center; + gap: 1px; + max-width: 0; + opacity: 0; + overflow: hidden; + transition: max-width 0.2s ease, opacity 0.15s ease, margin-left 0.2s ease; +} +.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { + max-width: 96px; + opacity: 1; + margin-left: 2px; +} +.collab-link-chip__action { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-faint); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} +.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); } +.collab-link-chip__action--on { color: var(--accent); } +@media (hover: none) { + .collab-link-chip__actions { max-width: 96px; opacity: 1; margin-left: 2px; } + .collab-link-chip__open { opacity: 1; } +} + /* Markdown in Collab Notes */ .collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; } .collab-note-md em, .collab-note-md-full em { font-style: italic !important; }