mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
fix(packing): show who brings a shared item in a card with their pictures, and the others as a +N circle
The row's badges said who brings an item, who it is shared with and who takes care of it in the browser's own tooltip. They now use TREK's, and the bringers one lists everyone with their picture on a line of their own. The others show as a +N circle tucked under the first picture. Tooltip takes an optional content for tips that hold more than words.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
// FE-PACK-BRINGERCARD-001 to FE-PACK-BRINGERCARD-004
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { BringersCard, MoreBringers, bringersOf } from './PackingBringersCard';
|
||||
|
||||
const members = [
|
||||
{ id: 1, username: 'anna', avatar_url: '/uploads/avatars/anna.jpg' },
|
||||
{ id: 2, username: 'ben', avatar: '/uploads/avatars/ben.jpg' },
|
||||
{ id: 3, username: 'carla' },
|
||||
];
|
||||
|
||||
describe('bringersOf', () => {
|
||||
it('FE-PACK-BRINGERCARD-001: lists the bringer first, then the others, each with the picture the trip knows', () => {
|
||||
const item = {
|
||||
owner_id: 1,
|
||||
owner_username: 'anna',
|
||||
contributors: [
|
||||
{ user_id: 2, username: 'ben' },
|
||||
{ user_id: 3, username: 'carla' },
|
||||
],
|
||||
};
|
||||
expect(bringersOf(item, members)).toEqual([
|
||||
{ name: 'anna', avatar: '/uploads/avatars/anna.jpg' },
|
||||
{ name: 'ben', avatar: '/uploads/avatars/ben.jpg' },
|
||||
{ name: 'carla', avatar: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERCARD-002: lists a person once and copes with a bringer the trip no longer has', () => {
|
||||
const item = { owner_id: 9, owner_username: 'dora', contributors: [{ user_id: 9, username: 'dora' }] };
|
||||
expect(bringersOf(item, members)).toEqual([{ name: 'dora', avatar: null }]);
|
||||
expect(bringersOf({}, members)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BringersCard', () => {
|
||||
it('FE-PACK-BRINGERCARD-003: names everyone on a line of their own under the heading', () => {
|
||||
render(
|
||||
<BringersCard
|
||||
bringers={bringersOf(
|
||||
{ owner_id: 1, owner_username: 'anna', contributors: [{ user_id: 3, username: 'carla' }] },
|
||||
members
|
||||
)}
|
||||
/>
|
||||
);
|
||||
const rows = screen.getAllByRole('listitem');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('anna');
|
||||
expect(rows[1]).toHaveTextContent('carla');
|
||||
expect(screen.getByText('packing.bringersTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERCARD-004: the "+N" circle counts the others', () => {
|
||||
const { container } = render(<MoreBringers count={3} />);
|
||||
expect(container).toHaveTextContent('+3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { TripMember } from './usePackingListPanel';
|
||||
|
||||
/** One person bringing a common item: the name, and the picture when the trip knows one. */
|
||||
export interface Bringer {
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/** The item's bringer first, then whoever also brings it, each once, with their picture from the trip's members. */
|
||||
export function bringersOf(
|
||||
item: {
|
||||
owner_id?: number | null;
|
||||
owner_username?: string | null;
|
||||
contributors?: { user_id: number; username: string }[] | null;
|
||||
},
|
||||
members: readonly TripMember[]
|
||||
): Bringer[] {
|
||||
const avatarOf = (id: number | null | undefined) => {
|
||||
const member = id == null ? undefined : members.find((m) => m.id === id);
|
||||
return member?.avatar_url ?? member?.avatar ?? null;
|
||||
};
|
||||
const all = [
|
||||
...(item.owner_username ? [{ id: item.owner_id ?? null, name: item.owner_username }] : []),
|
||||
...(item.contributors ?? []).map((c) => ({ id: c.user_id, name: c.username })),
|
||||
];
|
||||
const bringers: Bringer[] = [];
|
||||
for (const person of all) {
|
||||
if (bringers.some((b) => b.name === person.name)) continue;
|
||||
bringers.push({ name: person.name, avatar: avatarOf(person.id) });
|
||||
}
|
||||
return bringers;
|
||||
}
|
||||
|
||||
const CIRCLE: CSSProperties = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
/** A bringer's picture, or the first letter of the name on a colour taken from it. */
|
||||
export function BringerAvatar({ bringer }: { bringer: Bringer }) {
|
||||
if (bringer.avatar) {
|
||||
return <img src={bringer.avatar} alt="" style={{ ...CIRCLE, objectFit: 'cover', display: 'block' }} />;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
...CIRCLE,
|
||||
background: `hsl(${((bringer.name.codePointAt(0) ?? 0) * 37) % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
|
||||
color: 'white', // theme-lint-disable: the letter sits on the name's own colour
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{bringer.name[0]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "+N" of the others bringing it, as a circle tucked under the first bringer's picture. */
|
||||
export function MoreBringers({ count }: { count: number }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
...CIRCLE,
|
||||
marginInlineStart: -6,
|
||||
color: 'var(--text-muted)',
|
||||
background: 'var(--bg-tertiary)',
|
||||
boxShadow: '0 0 0 2px var(--bg-card)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
+{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the row's tooltip shows: everyone bringing the item, one per line with their picture. */
|
||||
export function BringersCard({ bringers }: { bringers: readonly Bringer[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<div
|
||||
className="text-content-faint"
|
||||
style={{
|
||||
fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '.06em',
|
||||
textTransform: 'uppercase',
|
||||
paddingBottom: 6,
|
||||
}}
|
||||
>
|
||||
{t('packing.bringersTitle')}
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{bringers.map((b, i) => (
|
||||
<li
|
||||
key={b.name}
|
||||
className={i > 0 ? 'border-t border-edge-faint' : undefined}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}
|
||||
>
|
||||
<BringerAvatar bringer={b} />
|
||||
<span
|
||||
className="text-content"
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}
|
||||
>
|
||||
{b.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyle
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { packingBringerNames } from './packingBringers'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { BringerAvatar, BringersCard, MoreBringers, bringersOf } from './PackingBringersCard'
|
||||
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
@@ -78,7 +80,6 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
const recipients = item.recipients || []
|
||||
const sharedByMe = !!item.is_private && item.owner_id === currentUserId && recipients.length > 0
|
||||
const broughtBy = !item.is_private && item.owner_username ? item.owner_username : null
|
||||
const contributors = item.contributors || []
|
||||
const canShare = canEdit && !isPlaceholder && !!onSetSharing
|
||||
|
||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||
@@ -114,8 +115,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
// A multi-piece item counts its pieces into the bag (#2296).
|
||||
const packed = packedOf(item)
|
||||
const partlyPacked = !item.checked && packed > 0
|
||||
const owner = tripMembers.find(m => m.id === item.owner_id)
|
||||
const ownerAvatar = owner?.avatar_url ?? owner?.avatar ?? null
|
||||
const bringers = bringersOf(item, tripMembers)
|
||||
|
||||
// At rest a row reads as what it holds (owner, a quantity above one, a
|
||||
// weight, the bag) and everything else stays in place but dimmed: nothing
|
||||
@@ -215,31 +215,29 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
{/* Sharing badges (#858 three-tier), as compact chips like the phone's
|
||||
(#1525): the full sentence is the tooltip and the accessible name. */}
|
||||
{!isPlaceholder && sharedToMe && (
|
||||
<span className="packing-row-badge" title={t('packing.takenCareOf', { name: item.owner_username || '' })} aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 22, height: 22, borderRadius: 99, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)' }}>
|
||||
<HandHelping size={12} />
|
||||
</span>
|
||||
<Tooltip label={t('packing.takenCareOf', { name: item.owner_username || '' })}>
|
||||
<span className="packing-row-badge" aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: 22, height: 22, borderRadius: 99, color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 12%, transparent)' }}>
|
||||
<HandHelping size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isPlaceholder && sharedByMe && (
|
||||
<span className="packing-row-badge" title={`${t('packing.sharedWithCount', { count: recipients.length })}: ${recipients.map(r => r.username).join(', ')}`} aria-label={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, height: 22, padding: '0 7px', borderRadius: 99, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-tertiary)' }}>
|
||||
<UserRound size={11} /> {recipients.length}
|
||||
</span>
|
||||
<Tooltip label={`${t('packing.sharedWithCount', { count: recipients.length })}: ${recipients.map(r => r.username).join(', ')}`}>
|
||||
<span className="packing-row-badge" aria-label={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, height: 22, padding: '0 7px', borderRadius: 99, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', background: 'var(--bg-tertiary)' }}>
|
||||
<UserRound size={11} /> {recipients.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isPlaceholder && broughtBy && (
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
|
||||
{ownerAvatar
|
||||
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
|
||||
: (
|
||||
<span aria-hidden style={{
|
||||
width: 20, height: 20, borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
background: `hsl(${(broughtBy.codePointAt(0) ?? 0) * 37 % 360}, 55%, 55%)`, // theme-lint-disable: the colour comes from the bringer's name
|
||||
color: 'white', fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase',
|
||||
}}>{broughtBy[0]}</span>
|
||||
)}
|
||||
{contributors.length > 0 && <span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)' }}>+{contributors.length}</span>}
|
||||
</span>
|
||||
<Tooltip label={t('packing.broughtBy', { name: packingBringerNames(item) })} content={<BringersCard bringers={bringers} />}>
|
||||
<span className="packing-row-badge" aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<BringerAvatar bringer={bringers[0]} />
|
||||
{bringers.length > 1 && <MoreBringers count={bringers.length - 1} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="packing-row-inline-actions" style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user