mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user