mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
fix(costs): row buttons, settle-up rows and share toggles say what they are about to a screen reader
A screen reader lists a page's buttons together, and the costs views gave
it twenty "Edit" and "Delete" buttons with nothing to tell them apart.
- Edit and delete on an expense name the expense ("Edit: Dinner"), edit
and undo on a payment name who paid whom, on desktop and phone.
- A settle-up row shows only initials in its avatars, its names lived in a
tooltip; the row now carries them for assistive tech too, and its Settle
button says who pays whom and how much.
- The search field of every searchable select (currency, time zone,
category) was read out as "..."; it now says Search in the user's
language.
- The public link toggles on the phone share sheet and the journey share
toggles say whether they are on (aria-pressed), not only by colour.
- Japanese still said "you" in the settle-up lines.
Refs #2604
This commit is contained in:
@@ -123,4 +123,20 @@ describe('CostsPanel: summary footers and won amounts (#2604)', () => {
|
||||
await user.type(shares[0], '1000.5');
|
||||
expect(shares[0].value).toBe('1000.');
|
||||
});
|
||||
|
||||
it('FE-COSTS-2604-005: edit and delete on a row say which expense they act on', async () => {
|
||||
mount([owedByBob()]);
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByRole('button', { name: 'Edit: Museum' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete: Museum' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COSTS-2604-006: a settle-up row and its button name who pays whom', async () => {
|
||||
mount([owedByBob()], {
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }],
|
||||
});
|
||||
await screen.findByText('Museum');
|
||||
expect(screen.getByRole('button', { name: /^Settle: bob → you, 20,00\s€$/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('bob → you').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await screen.findByText('Dinner')
|
||||
await screen.findByText('Payment')
|
||||
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^Undo(:|$)/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a manual payment via the Add payment button', async () => {
|
||||
@@ -242,7 +242,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Lunch')
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[0])
|
||||
await user.click(screen.getAllByRole('button', { name: /^Edit(:|$)/ })[0])
|
||||
await user.click(await screen.findByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(patched).toBeTruthy())
|
||||
@@ -453,7 +453,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Hotel')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
// Nobody paid this expense — reopening it must not silently reselect "You".
|
||||
expect(await screen.findByRole('radio', { name: 'No one paid yet' })).toBeChecked()
|
||||
@@ -579,7 +579,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
// Loading used to be payers.find(...), which silently dropped the second payer.
|
||||
// Seeded with two decimals since #2175, localized for EUR.
|
||||
@@ -768,7 +768,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Payment')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
// The stored USD amount comes back as-is (padded to two decimals, #2175),
|
||||
// not silently reread as euros.
|
||||
@@ -984,7 +984,7 @@ describe('CostsPanel — settle up', () => {
|
||||
await screen.findByText('45,00 €')
|
||||
// The "you're owed" card names who still owes me.
|
||||
expect(screen.getByText("You're owed")).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settle' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Settle(:|$)/ }))
|
||||
|
||||
await waitFor(() => expect(posted).toHaveLength(1))
|
||||
expect(posted[0]).toMatchObject({ from_user_id: 2, to_user_id: 1, amount: 45, currency: 'EUR' })
|
||||
@@ -1018,7 +1018,7 @@ describe('CostsPanel — settle up', () => {
|
||||
server.use(http.post('/api/trips/1/budget/settlements', () => HttpResponse.json({ error: 'no' }, { status: 500 })))
|
||||
mount([], { flows: [flows[0]] })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^Settle(:|$)/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
|
||||
addToast.mockClear()
|
||||
@@ -1037,10 +1037,10 @@ describe('CostsPanel — settle up', () => {
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Undo' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^Undo(:|$)/ }))
|
||||
await waitFor(() => expect(deleted).toBe(1))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Undo(:|$)/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
delete window.__addToast
|
||||
})
|
||||
@@ -1252,7 +1252,7 @@ describe('CostsPanel — expense rows', () => {
|
||||
mount([dinner()])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Delete(:|$)/ }))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
// The optimistic removal is rolled back by the store.
|
||||
@@ -1274,9 +1274,9 @@ describe('CostsPanel — expense rows', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Add expense' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Settle up' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add payment' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Settle' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Settle(:|$)/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Delete(:|$)/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Undo(:|$)/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1353,7 +1353,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
await user.click(await screen.findByRole('button', { name: /^Edit(:|$)/ }))
|
||||
const amount = await screen.findByPlaceholderText('0,00')
|
||||
await user.clear(amount)
|
||||
await user.type(amount, '12,50')
|
||||
@@ -1428,7 +1428,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
await user.click(await screen.findByRole('button', { name: /^Edit(:|$)/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
@@ -1441,7 +1441,7 @@ describe('CostsPanel — payment modal', () => {
|
||||
const user = userEvent.setup()
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
await user.click(await screen.findByRole('button', { name: /^Edit(:|$)/ }))
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
expect(save).toBeEnabled()
|
||||
|
||||
@@ -1477,7 +1477,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
mount([expense({ id: 150, name: 'Market run', category: 'groceries', total_price: 30, note, payers: [{ user_id: 1, amount: 30 }], members: [{ user_id: 1, username: 'alice', amount: 15 }, { user_id: 2, username: 'bob', amount: 15 }] })])
|
||||
|
||||
await screen.findByText('Market run')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
expect(await screen.findByDisplayValue('Apples')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Cake')).toBeInTheDocument()
|
||||
@@ -1499,7 +1499,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
mount([expense({ id: 151, name: 'Market run', category: 'groceries', total_price: 30, note: 'TICKETJSON:{oops', payers: [{ user_id: 1, amount: 30 }], members: [{ user_id: 1, username: 'alice' }] })])
|
||||
|
||||
await screen.findByText('Market run')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Add item/i })).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
|
||||
@@ -1520,7 +1520,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
})])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
// Custom shares reopen padded to two decimals (#2175), localized for EUR.
|
||||
expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT')
|
||||
@@ -1801,7 +1801,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
mount([dinner()])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Delete(:|$)/ }))
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true))
|
||||
expect(screen.queryByText('Dinner')).not.toBeInTheDocument()
|
||||
@@ -1872,7 +1872,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={[...tripMembers, { id: 3, username: 'cara', avatar_url: null }]} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }))
|
||||
await user.click(await screen.findByRole('button', { name: /^Edit(:|$)/ }))
|
||||
// No symbol is known, so the code itself prefixes the amount and labels the option.
|
||||
await screen.findByPlaceholderText('0.00')
|
||||
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
|
||||
@@ -1890,7 +1890,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
mount([expense({ id: 240, name: 'Mining rig', category: 'other', total_price: 2, currency: 'XBT', payers: [{ user_id: 1, amount: 2 }], members: [{ user_id: 1, username: 'alice' }] })])
|
||||
|
||||
await screen.findByText('Mining rig')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
// XBT is unknown to the locale map → dot separator, two decimals (#2175).
|
||||
expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument()
|
||||
@@ -2038,7 +2038,7 @@ describe('CostsPanel — split modes and guests', () => {
|
||||
})])
|
||||
|
||||
await screen.findByText('Hotel')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
|
||||
// Nobody paid, so switching to multi-payer seeds me as the only payer.
|
||||
await user.click(await screen.findByRole('button', { name: 'Multiple people paid' }))
|
||||
@@ -2159,11 +2159,11 @@ describe('CostsPanel — decimal padding on edit (#2175)', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Coffee')
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[0])
|
||||
await user.click(screen.getAllByRole('button', { name: /^Edit(:|$)/ })[0])
|
||||
expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[1])
|
||||
await user.click(screen.getAllByRole('button', { name: /^Edit(:|$)/ })[1])
|
||||
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2243,7 +2243,7 @@ describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
// Not "Unfinished": the refund has its recipient recorded.
|
||||
expect(within(row).queryByText('Unfinished')).toBeNull()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
// The negative payer reopens selected instead of resetting to "Nobody",
|
||||
// and the total is seeded signed and padded.
|
||||
expect(await screen.findByRole('radio', { name: 'You' })).toBeChecked()
|
||||
@@ -2540,7 +2540,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
@@ -2610,7 +2610,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
// The list reads the missing currency as the trip's own.
|
||||
expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument()
|
||||
// Seeding the display currency here would label 100 euro as 100 dollars, and a
|
||||
// plain save would then store it that way.
|
||||
@@ -2676,7 +2676,7 @@ describe('CostsPanel: a bill the server cannot convert', () => {
|
||||
// Only the currency the settlement is missing, at the rate the browser holds.
|
||||
await waitFor(() => expect(frozen).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }]))
|
||||
// The settlement is read again and Bob's half can be settled.
|
||||
expect(await screen.findByRole('button', { name: 'Settle' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: /^Settle(:|$)/ })).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/244\.50/).length).toBeGreaterThan(0)
|
||||
// Nothing on the page counts the bill as 8,920,000 dollars any more.
|
||||
expect(screen.queryByText(/\$8,920,000/)).toBeNull()
|
||||
@@ -2719,7 +2719,7 @@ describe('CostsPanel: a bill the server cannot convert', () => {
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Settle' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^Settle(:|$)/ }))
|
||||
await waitFor(() => expect(posted).toHaveLength(1))
|
||||
// The transfer freezes at the rate the settlement was labelled with, so it cancels its
|
||||
// flow to the cent.
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const idx = people.findIndex(p => p.id === id)
|
||||
return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient
|
||||
}, [people])
|
||||
const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}`
|
||||
const initial = useCallback((id: number) => id === me ? t('costs.youShort') : nameOf(id).charAt(0).toUpperCase(), [me, nameOf, t])
|
||||
|
||||
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||
@@ -447,14 +448,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{flows.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<Tooltip label={`${personName(f.from.user_id)} → ${f.to.user_id === me ? t('costs.youLower') : personName(f.to.user_id)}`}>
|
||||
<Tooltip label={transferLabel(f.from.user_id, f.to.user_id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
|
||||
<span className="sr-only">{transferLabel(f.from.user_id, f.to.user_id)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span>
|
||||
{canEdit && <button type="button" onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||
{canEdit && <button type="button" aria-label={`${t('costs.settle')}: ${transferLabel(f.from.user_id, f.to.user_id)}, ${fmt(f.amount)}`} onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -603,7 +605,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
* mobile card has always done it this way, and out here the buttons stop
|
||||
* competing with the amount for the right edge of the card.
|
||||
*/
|
||||
function RowActions({ onEdit, onDelete, deleteLabel }: { onEdit: () => void; onDelete: () => void; deleteLabel: string }) {
|
||||
function RowActions({ onEdit, onDelete, deleteLabel, name }: { onEdit: () => void; onDelete: () => void; deleteLabel: string; name: string }) {
|
||||
// No `background` here on purpose: an inline value would beat the :hover
|
||||
// rule in the stylesheet block below, which is how the first attempt at
|
||||
// this silently did nothing.
|
||||
@@ -615,10 +617,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return (
|
||||
<div className="exp-actions bg-surface-secondary border border-edge-faint" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" aria-label={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
|
||||
<button type="button" aria-label={`${t('common.edit')}: ${name}`} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={deleteLabel}>
|
||||
<button type="button" aria-label={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
<button type="button" aria-label={`${deleteLabel}: ${name}`} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
@@ -741,7 +743,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete') })}
|
||||
{canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete'), name: e.name })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -803,6 +805,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
// Booked in the trip currency like an expense, so it is explained the same way.
|
||||
const line = paymentLineOf(s, ctx, settled(s))
|
||||
const who = `${personName(s.from_user_id)} → ${personName(s.to_user_id)}`
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="bg-surface-secondary border border-edge-faint exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
@@ -812,10 +815,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{t('costs.payment')}
|
||||
{line && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginInlineStart: 10 }}>{fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}</span>}
|
||||
</div>
|
||||
<Tooltip label={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Tooltip label={who}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{who}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -829,10 +832,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{canEdit && (
|
||||
<div className="exp-actions bg-surface-secondary border border-edge-faint" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" aria-label={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
|
||||
<button type="button" aria-label={`${t('common.edit')}: ${who}`} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('costs.undo')}>
|
||||
<button type="button" aria-label={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
<button type="button" aria-label={`${t('costs.undo')}: ${who}`} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
@@ -935,7 +938,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<span className="text-content" style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}`
|
||||
// Capped and scrollable: a long trip's expense list would otherwise push the
|
||||
// rest of the sidebar off the screen every time a name is tapped.
|
||||
const listCls = { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, maxHeight: 180, overflowY: 'auto' } as const
|
||||
|
||||
@@ -100,6 +100,9 @@ describe('JourneyShareSection', () => {
|
||||
expect(timeline.className).toContain('bg-zinc-900')
|
||||
expect(screen.getByRole('button', { name: 'Map' }).className).toContain('bg-zinc-900')
|
||||
expect(screen.getByRole('button', { name: 'Gallery' }).className).not.toContain('bg-zinc-900')
|
||||
// The state is said, not only shown (#2604).
|
||||
expect(timeline).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Gallery' })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByText(`${window.location.origin}/public/journey/tok-abc`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button type="button"
|
||||
key={key}
|
||||
aria-pressed={!!link[key]}
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-[12px] font-medium transition-all ${
|
||||
link[key]
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('BookingCostsSection', () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
await user.click(linkSelect());
|
||||
await user.type(screen.getByPlaceholderText('...'), 'snack');
|
||||
await user.type(screen.getByPlaceholderText('Search'), 'snack');
|
||||
expect(screen.getByRole('button', { name: /Snacks/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Souvenirs/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -298,6 +298,9 @@ describe('TripMembersModal', () => {
|
||||
const input = screen.getByDisplayValue(/\/shared\/abc123/);
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
// Each toggle says whether it is on, not only by its colour (#2604).
|
||||
expect(screen.getByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-019: copy share link calls clipboard.writeText', async () => {
|
||||
@@ -1095,6 +1098,19 @@ describe('TripMembersModal on a phone', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) });
|
||||
server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({
|
||||
token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false,
|
||||
})));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument();
|
||||
|
||||
@@ -24,7 +24,7 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
{/* Permission checkboxes */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => (
|
||||
<button type="button" key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||
<button type="button" key={opt.key} aria-pressed={!!perms[opt.key]} aria-disabled={opt.always || undefined} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||
border: '1.5px solid', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('CustomSelect', () => {
|
||||
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} searchable={true} />);
|
||||
await user.click(screen.getByRole('button')); // open
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('...');
|
||||
const searchInput = screen.getByPlaceholderText('common.search');
|
||||
await user.type(searchInput, 'ban');
|
||||
|
||||
// Only Banana should remain, Apple and Cherry should be filtered out
|
||||
@@ -257,8 +257,8 @@ describe('CustomSelect', () => {
|
||||
render(<CustomSelect ariaLabel="Choose fruit" value="" onChange={onChange} options={OPTIONS} searchable />);
|
||||
const trigger = screen.getByRole('button', { name: 'Choose fruit' });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByPlaceholderText('...')).toHaveFocus();
|
||||
await user.type(screen.getByPlaceholderText('...'), 'ban');
|
||||
expect(screen.getByPlaceholderText('common.search')).toHaveFocus();
|
||||
await user.type(screen.getByPlaceholderText('common.search'), 'ban');
|
||||
await user.keyboard('{ArrowDown}{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('banana');
|
||||
expect(trigger).toHaveFocus();
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useId } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface SelectOption {
|
||||
// Callers use both string keys and numeric ids (e.g. day/place ids) as values, and
|
||||
@@ -91,6 +92,7 @@ export default function CustomSelect({
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const keyboardDirection = useRef<'first' | 'last' | null>(null)
|
||||
const menuId = useId()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Follows the trigger while the sheet scrolls and while the on-screen keyboard
|
||||
// resizes the viewport, instead of freezing at the rect measured on open (#1999).
|
||||
@@ -258,7 +260,8 @@ export default function CustomSelect({
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="..."
|
||||
placeholder={t('common.search')}
|
||||
aria-label={t('common.search')}
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
|
||||
padding: '5px 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit',
|
||||
|
||||
Reference in New Issue
Block a user