fix(mobile): swipe actions are rounded tiles beside the row, icon only; expenses keep their own buttons

The actions under a swiped row were square blocks to the row's edge, unlike
the rounded cards around them. They are now tiles of their own with the
card's radius and a gap, inset within a grouped card such as the packing
list, and show only their icon (the label names them for a screen reader).
An expense already shows edit and delete beside its card, so it no longer
swipes to a second, larger pair of the same buttons.
This commit is contained in:
Maurice
2026-11-10 00:11:50 +01:00
parent 05caf67bc4
commit 0b46b89b62
4 changed files with 30 additions and 23 deletions
@@ -35,8 +35,8 @@ function setup() {
describe('MSwipeRow', () => {
it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => {
setup();
expect(screen.queryByText('Delete')).toBeNull();
expect(screen.queryByText('Done')).toBeNull();
expect(screen.queryByLabelText('Delete')).toBeNull();
expect(screen.queryByLabelText('Done')).toBeNull();
});
it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => {
@@ -45,7 +45,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchmove', 160);
touch(row, 'touchmove', 120);
touch(row, 'touchend');
fireEvent.click(screen.getByText('Delete'));
fireEvent.click(screen.getByLabelText('Delete'));
expect(onDelete).toHaveBeenCalledOnce();
});
@@ -54,7 +54,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchstart', 200, 100);
touch(row, 'touchmove', 190, 160);
touch(row, 'touchend');
expect(screen.queryByText('Delete')).toBeNull();
expect(screen.queryByLabelText('Delete')).toBeNull();
});
it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => {
@@ -72,7 +72,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchstart', 10);
touch(row, 'touchmove', 120);
touch(row, 'touchend');
expect(screen.queryByText('Done')).toBeNull();
expect(screen.queryByLabelText('Done')).toBeNull();
});
it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => {
+21 -6
View File
@@ -34,10 +34,18 @@ interface MSwipeRowProps {
className?: string;
/** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */
surface?: string;
/**
* How far the buttons sit in from the row's top and bottom edge. A row that is a
* card of its own leaves it at 0, so the buttons are as tall as the card; a row
* inside a grouped card insets them, so they do not touch its dividers.
*/
inset?: number;
children: ReactNode;
}
const BUTTON_W = 76;
/** One button, and the gap before it: the buttons are cards of their own beside the row. */
const BUTTON_W = 70;
const GAP = 6;
const EDGE_PX = 24;
const CLAIM_PX = 10;
const AXIS_BIAS = 1.5;
@@ -59,6 +67,7 @@ export default function MSwipeRow({
disabled = false,
className = '',
surface = 'bg-[color:var(--m-card)]',
inset = 0,
children,
}: MSwipeRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
@@ -91,7 +100,11 @@ export default function MSwipeRow({
// Positive offset reveals the leading side in LTR; RTL mirrors it.
const leadSign = () => (rtl() ? -1 : 1);
const openWidth = (which: 'leading' | 'trailing') =>
which === 'leading' ? (latest.current.leading ? BUTTON_W : 0) : latest.current.trailing.length * BUTTON_W;
which === 'leading'
? latest.current.leading
? BUTTON_W + GAP
: 0
: latest.current.trailing.length * (BUTTON_W + GAP);
const close = () => {
place(0, true);
@@ -194,17 +207,18 @@ export default function MSwipeRow({
action.onAction();
};
const shown = open ?? side;
// The icon alone, as in Mail; the label names the button for a screen reader.
const button = (action: SwipeAction) => (
<button
key={action.label}
type="button"
data-swipe-action=""
tabIndex={open ? 0 : -1}
aria-label={action.label}
onClick={() => run(action)}
className={`flex h-full w-[76px] flex-none flex-col items-center justify-center gap-1 text-caption font-semibold ${TONE[action.tone]}`}
className={`flex h-full w-[70px] flex-none items-center justify-center ${inset ? 'rounded-xl' : 'rounded-2xl'} ${TONE[action.tone]}`}
>
<action.icon size={18} strokeWidth={2.2} aria-hidden="true" />
{action.label}
<action.icon size={16} strokeWidth={2.2} aria-hidden="true" />
</button>
);
@@ -213,7 +227,8 @@ export default function MSwipeRow({
{shown && (
<div
aria-hidden={!open}
className={`absolute inset-0 flex ${shown === 'leading' ? 'justify-start' : 'justify-end'}`}
className={`absolute inset-x-0 flex gap-[6px] ${shown === 'leading' ? 'justify-start pe-[6px]' : 'justify-end ps-[6px]'}`}
style={{ top: inset, bottom: inset }}
>
{shown === 'leading' ? leading && button(leading) : trailing.map(button)}
</div>
@@ -15,7 +15,6 @@ import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import MConfirmSheet from '../../settings/MConfirmSheet'
import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow'
import { CountPill, TabPanes } from './tabChrome'
import { AddPaymentSheet, MemberAvatar } from './MCostsPayment'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
@@ -595,17 +594,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
const members = item.members || []
const note = readUserNote(item)
// A swipe offers what the buttons beside the card do; delete still asks first.
const swipe: SwipeAction[] = canEdit
? [
{ label: t('common.delete'), icon: Trash2, tone: 'danger', onAction: onDelete },
{ label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: onEdit },
]
: []
// No swipe here: edit and delete stand beside the card already, and a swipe
// put a second, larger pair of the same buttons next to them.
return (
<MSwipeRow trailing={swipe} disabled={!canEdit} surface="bg-[color:var(--m-bg)]" className="mt-2 rounded-2xl">
<div className="flex items-center gap-[6px]">
<div className="mt-2 flex items-center gap-[6px]">
<div className="relative min-w-0 flex-1 rounded-2xl bg-m-card px-3 pb-[10px] pt-[22px]" style={{ border: `1.5px solid ${borderColor}` }}>
<span
className="absolute -start-[1.5px] -top-[1.5px] flex items-center gap-1 rounded-es-none rounded-ee-[12px] rounded-ss-[15px] rounded-se-none px-[11px] pb-[4px] pt-[3px] font-geist text-[0.5625rem] font-extrabold uppercase tracking-[.05em] text-white"
@@ -688,7 +680,6 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
</div>
)}
</div>
</MSwipeRow>
)
}
@@ -714,6 +714,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
leading={swipeLeading}
trailing={swipeTrailing}
surface="bg-m-sheetop"
inset={4}
className="border-t border-[color:var(--m-rowbr)] first:border-t-0"
>
<div className="flex items-center gap-[9px] py-2">