style(collab): shared links as chips

One chip per link, flowing like tags, instead of a column of rows with four
grey glyphs each. The chip is the link, with the favicon and the host beside
the title; edit, pin and delete are folded into its tail and unfold on hover
or keyboard focus, and stay unfolded where there is no hover. A pinned chip
carries the accent.
This commit is contained in:
Maurice
2026-09-20 19:50:06 +02:00
parent 3dfdc92b2a
commit 8d70d875b3
3 changed files with 148 additions and 31 deletions
@@ -63,7 +63,8 @@ describe('CollabLinks', () => {
);
render(<CollabLinks tripId={1} />);
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(<CollabLinks tripId={1} />);
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 () => {
+36 -27
View File
@@ -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 <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
return (
<span className="collab-link-chip__icon" aria-hidden="true">
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
</span>
)
}
/** 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 }) {
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{/* 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. */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '2px 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-start' }}>
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<LinkIcon url={link.url} />
<span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
</span>
<ExternalLink className="collab-link-chip__open" size={12} aria-hidden="true" />
</a>
{/* The glyph used to be decoration next to a row that was already a link,
which read as a button that did nothing (#2414). */}
<a href={link.url} target="_blank" rel="noreferrer" aria-label={t('collab.links.open')} title={t('collab.links.open')} style={{ display: 'flex', color: 'var(--text-faint)' }}>
<ExternalLink size={15} aria-hidden="true" />
</a>
{canEdit && <>
<button type="button" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Pencil size={15} aria-hidden="true" />
</button>
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Trash2 size={15} aria-hidden="true" />
</button>
</>}
{canEdit && (
<span className="collab-link-chip__actions">
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
<Pencil size={13} aria-hidden="true" />
</button>
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
)}
</div>
))}
</div>
+105
View File
@@ -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; }