mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
fix(mobile): Arabic reads in its direction: arrows turn round, Latin names keep their ends, figures stay whole
In the touch shell the back, forward, disclosure, date-range and flow arrows kept pointing right in Arabic, so Settle up showed the debt the wrong way round and Vacay's year arrows pointed inward. The shell now mirrors lucide's direction arrows in right-to-left languages; an icon that names its own treatment keeps it, and the lightbox arrows stay on their physical sides. Latin user text in Arabic rows lost its beginning when cut and moved its punctuation to the wrong end. Names, titles, addresses and file names take their direction from their own text (dir=auto) and still line up with the row; markdown paragraphs, chat messages, poll options and journal stories are isolated; signed balances, amounts, sizes, rates and leg figures read as one left-to-right run; URL fields read left to right. Name pairs joined by an arrow isolate each name and point from the first to the second. The hero progress and poll result bars fill from the start edge. Arabic and Hebrew uppercase caption labels grow a step and take the muted ink, and a todo's due date in a notification is written in the reader's language instead of as YYYY-MM-DD.
This commit is contained in:
@@ -80,11 +80,9 @@
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
|
||||
"mobile/screens/trip/map/MMapArea.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
|
||||
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 1,
|
||||
"pages/AtlasPage.tsx": 2,
|
||||
|
||||
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
|
||||
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
|
||||
expect(container.querySelector('h1')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('h3')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Big').tagName).toBe('P');
|
||||
expect(screen.getByText('Small').tagName).toBe('P');
|
||||
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
|
||||
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
|
||||
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
|
||||
|
||||
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderInlineStart: `3px solid var(--journal-accent)`,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||
import { KIND_LOOK, createdAt, kindOf, localizedParams } from './notificationModel'
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
@@ -55,7 +55,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
useNotificationItemActions(notification, onClose)
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const bodyText = t(notification.text_key, localizedParams(notification.text_params, locale))
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-009
|
||||
*
|
||||
* What a notification's tile shows, how the server's time is read, and how the
|
||||
* list is cut into days under headings in the reader's language.
|
||||
@@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import NotificationDayList from './NotificationDayList';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf, localizedParams } from './notificationModel';
|
||||
|
||||
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
|
||||
id,
|
||||
@@ -89,3 +89,21 @@ describe('NotificationDayList', () => {
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localizedParams', () => {
|
||||
const NOW = new Date(2026, 10, 7, 12);
|
||||
|
||||
it('FE-NOTIF-MODEL-008: a due day sent as YYYY-MM-DD reads as the short date of the language', () => {
|
||||
const params = { todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '2026-11-05' };
|
||||
expect(localizedParams(params, 'de', NOW)).toEqual({ todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '5. Nov.' });
|
||||
expect(localizedParams(params, 'en-US', NOW).due).toBe('Nov 5');
|
||||
expect(localizedParams(params, 'ru', NOW).due).toBe('5 нояб.');
|
||||
// Another year names it.
|
||||
expect(localizedParams({ due: '2027-01-02' }, 'en-US', NOW).due).toBe('Jan 2, 2027');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
|
||||
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
|
||||
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,34 @@ export function createdAt(value: string): Date {
|
||||
|
||||
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
const BARE_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
/**
|
||||
* The parameters of a notification's text as the reader's language writes
|
||||
* them. A day the server sends (a todo's due date) arrives as YYYY-MM-DD, which
|
||||
* would stand as "2026-11-05" in a German or Arabic sentence; it becomes the
|
||||
* short date of the language ("5. Nov.", "٥ نوفمبر"), with the year only when
|
||||
* it is not this one. Everything else passes through as it came.
|
||||
*/
|
||||
export function localizedParams(
|
||||
params: Record<string, string> | null | undefined,
|
||||
locale: string,
|
||||
now: Date = new Date()
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = { ...params };
|
||||
for (const [key, value] of Object.entries(out)) {
|
||||
const day = BARE_DAY.exec(value);
|
||||
if (!day) continue;
|
||||
const date = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
|
||||
out[key] = new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||
}).format(date);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading of the day a notification belongs to, in the reader's language:
|
||||
* today and yesterday by name, the rest of the week by weekday, anything older
|
||||
|
||||
@@ -41,4 +41,23 @@ describe('MarkdownText', () => {
|
||||
expect(wrapper.className).toContain('truncate')
|
||||
expect(wrapper.className).toContain('text-m-muted')
|
||||
})
|
||||
|
||||
it('FE-MDTEXT-007: a clamped row takes its direction from its own text, so a Latin address in Arabic is cut at its end', () => {
|
||||
const { container } = render(<MarkdownText clamp>{'68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto'}</MarkdownText>)
|
||||
const wrapper = container.firstElementChild as HTMLElement
|
||||
expect(wrapper).toHaveAttribute('dir', 'auto')
|
||||
expect(wrapper).toHaveClass('truncate')
|
||||
// No isolate inside: dir="auto" would not see the text through a <bdi>.
|
||||
expect(wrapper.querySelector('bdi')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MDTEXT-008: in a full view each paragraph runs in its own direction, inside its block', () => {
|
||||
const text = ["Kyoto's kitchen: pickles and matcha everything.", 'Second paragraph?'].join('\n\n')
|
||||
const { container } = render(<MarkdownText>{text}</MarkdownText>)
|
||||
expect(container.firstElementChild).not.toHaveAttribute('dir')
|
||||
const paragraphs = container.querySelectorAll('p')
|
||||
expect(paragraphs).toHaveLength(2)
|
||||
paragraphs.forEach(p => expect(p.firstElementChild?.tagName).toBe('BDI'))
|
||||
expect(paragraphs[1].textContent).toBe('Second paragraph?')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from './markdownLink'
|
||||
import { isolatedTextBlocks } from './markdownSanitize'
|
||||
|
||||
/**
|
||||
* A clamped caption lives inside a tappable row, so an anchor there would eat
|
||||
@@ -12,6 +13,8 @@ const clampedComponents: Components = {
|
||||
a: ({ children }) => <>{children}</>,
|
||||
}
|
||||
|
||||
const fullComponents: Components = { ...isolatedTextBlocks, ...markdownLinkComponents }
|
||||
|
||||
/**
|
||||
* A description or note, rendered (#2337). The field is Markdown wherever it is
|
||||
* written — the editors ship a formatting bar — so printing it raw anywhere
|
||||
@@ -20,6 +23,9 @@ const clampedComponents: Components = {
|
||||
* `clamp` is the list-row variant: one line high, matching what the desktop
|
||||
* sidebar rows do with the same field. The height cap is inline because the row
|
||||
* itself sets a font size, and a Tailwind `leading-*` would race with it.
|
||||
* The clamped line takes its direction from its own text (dir="auto" on the
|
||||
* row, which a <bdi> inside would hide from it), so a Latin address in an Arabic
|
||||
* row loses its end at the cut, not its beginning.
|
||||
*/
|
||||
export default function MarkdownText({ children, className = '', clamp = false }: {
|
||||
children: string
|
||||
@@ -28,12 +34,13 @@ export default function MarkdownText({ children, className = '', clamp = false }
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
dir={clamp ? 'auto' : undefined}
|
||||
className={`collab-note-md ${clamp ? 'truncate ' : ''}${className}`}
|
||||
style={clamp ? { maxHeight: '1.2em', lineHeight: 1.2 } : undefined}
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={clamp ? clampedComponents : markdownLinkComponents}
|
||||
components={clamp ? clampedComponents : fullComponents}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
|
||||
@@ -31,6 +31,35 @@ export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
|
||||
[rehypeSanitize, schema],
|
||||
]
|
||||
|
||||
/**
|
||||
* A paragraph or heading of user text runs in its own direction: a Latin note
|
||||
* in the Arabic UI keeps its question mark and full stop at its end (and an
|
||||
* Arabic one in an English UI), while the block keeps the alignment of the page
|
||||
* around it. The isolate is inline, so the block itself stays as it was.
|
||||
*/
|
||||
export const isolatedTextBlocks: Components = {
|
||||
p: ({ children }) => (
|
||||
<p>
|
||||
<bdi>{children}</bdi>
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1>
|
||||
<bdi>{children}</bdi>
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2>
|
||||
<bdi>{children}</bdi>
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3>
|
||||
<bdi>{children}</bdi>
|
||||
</h3>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Links as in markdownLinkComponents (#1629): own tab plus `rel` protection.
|
||||
* The exception is a footnote or heading link, which points inside the text
|
||||
@@ -38,6 +67,7 @@ export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
|
||||
* somewhere else.
|
||||
*/
|
||||
export const sanitizedMarkdownComponents: Components = {
|
||||
...isolatedTextBlocks,
|
||||
a: ({ children, href, ...rest }) =>
|
||||
href?.startsWith('#') ? (
|
||||
<a {...rest} href={href}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import '@fontsource/geist-sans/600.css'
|
||||
// several .leaflet-* styles.
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
import './styles/rtl.css'
|
||||
import './styles/ferratas.css'
|
||||
import './styles/paddle.css'
|
||||
import './styles/walkRoute.css'
|
||||
|
||||
@@ -99,8 +99,8 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
<MapPin size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{str(r.name)}</span>
|
||||
{str(r.address) && <span className="block truncate font-geist text-[0.625rem] text-m-muted">{str(r.address)}</span>}
|
||||
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{str(r.name)}</span>
|
||||
{str(r.address) && <span dir="auto" className="block truncate font-geist text-[0.625rem] text-m-muted">{str(r.address)}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function MCollLinksEditor({ links, onChange, t }: MCollLinksEdito
|
||||
className={`w-24 flex-none ${rowInput}`}
|
||||
/>
|
||||
<input
|
||||
dir="ltr"
|
||||
value={l.url}
|
||||
onChange={e => setLink(i, { url: e.target.value })}
|
||||
placeholder="https://…"
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo
|
||||
className="mb-1 flex w-full items-center gap-[10px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[11px] text-start disabled:opacity-60"
|
||||
>
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: list.color || '#6366F1' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold text-m-ink">{list.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold text-m-ink">{list.name}</span>
|
||||
{busyId === list.id
|
||||
? <Loader2 size={13} className="flex-none animate-spin text-m-faint" />
|
||||
: <span className="font-geist text-[0.6875rem] font-bold text-m-faint">{list.place_count ?? 0}</span>}
|
||||
|
||||
@@ -59,11 +59,11 @@ export default function MCollPlaceRow({
|
||||
{selected ? <Check size={17} strokeWidth={2.4} /> : <TileIcon size={17} strokeWidth={2.2} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.78125rem] font-bold text-m-ink">{place.name}</span>
|
||||
<span dir="auto" className="block truncate text-[0.78125rem] font-bold text-m-ink">{place.name}</span>
|
||||
{place.address && (
|
||||
<span className="mt-[2px] flex items-center gap-[3px] truncate font-geist text-[0.625rem] text-m-muted">
|
||||
<MapPin size={9} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{place.address}</span>
|
||||
<span dir="auto" className="truncate">{place.address}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -84,7 +84,7 @@ export default function MCollPlaceRow({
|
||||
{cat && place.category?.name && (
|
||||
<span className={pill} style={pillStyle(cat.color)}>
|
||||
<TileIcon size={9} strokeWidth={2.6} className="flex-none" />
|
||||
<span className="truncate">{place.category.name}</span>
|
||||
<span dir="auto" className="truncate">{place.category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function MCollPlaceSheet({
|
||||
{held.category?.name && (
|
||||
<span className={HERO_CAT_CHIP}>
|
||||
<MapPin size={9} strokeWidth={2.6} className="flex-none" />
|
||||
<span className="truncate">{held.category.name}</span>
|
||||
<span dir="auto" className="truncate">{held.category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
{/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
|
||||
: <MapPin size={15} strokeWidth={2} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{trip.title}</span>
|
||||
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{trip.title}</span>
|
||||
{dateRange(trip) && (
|
||||
<span className="mt-[1px] flex items-center gap-1 truncate font-geist text-[0.6875rem] text-m-muted">
|
||||
<CalendarDays size={11} strokeWidth={2} className="flex-none" /> {dateRange(trip)}
|
||||
|
||||
@@ -367,7 +367,7 @@ export default function MCollections() {
|
||||
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start ${on ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color || '#6366F1' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{l.count}</span>
|
||||
{on && <Check size={14} strokeWidth={2.6} className="flex-none text-m-ink" />}
|
||||
</button>
|
||||
|
||||
@@ -140,7 +140,7 @@ function MCurrencyWidget(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[9px] font-geist text-[0.65625rem] text-m-muted">
|
||||
{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}
|
||||
{rate != null ? <span dir="ltr">{`1 ${from} = ${rate.toFixed(4)} ${to}`}</span> : t('dashboard.fx.unavailable')}
|
||||
</div>
|
||||
</WidgetPanel>
|
||||
)
|
||||
@@ -195,7 +195,7 @@ function MCollectionsWidget(): React.ReactElement {
|
||||
className="mt-[11px] flex w-full items-center rounded-[14px] p-[15px_14px] text-start text-white"
|
||||
style={{ background: list.color || entityGradient(list.id) }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.875rem] font-bold">{list.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.875rem] font-bold">{list.name}</span>
|
||||
<span className="ms-auto flex flex-none items-center gap-1 rounded-full bg-white/[.22] px-[10px] py-[3px] font-geist text-[0.625rem] font-bold">
|
||||
<MapPin size={10} strokeWidth={2.4} />
|
||||
{list.place_count ?? 0}
|
||||
@@ -307,7 +307,7 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
|
||||
<span dir="auto" className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
|
||||
{r.status === 'pending' && (
|
||||
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-muted">
|
||||
{t('reservations.pending')}
|
||||
|
||||
@@ -418,10 +418,10 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{trip.title}</div>
|
||||
<div dir="auto" className="mt-[7px] truncate text-[1.4375rem] font-bold">{trip.title}</div>
|
||||
{ongoing && (
|
||||
<div className="relative mb-2 mt-[9px] h-1 rounded-full bg-white/25">
|
||||
<span className="absolute bottom-0 left-0 top-0 rounded-full bg-white" style={{ width: `${progress}%` }} />
|
||||
<span className="absolute bottom-0 start-0 top-0 rounded-full bg-white" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex flex-wrap gap-[6px] ${ongoing ? '' : 'mt-[9px]'}`}>
|
||||
@@ -541,7 +541,7 @@ function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
|
||||
</div>
|
||||
<div className="p-[9px_12px_12px]">
|
||||
{/* The name has the card's whole width; the dates take the line under it. */}
|
||||
<div className="truncate text-[0.875rem] font-semibold">{trip.title}</div>
|
||||
<div dir="auto" className="truncate text-[0.875rem] font-semibold">{trip.title}</div>
|
||||
<span className="mt-[6px] inline-flex max-w-full items-center gap-[6px] rounded-full bg-[color:var(--m-ic)] px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-ink">
|
||||
<span className="truncate">{fullDate(trip.start_date, locale) ?? '—'}</span>
|
||||
<ArrowRight size={12} strokeWidth={2.2} className="flex-none text-m-faint" />
|
||||
@@ -572,7 +572,7 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
|
||||
<div className="absolute end-3 top-3 flex gap-[7px]">
|
||||
{actions.map(a => <CoverActionBtn key={a.key} action={a} />)}
|
||||
</div>
|
||||
<div className="absolute bottom-[14px] inset-x-4 truncate text-[1.625rem] font-extrabold text-white [text-shadow:0_2px_12px_rgba(0,0,0,.4)]">
|
||||
<div dir="auto" className="absolute bottom-[14px] inset-x-4 truncate text-[1.625rem] font-extrabold text-white [text-shadow:0_2px_12px_rgba(0,0,0,.4)]">
|
||||
{trip.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
|
||||
{t('mobileJourney.latestJourney')}
|
||||
</span>
|
||||
</span>
|
||||
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
|
||||
<div dir="auto" className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
|
||||
<div className="mt-[9px] flex gap-[6px]">
|
||||
<HeroPill icon={<NotebookPen size={10} strokeWidth={2.2} />} label={t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })} />
|
||||
<HeroPill icon={<Image size={10} strokeWidth={2.2} />} label={t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })} />
|
||||
@@ -186,7 +186,7 @@ function GridCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
|
||||
>
|
||||
<div className="h-[74px]" style={journeyCoverStyle(journey)} />
|
||||
<div className="px-3 pb-[13px] pt-[11px]">
|
||||
<div className="truncate text-[0.875rem] font-extrabold">{journey.title}</div>
|
||||
<div dir="auto" className="truncate text-[0.875rem] font-extrabold">{journey.title}</div>
|
||||
{hasStats && (
|
||||
<div className="mt-[9px] flex gap-[5px]">
|
||||
<span className="inline-flex items-center gap-[3px] rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
|
||||
|
||||
@@ -162,9 +162,10 @@ export default function MJourneyEntrySheet({
|
||||
name is the one thing that makes an entry findable later, and it is one
|
||||
line to type, so it comes first here too. */}
|
||||
{readOnly ? (
|
||||
<div className="pb-[10px] pt-1 text-[1.25rem] font-extrabold">{title || t('journey.editor.titlePlaceholder')}</div>
|
||||
<div className="pb-[10px] pt-1 text-[1.25rem] font-extrabold"><bdi>{title || t('journey.editor.titlePlaceholder')}</bdi></div>
|
||||
) : (
|
||||
<input
|
||||
dir="auto"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={t('journey.editor.titlePlaceholder')}
|
||||
@@ -404,6 +405,7 @@ export default function MJourneyEntrySheet({
|
||||
{captureOnly && !readOnly && (
|
||||
<textarea
|
||||
rows={2}
|
||||
dir="auto"
|
||||
value={story}
|
||||
onChange={e => setStory(e.target.value)}
|
||||
placeholder={t('journey.editor.writeStory')}
|
||||
@@ -422,6 +424,7 @@ export default function MJourneyEntrySheet({
|
||||
<textarea
|
||||
ref={storyRef}
|
||||
rows={3}
|
||||
dir="auto"
|
||||
value={story}
|
||||
onChange={e => setStory(e.target.value)}
|
||||
placeholder={t('journey.editor.writeStory')}
|
||||
@@ -547,6 +550,7 @@ export default function MJourneyEntrySheet({
|
||||
<div className={`${eyebrow} mb-[5px]`}>{t('journey.editor.location')}</div>
|
||||
<div className={`flex items-center gap-2 px-3 py-[10px] ${fieldShell}`}>
|
||||
<input
|
||||
dir="auto"
|
||||
value={locationQuery || locationName}
|
||||
readOnly={readOnly}
|
||||
onChange={e => searchLocation(e.target.value)}
|
||||
@@ -580,8 +584,8 @@ export default function MJourneyEntrySheet({
|
||||
>
|
||||
<MapPin size={13} className="mt-[2px] flex-none text-m-faint" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.78125rem] font-semibold">{r.name}</span>
|
||||
{r.address && <span className="block truncate font-geist text-[0.65625rem] text-m-muted">{r.address}</span>}
|
||||
<span dir="auto" className="block truncate text-[0.78125rem] font-semibold">{r.name}</span>
|
||||
{r.address && <span dir="auto" className="block truncate font-geist text-[0.65625rem] text-m-muted">{r.address}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -126,7 +126,7 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
|
||||
<span
|
||||
className={`min-w-0 flex-1 text-body font-extrabold leading-[1.3] text-m-ink ${suggestion ? 'italic text-m-muted' : ''}`}
|
||||
>
|
||||
{cardTitle(entry, t)}
|
||||
<bdi>{cardTitle(entry, t)}</bdi>
|
||||
</span>
|
||||
<FeedMarks entry={entry} showMood={showMood} showWeather={showWeather} />
|
||||
</span>
|
||||
@@ -137,12 +137,12 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
|
||||
) : (
|
||||
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
|
||||
)}
|
||||
<span className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
|
||||
<span dir="auto" className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
|
||||
{place}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{story && <span className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
|
||||
{story && <span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowRight, Bell, Check, Mail, Trash2, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { InAppNotification } from '../../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from '../../../components/Notifications/useNotificationItemActions'
|
||||
import { localizedParams } from '../../../components/Notifications/notificationModel'
|
||||
import MChip from '../../components/MChip'
|
||||
|
||||
interface MNotificationRowProps {
|
||||
@@ -15,11 +16,11 @@ interface MNotificationRowProps {
|
||||
* render as chips below the text, per-row delete sits in the trailing column.
|
||||
*/
|
||||
export default function MNotificationRow({ notification }: MNotificationRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const { handleRespond, handleNavigate, markRead, markUnread, deleteNotification } = useNotificationItemActions(notification)
|
||||
|
||||
const title = t(notification.title_key, notification.title_params)
|
||||
const body = t(notification.text_key, notification.text_params)
|
||||
const body = t(notification.text_key, localizedParams(notification.text_params, locale))
|
||||
|
||||
// Tapping an unread row marks it read; a row that is already read carries no
|
||||
// action, so it neither takes focus nor claims a role. It stays a div because
|
||||
|
||||
@@ -137,9 +137,10 @@ export default function MSettingsGeneral() {
|
||||
<MSetSegments
|
||||
value={settings.temperature_unit || 'celsius'}
|
||||
onChange={(v) => save('temperature_unit', v)}
|
||||
// The unit names are Latin in every language: isolated, so Arabic keeps "°F Fahrenheit" whole.
|
||||
options={[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
{ value: 'celsius', label: <bdi>°C Celsius</bdi> },
|
||||
{ value: 'fahrenheit', label: <bdi>°F Fahrenheit</bdi> },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -148,8 +149,8 @@ export default function MSettingsGeneral() {
|
||||
value={settings.distance_unit || 'metric'}
|
||||
onChange={(v) => save('distance_unit', v)}
|
||||
options={[
|
||||
{ value: 'metric', label: 'km Metric' },
|
||||
{ value: 'imperial', label: 'mi Imperial' },
|
||||
{ value: 'metric', label: <bdi>km Metric</bdi> },
|
||||
{ value: 'imperial', label: <bdi>mi Imperial</bdi> },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
/>
|
||||
)}
|
||||
<CatIcon size={12} strokeWidth={2.2} className="flex-none" style={{ color: cat?.color || 'var(--m-muted)' }} />
|
||||
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{place.name}</span>
|
||||
<span dir="auto" className="truncate text-[0.8125rem] font-semibold text-m-ink">{place.name}</span>
|
||||
</span>
|
||||
{sub && (
|
||||
<MarkdownText clamp className="mt-px font-geist text-[0.65625rem] text-m-muted">{sub}</MarkdownText>
|
||||
|
||||
@@ -389,7 +389,7 @@ function UpNextCard({ tl, t, onOpen }: {
|
||||
{time}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate text-[1.125rem] font-bold">{place?.name}</span>
|
||||
<span dir="auto" className="min-w-0 truncate text-[1.125rem] font-bold">{place?.name}</span>
|
||||
</div>
|
||||
{sub && <MarkdownText clamp className="mt-[2px] font-geist text-[0.75rem] text-m-muted">{sub}</MarkdownText>}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { WalkLateLine } from '../../../../components/Tours/walk/WalkTransitNote'
|
||||
import { TourDayTimeText, TourOverrunLine } from '../../../../components/Tours/TourDayTimeParts'
|
||||
import { tourDayEntryOf, type TourOverrun } from '../../../../components/Tours/tourDayTime'
|
||||
import type { WalkLate } from '../../../../components/Tours/walk/walkTransit'
|
||||
import { flowText } from '../../../../utils/flowText'
|
||||
|
||||
/**
|
||||
* The five row types of the mobile day timeline (place / manual transport /
|
||||
@@ -198,7 +199,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tour ? (chrome.editing && <TourDayRowIcon type={tour.tour_type} size={20} />) : <CatIcon size={12} strokeWidth={2.2} className="flex-none text-m-muted" />}
|
||||
<span className="min-w-0 truncate text-[0.875rem] font-semibold">{place?.name}</span>
|
||||
<span dir="auto" className="min-w-0 truncate text-[0.875rem] font-semibold">{place?.name}</span>
|
||||
{bookingLines.length > 0 && (
|
||||
<span className="flex flex-none items-center gap-1 rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] text-[0.5625rem] font-bold tracking-[.04em]">
|
||||
<Ticket size={10} strokeWidth={2.2} />
|
||||
@@ -216,7 +217,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
|
||||
)}
|
||||
{bookingLines.slice(1).map(line => (
|
||||
<div key={line.id} className="mt-[2px] flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-geist text-[0.71875rem] text-m-muted">{line.text}</span>
|
||||
<span dir="auto" className="min-w-0 truncate font-geist text-[0.71875rem] text-m-muted">{line.text}</span>
|
||||
{line.code && <BlurredCode interactive={false} className={ROW_CODE}>{line.code}</BlurredCode>}
|
||||
</div>
|
||||
))}
|
||||
@@ -284,7 +285,7 @@ export function TransportRow({ res, dayId, chrome, reorder, drag, onOpen }: {
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4 rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-[7px]">
|
||||
<span className="min-w-0 truncate text-[0.875rem] font-semibold">{res.title}</span>
|
||||
<span dir="auto" className="min-w-0 truncate text-[0.875rem] font-semibold">{res.title}</span>
|
||||
{time && <span className={TIME_CHIP}>{time}</span>}
|
||||
</div>
|
||||
{sub && <div className="mt-px truncate font-geist text-[0.71875rem] text-m-muted">{sub}</div>}
|
||||
@@ -376,7 +377,7 @@ function TransitLegRows({ legs, t }: { legs: TransitLegDisplay[]; t: Translation
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LineBadge leg={leg} className="flex-none" />
|
||||
<span className="truncate text-[0.78125rem] font-bold">
|
||||
{[leg.from?.name, leg.to?.name].filter(Boolean).join(' → ')}
|
||||
{flowText([leg.from?.name, leg.to?.name].filter((n): n is string => !!n))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-[2px] font-geist text-[0.625rem] text-m-faint">
|
||||
@@ -434,7 +435,7 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, o
|
||||
>
|
||||
<div className="flex items-center gap-[7px]">
|
||||
<span className="min-w-0 truncate text-[0.84375rem] font-bold">
|
||||
{to ? `${from} → ${to}` : res.title}
|
||||
{to ? flowText([from, to]) : res.title}
|
||||
</span>
|
||||
{start && (
|
||||
<span className="ms-auto flex-none whitespace-nowrap rounded-[6px] bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.6875rem] font-semibold tabular-nums">
|
||||
@@ -475,8 +476,9 @@ function TravelLine({ seg }: { seg: RouteSegment }) {
|
||||
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
|
||||
<span className="inline-flex items-center gap-[6px] whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-2.5 py-[3px] font-geist text-[0.6875rem] leading-none">
|
||||
<Icon size={12} strokeWidth={2.2} className="flex-none text-m-muted" />
|
||||
<span className="font-semibold text-m-ink">{durationText}</span>
|
||||
{seg.distanceText && <span className="text-m-muted">{seg.distanceText}</span>}
|
||||
{/* Each figure in its own direction, so "5 km" does not turn into "km 5" in Arabic. */}
|
||||
<span dir="auto" className="font-semibold text-m-ink">{durationText}</span>
|
||||
{seg.distanceText && <span dir="auto" className="text-m-muted">{seg.distanceText}</span>}
|
||||
{/* Extra text a plugin route attached to this leg (e.g. "25 min charge"). */}
|
||||
{seg.noteText && (
|
||||
<span className="inline-flex items-center gap-[3px] text-m-muted">
|
||||
@@ -540,7 +542,7 @@ export function HotelConnRow({ seg, name, placement }: {
|
||||
const hotel = (
|
||||
<div className="flex items-center justify-center gap-[5px] whitespace-nowrap font-geist text-[0.59375rem] font-semibold text-m-muted">
|
||||
<BedDouble size={11} strokeWidth={2} className="flex-none" />
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
<span dir="auto" className="min-w-0 truncate">{name}</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Hotel, MapPin, X } from 'lucide-react'
|
||||
import { ArrowRight, Check, Hotel, MapPin, X } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
@@ -140,7 +140,7 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-none text-m-faint">→</span>
|
||||
<ArrowRight size={14} strokeWidth={2.2} aria-hidden="true" className="flex-none text-m-faint" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<CustomSelect
|
||||
value={range.end}
|
||||
@@ -224,8 +224,8 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps
|
||||
{p.image_url ? <img src={p.image_url} alt="" className="h-full w-full object-cover" /> : <MapPin size={14} className="text-m-faint" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{p.name}</div>
|
||||
{p.address && <div className="truncate font-geist text-[0.65625rem] text-m-muted">{p.address}</div>}
|
||||
<div dir="auto" className="truncate text-[0.8125rem] font-semibold text-m-ink">{p.name}</div>
|
||||
{p.address && <div dir="auto" className="truncate font-geist text-[0.65625rem] text-m-muted">{p.address}</div>}
|
||||
</div>
|
||||
{sel && (
|
||||
<span className="flex h-[22px] w-[22px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg">
|
||||
|
||||
@@ -96,6 +96,7 @@ export function MBookingNotes({
|
||||
<>
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.notes')}</Eyebrow>
|
||||
<textarea
|
||||
dir="auto"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={2}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react'
|
||||
import { ArrowRight, Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import CustomSelect from '../../../../components/shared/CustomSelect'
|
||||
import { NumericInput } from '../../../../components/shared/NumericInput'
|
||||
@@ -184,7 +184,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-[0.71875rem] text-m-muted">
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
{fx.inTrip != null && <>
|
||||
<span className="text-m-faint">→</span>
|
||||
<ArrowRight size={12} strokeWidth={2.2} aria-hidden="true" className="text-m-faint" />
|
||||
<span className={fx.shown == null ? 'font-semibold text-m-ink' : undefined}>{formatMoney(fx.inTrip, tripCur, locale)}</span>
|
||||
</>}
|
||||
{fx.shown != null && <>
|
||||
@@ -493,7 +493,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-start"
|
||||
>
|
||||
<Receipt size={14} className="flex-none text-m-faint" />
|
||||
<span className="truncate text-[0.8125rem] font-medium text-m-ink">{r.original_name}</span>
|
||||
<span dir="auto" className="truncate text-[0.8125rem] font-medium text-m-ink">{r.original_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -509,7 +509,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<div key={idx} className={ROW_CLS}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Paperclip size={14} className="flex-none text-m-faint" />
|
||||
<span className="truncate text-[0.8125rem] font-medium text-m-ink">{file.name}</span>
|
||||
<span dir="auto" className="truncate text-[0.8125rem] font-medium text-m-ink">{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -415,7 +415,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
className={`flex w-full items-center gap-[10px] rounded-[13px] px-[11px] py-[9px] text-start ${INNER_CLS}`}
|
||||
>
|
||||
<ResIcon size={15} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{r.title}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{r.title}</span>
|
||||
{(startTime || endTime) && (
|
||||
<span className="flex-none font-geist text-[0.6875rem] tabular-nums text-m-muted">
|
||||
{startTime ? displayTime(startTime, locale, timeFormat) : ''}
|
||||
@@ -512,9 +512,9 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.875rem] font-semibold">{acc.place_name}</div>
|
||||
<div dir="auto" className="truncate text-[0.875rem] font-semibold">{acc.place_name}</div>
|
||||
{acc.place_address && (
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{acc.place_address}</div>
|
||||
<div dir="auto" className="truncate font-geist text-[0.65625rem] text-m-muted">{acc.place_address}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function MLinkedCosts({ reservationId = null, placeId = null, hin
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{item.name}</span>
|
||||
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{item.name}</span>
|
||||
<span className="block truncate font-geist text-[0.65625rem] text-m-faint">{t(meta.labelKey)}</span>
|
||||
</span>
|
||||
<span className="flex-none font-geist text-[0.8125rem] font-semibold tabular-nums text-m-ink">{money(item)}</span>
|
||||
@@ -122,7 +122,7 @@ export default function MLinkedCosts({ reservationId = null, placeId = null, hin
|
||||
onClick={async () => { await link(item); setPicking(false); setQuery('') }}
|
||||
className="flex w-full items-center gap-[10px] rounded-[10px] px-[10px] py-[8px] text-start active:bg-[color:var(--m-ic)]">
|
||||
<Icon size={14} strokeWidth={2} className="flex-none" style={{ color: meta.color }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{item.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.78125rem] font-medium text-m-ink">{item.name}</span>
|
||||
<span className="flex-none font-geist text-[0.71875rem] tabular-nums text-m-muted">{money(item)}</span>
|
||||
<ChevronRight size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
|
||||
@@ -362,6 +362,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('places.formWebsite')}</Eyebrow>
|
||||
<input
|
||||
type="url"
|
||||
dir="ltr"
|
||||
value={form.website}
|
||||
onChange={e => handleChange('website', e.target.value)}
|
||||
placeholder="https://"
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-[1rem] font-bold leading-snug">{place.name}</span>
|
||||
<span dir="auto" className="min-w-0 truncate text-[1rem] font-bold leading-snug">{place.name}</span>
|
||||
{/* Accepted out of the traveller's own recordings — the same
|
||||
mark the desktop inspector carries. */}
|
||||
{place.source === 'dawarich' && (
|
||||
@@ -224,14 +224,14 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
)}
|
||||
</div>
|
||||
{place.address && (
|
||||
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted">{place.address}</div>
|
||||
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted"><bdi>{place.address}</bdi></div>
|
||||
)}
|
||||
{/* The category under the name and address, as wide as its name:
|
||||
squeezed under the photo it was cut to "Shoppi…". */}
|
||||
{category && (
|
||||
<span className="mt-[6px] inline-flex max-w-full items-center gap-1 rounded-full border border-[color:var(--m-faint)] px-2 py-[2px] font-geist text-[0.625rem] font-semibold text-m-muted">
|
||||
<CatIcon size={10} strokeWidth={2} className="flex-none" />
|
||||
<span className="truncate">{category.name}</span>
|
||||
<span dir="auto" className="truncate">{category.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -420,7 +420,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
className="h-2 w-2 flex-none rounded-full"
|
||||
style={{ background: res.status === 'confirmed' ? 'var(--m-st-confirmed)' : 'var(--m-st-pending)' }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{res.title}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold">{res.title}</span>
|
||||
<span className="flex-none font-geist text-[0.65625rem] text-m-muted">
|
||||
{res.status === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
@@ -477,7 +477,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
? <img src={m.avatar_url || avatarSrc(m.avatar)!} alt="" className="h-full w-full object-cover" />
|
||||
: m.username?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{m.username}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate">{m.username}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -522,7 +522,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
onClick={() => openFile(f.url, f.original_name)}
|
||||
className="flex w-full items-center gap-2 rounded-[10px] bg-[color:var(--m-ic)] px-[10px] py-[7px] text-start"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.75rem] font-medium">{f.original_name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.75rem] font-medium">{f.original_name}</span>
|
||||
<ExternalLink size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -218,6 +218,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.titleLabel')} *</Eyebrow>
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={form.title}
|
||||
onChange={e => set('title', e.target.value)}
|
||||
placeholder={t('reservations.titlePlaceholder')}
|
||||
@@ -363,7 +364,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
|
||||
{/* LINK */}
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.urlLabel')}</Eyebrow>
|
||||
<div className="relative">
|
||||
{/* An address reads left to right in every language, icon and all. */}
|
||||
<div className="relative" dir="ltr">
|
||||
<Link2 size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-m-faint" />
|
||||
<input
|
||||
type="url"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useBookingLink } from '../../../../hooks/useBookingLink'
|
||||
import { bookingFacts, stayStats } from '../../../../components/Planner/bookings/bookingFacts'
|
||||
import { parseMeta } from '../../../../components/Planner/bookings/bookingsModel'
|
||||
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
|
||||
import { flowText } from '../../../../utils/flowText'
|
||||
|
||||
interface TransportSheetPayload {
|
||||
reservationId?: number
|
||||
@@ -147,8 +148,8 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
if (airline) subParts.push(airline)
|
||||
if (flightNumber) subParts.push(flightNumber)
|
||||
if (trainNumber) subParts.push(trainNumber)
|
||||
if (leg && (leg.from || leg.to)) subParts.push([leg.from, leg.to].filter(Boolean).join(' → '))
|
||||
else if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
if (leg && (leg.from || leg.to)) subParts.push(flowText([leg.from, leg.to].filter((n): n is string => !!n)))
|
||||
else if (from?.name && to?.name) subParts.push(flowText([from.name, to.name]))
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
// A flight's legs carry no seat or platform of their own, so those stay the booking's.
|
||||
@@ -210,7 +211,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<ResIcon size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate text-[1rem]">{res.title}</span>}
|
||||
title={<span dir="auto" className="truncate text-[1rem]">{res.title}</span>}
|
||||
sub={subParts.length > 0 ? subParts.join(' · ') : undefined}
|
||||
onClose={shell.closeSheet}
|
||||
closeLabel={t('common.close')}
|
||||
@@ -266,9 +267,9 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
<span className="text-m-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate">{leg.from?.name}</span>
|
||||
<span dir="auto" className="truncate">{leg.from?.name}</span>
|
||||
<ArrowRight size={10} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className="truncate">{leg.to?.name}</span>
|
||||
<span dir="auto" className="truncate">{leg.to?.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -358,7 +359,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
className={`flex max-w-[150px] items-center gap-[5px] rounded-full px-3 py-[7px] text-[0.75rem] font-semibold ${INNER_CLS}`}
|
||||
>
|
||||
<Paperclip size={13} strokeWidth={2} className="flex-none" />
|
||||
<span className="truncate">{f.original_name}</span>
|
||||
<span dir="auto" className="truncate">{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
{canEditDays && (
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint
|
||||
className={ITEM_CLS}
|
||||
>
|
||||
<FileText size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-ink">{file.original_name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-ink">{file.original_name}</span>
|
||||
<Link2 size={12} strokeWidth={2.2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
@@ -99,14 +99,14 @@ export default function PlFileAttach({ planner, files, onAdd, onRemove, hideHint
|
||||
{isWalletPass(file.mime_type, file.original_name)
|
||||
? <Wallet size={11} strokeWidth={2} className="flex-none text-m-ink" />
|
||||
: <FileText size={11} strokeWidth={2} className="flex-none text-m-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] font-medium text-m-ink">{file.original_name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] font-medium text-m-ink">{file.original_name}</span>
|
||||
<ExternalLink size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
{files.map((file, idx) => (
|
||||
<div key={`${file.name}-${idx}`} className={ITEM_CLS}>
|
||||
<Paperclip size={11} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{file.name}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(idx)}
|
||||
|
||||
@@ -136,9 +136,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onSuggest
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{s.mainText}</div>
|
||||
<div dir="auto" className="truncate text-[0.8125rem] font-semibold text-m-ink">{s.mainText}</div>
|
||||
{s.secondaryText && (
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
|
||||
<div dir="auto" className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
|
||||
)}
|
||||
</div>
|
||||
<SourceMark label={sourceLabel(s, acSource)} />
|
||||
@@ -159,8 +159,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onSuggest
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{String(result.address ?? '')}</div>
|
||||
<div dir="auto" className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
|
||||
<div dir="auto" className="truncate font-geist text-[0.65625rem] text-m-muted">{String(result.address ?? '')}</div>
|
||||
</div>
|
||||
<SourceMark label={sourceLabel(result, searchSource)} />
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
{res.location && (
|
||||
<div className="mt-2 flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]">
|
||||
<MapPin size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="truncate text-[0.71875rem] font-semibold text-m-ink">{res.location}</span>
|
||||
<span dir="auto" className="truncate text-[0.71875rem] font-semibold text-m-ink">{res.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
</button>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
@@ -333,10 +334,10 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
{hasReply && (
|
||||
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
|
||||
<div className="font-geist text-[0.625rem] font-bold opacity-70">{msg.reply_username}</div>
|
||||
<div className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
<div dir="auto" className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
|
||||
<span dir="auto" className="whitespace-pre-wrap break-words">{msg.text}</span>
|
||||
{msg.attachments?.map(att => (
|
||||
<img key={att.id} src={att.url} alt={att.original_name || ''} className="mt-2 max-h-52 w-full rounded-[10px] object-cover" />
|
||||
))}
|
||||
|
||||
@@ -282,7 +282,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
|
||||
<div className="flex items-center gap-[7px] px-3 py-[10px]" style={{ background: `${color}0d` }}>
|
||||
{!!note.pinned && <Pin size={11} strokeWidth={2.4} style={{ color }} className="flex-none" />}
|
||||
<button type="button" onClick={onTap} className="flex min-w-0 flex-1 items-center gap-[7px] text-start">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-bold text-m-ink">{note.title}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.8125rem] font-bold text-m-ink">{note.title}</span>
|
||||
{note.category && (
|
||||
<span
|
||||
className="flex-none rounded-full px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.03em]"
|
||||
@@ -335,7 +335,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
|
||||
<span className="flex h-4 w-4 flex-none items-center justify-center rounded-full bg-m-card font-geist text-[0.5rem] font-bold text-m-muted">
|
||||
{initial}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.625rem] text-m-faint">{note.username}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate font-geist text-[0.625rem] text-m-faint">{note.username}</span>
|
||||
{note.attachments.length > 0 && (
|
||||
<span className="flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-semibold text-m-faint">
|
||||
<Paperclip size={10} strokeWidth={2.2} />
|
||||
@@ -435,6 +435,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('common.title')} *</Eyebrow>
|
||||
<input
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
@@ -444,6 +445,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('collab.notes.content')}</Eyebrow>
|
||||
<textarea
|
||||
dir="auto"
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
rows={5}
|
||||
@@ -506,6 +508,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
dir="ltr"
|
||||
value={website}
|
||||
onChange={e => setWebsite(e.target.value)}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
|
||||
@@ -265,7 +265,7 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
|
||||
onClick={() => onVote(poll.id, idx)}
|
||||
className="relative flex items-start gap-[8px] overflow-hidden rounded-[10px] bg-m-card px-[12px] py-[10px] text-start disabled:cursor-default"
|
||||
>
|
||||
<span className="absolute inset-y-0 left-0" style={{ width: `${pct}%`, background: fillTint }} />
|
||||
<span className="absolute inset-y-0 start-0" style={{ width: `${pct}%`, background: fillTint }} />
|
||||
<span
|
||||
className={`relative flex h-5 w-5 flex-none items-center justify-center border-2 ${
|
||||
poll.multiple_choice ? 'rounded-[6px]' : 'rounded-full'
|
||||
@@ -276,7 +276,7 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
|
||||
</span>
|
||||
{/* No truncate: long or multiline options wrap fully (#2177) */}
|
||||
<span className={`relative min-w-0 flex-1 whitespace-pre-wrap break-words text-[0.8125rem] [overflow-wrap:anywhere] ${myVote || isWinner ? 'font-bold' : 'font-medium'} text-m-ink`}>
|
||||
{opt.text}
|
||||
<bdi>{opt.text}</bdi>
|
||||
</span>
|
||||
{(voted || closed) && count > 0 && (
|
||||
<span className="relative flex flex-none [&>*+*]:-ms-1">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { formatMoney } from '../../../../utils/formatters'
|
||||
import { flowText } from '../../../../utils/flowText'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import MCostSheet from '../sheets/MCostSheet'
|
||||
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
|
||||
@@ -227,10 +228,10 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
<div className="mt-[6px]">
|
||||
{flows.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 border-b border-[color:var(--m-rowbr)] py-2 last:border-b-0">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(f.from.user_id)}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(f.from.user_id)}</span>
|
||||
<ArrowRight size={12} strokeWidth={2.2} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(f.to.user_id)}</span>
|
||||
<span className="ms-auto flex-none font-geist text-[0.78125rem] font-extrabold tabular-nums text-m-ink">{formatMoney(f.amount, base, locale)}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(f.to.user_id)}</span>
|
||||
<span dir="ltr" className="ms-auto flex-none font-geist text-[0.78125rem] font-extrabold tabular-nums text-m-ink">{formatMoney(f.amount, base, locale)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -245,7 +246,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
<div key={p.id} className="flex items-center gap-[9px] border-b border-[color:var(--m-rowbr)] py-[7px] last:border-b-0">
|
||||
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} variant="neutral" size={24} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<span className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums" style={{ color: pos ? STATUS_COLOR.confirmed : neg ? STATUS_COLOR.danger : 'var(--m-faint)' }}>
|
||||
<span dir="ltr" className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums" style={{ color: pos ? STATUS_COLOR.confirmed : neg ? STATUS_COLOR.danger : 'var(--m-faint)' }}>
|
||||
{pos ? '+' : neg ? '−' : ''}
|
||||
{formatMoney(Math.abs(balance), base, locale)}
|
||||
</span>
|
||||
@@ -273,8 +274,8 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
className="flex w-full items-center gap-[9px] py-[7px] text-start font-[inherit]"
|
||||
>
|
||||
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} variant="neutral" size={24} />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(p.id)}</span>
|
||||
<span className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">{formatMoney(row.final, base, locale)}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">{personName(p.id)}</span>
|
||||
<span dir="ltr" className="ms-auto flex-none font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">{formatMoney(row.final, base, locale)}</span>
|
||||
{open
|
||||
? <ChevronUp size={13} strokeWidth={2.2} className="flex-none text-m-faint" />
|
||||
: <ChevronDown size={13} strokeWidth={2.2} className="flex-none text-m-faint" />}
|
||||
@@ -300,7 +301,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
|
||||
<div className="flex items-center gap-[7px]">
|
||||
<span className="h-2 w-2 flex-none rounded-full" style={{ background: meta.color }} />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{t(meta.labelKey)}</span>
|
||||
<span className="ms-auto flex-none font-geist text-[0.65625rem] font-bold tabular-nums text-m-ink">{formatMoney(c.amount, base, locale)}</span>
|
||||
<span dir="ltr" className="ms-auto flex-none font-geist text-[0.65625rem] font-bold tabular-nums text-m-ink">{formatMoney(c.amount, base, locale)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[color:var(--m-ic)]">
|
||||
<span className="block h-full rounded-full" style={{ width: `${c.widthPct}%`, background: meta.color }} />
|
||||
@@ -603,7 +604,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate text-[0.8125rem] font-bold text-m-ink">{item.name}</span>
|
||||
<span dir="auto" className="truncate text-[0.8125rem] font-bold text-m-ink">{item.name}</span>
|
||||
{(item.receipts || []).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -620,7 +621,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
)}
|
||||
</div>
|
||||
{line && (
|
||||
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
<div dir="auto" className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
{formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
|
||||
</div>
|
||||
)}
|
||||
@@ -638,7 +639,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
}`}
|
||||
>
|
||||
<MemberAvatar name={m.username} avatarUrl={m.avatar_url} variant="accent" size={14} />
|
||||
<span className="font-geist text-[0.5625rem] font-bold tabular-nums text-m-ink">
|
||||
<span dir="ltr" className="font-geist text-[0.5625rem] font-bold tabular-nums text-m-ink">
|
||||
{formatMoney(memberShareOf(item, m.user_id, ctx), base, locale)}
|
||||
</span>
|
||||
</button>
|
||||
@@ -646,7 +647,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[11px] py-1 font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">
|
||||
<span dir="ltr" className="flex-none rounded-full bg-[color:var(--m-ic)] px-[11px] py-1 font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">
|
||||
{formatMoney(total, base, locale)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -716,7 +717,7 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
// A transfer without a currency was entered in the display currency, not the trip's.
|
||||
const amount = paymentAmount(settlement, ctx)
|
||||
const line = paymentLineOf(settlement, ctx, amount)
|
||||
const who = `${personName(settlement.from_user_id)} → ${personName(settlement.to_user_id)}`
|
||||
const who = flowText([personName(settlement.from_user_id), personName(settlement.to_user_id)])
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-[6px]">
|
||||
<div className="relative min-w-0 flex-1 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-card px-3 py-[12px]">
|
||||
@@ -728,12 +729,12 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
|
||||
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{t('costs.payment')}</div>
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-faint">{who}</div>
|
||||
{line && (
|
||||
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
<div dir="auto" className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
|
||||
{formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[11px] py-1 font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">
|
||||
<span dir="ltr" className="flex-none rounded-full bg-[color:var(--m-ic)] px-[11px] py-1 font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">
|
||||
{formatMoney(amount, base, locale)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -771,7 +772,7 @@ function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }:
|
||||
}) {
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale)
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, items)
|
||||
const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}`
|
||||
const transfer = (fromId: number, toId: number) => flowText([personName(fromId), personName(toId)])
|
||||
// Beside a converted row's name, what was entered, as the list above names it (#2525).
|
||||
const nameOf = (itemId: number, name: string, shown: number) => {
|
||||
const e = items.find(i => i.id === itemId)
|
||||
@@ -781,7 +782,7 @@ function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }:
|
||||
const line = (key: string, label: string, value: string) => (
|
||||
<div key={key} className="flex items-baseline gap-2 py-[2px] font-geist text-[0.6875rem]">
|
||||
<span className="min-w-0 flex-1 truncate text-m-muted">{label}</span>
|
||||
<span className="flex-none font-semibold tabular-nums text-m-ink">{value}</span>
|
||||
<span dir="ltr" className="flex-none font-semibold tabular-nums text-m-ink">{value}</span>
|
||||
</div>
|
||||
)
|
||||
// Capped and scrollable so a long trip's list can't push the page away.
|
||||
|
||||
@@ -97,7 +97,8 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
|
||||
aria-label={t('mobileTrip.filesPrev')}
|
||||
className="absolute left-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white/80"
|
||||
>
|
||||
<ChevronLeft size={22} strokeWidth={2} />
|
||||
{/* The buttons sit on physical sides, so their arrows stay put in Arabic. */}
|
||||
<ChevronLeft size={22} strokeWidth={2} className="rtl:scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
{fileIsVideo ? (
|
||||
@@ -124,7 +125,7 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
|
||||
aria-label={t('mobileTrip.filesNext')}
|
||||
className="absolute right-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white/80"
|
||||
>
|
||||
<ChevronRight size={22} strokeWidth={2} />
|
||||
<ChevronRight size={22} strokeWidth={2} className="rtl:scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function MFileMenuSheet({ planner, file, onClose, onOpenLinks }:
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<TypeIcon size={19} strokeWidth={1.8} style={{ color: meta.color }} />}
|
||||
title={<span className="truncate">{shown.original_name}</span>}
|
||||
title={<span dir="auto" className="truncate">{shown.original_name}</span>}
|
||||
sub={[formatSize(shown.file_size), formatFileDate(shown.created_at, locale)].filter(Boolean).join(' · ')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function MFileTrashSheet({ planner, open, onClose }: MFileTrashSh
|
||||
<TypeIcon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.78125rem] font-semibold text-m-ink">{file.original_name}</div>
|
||||
<div dir="auto" className="truncate text-[0.78125rem] font-semibold text-m-ink">{file.original_name}</div>
|
||||
<div className="mt-[2px] font-geist text-[0.625rem] text-m-faint">
|
||||
{[formatSize(file.file_size), formatFileDate(file.created_at, locale)].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
|
||||
)}
|
||||
</span>
|
||||
<span className="flex flex-1 flex-col gap-[6px] px-3 pb-[10px] pt-2">
|
||||
<span className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink">
|
||||
<span dir="auto" className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink">
|
||||
{file.original_name}
|
||||
</span>
|
||||
<span className="mt-auto flex flex-wrap items-center gap-1">
|
||||
@@ -160,7 +160,8 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
|
||||
|
||||
function Badge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted">
|
||||
// A size, a date or a name, each in its own direction ("46.1 KB" stays so in Arabic).
|
||||
<span dir="auto" className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -220,7 +221,7 @@ export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu
|
||||
|
||||
<div>
|
||||
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
|
||||
{file.original_name}
|
||||
<bdi>{file.original_name}</bdi>
|
||||
</h3>
|
||||
<div className="mt-[6px] flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
|
||||
@@ -322,7 +322,7 @@ function FileRow({ file, planner, onOpen, onStar, onMenu }: {
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{file.original_name}</div>
|
||||
<div dir="auto" className="truncate text-[0.8125rem] font-bold text-m-ink">{file.original_name}</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{isWalletPass(file.mime_type, file.original_name) && (
|
||||
@@ -333,7 +333,7 @@ function FileRow({ file, planner, onOpen, onStar, onMenu }: {
|
||||
</span>
|
||||
)}
|
||||
{!!file.file_size && (
|
||||
<span className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[0.53125rem] font-bold text-m-muted">
|
||||
<span dir="ltr" className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[0.53125rem] font-bold text-m-muted">
|
||||
{formatSize(file.file_size)}
|
||||
</span>
|
||||
)}
|
||||
@@ -345,7 +345,7 @@ function FileRow({ file, planner, onOpen, onStar, onMenu }: {
|
||||
<span className="flex h-3 w-3 flex-none items-center justify-center rounded-full bg-m-act font-geist text-[0.40625rem] font-extrabold text-m-actfg">
|
||||
{file.uploaded_by_name[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="max-w-[92px] truncate font-geist text-[0.53125rem] font-bold text-m-muted">{file.uploaded_by_name}</span>
|
||||
<span dir="auto" className="max-w-[92px] truncate font-geist text-[0.53125rem] font-bold text-m-muted">{file.uploaded_by_name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -502,7 +502,7 @@ function PackingCategoryCard({
|
||||
className="min-w-0 flex-1 border-b border-[color:var(--m-rowbr)] bg-transparent text-[0.875rem] font-bold text-m-ink outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-[0.875rem] font-bold text-m-ink">{group.category}</span>
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.875rem] font-bold text-m-ink">{group.category}</span>
|
||||
)}
|
||||
{editMode && (
|
||||
<button
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CardTitleButton({
|
||||
<TypeIcon size={10} strokeWidth={2.2} style={{ color: typeColor }} />
|
||||
{t(`reservations.type.${res.type}`)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink wide:line-clamp-2 wide:whitespace-normal">
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink wide:line-clamp-2 wide:whitespace-normal">
|
||||
{res.title}
|
||||
</span>
|
||||
{!!res.needs_review && (
|
||||
@@ -105,7 +105,7 @@ export function CardWhenAndCode({
|
||||
<Field label={t('reservations.date')} className="flex-[1.4]">
|
||||
{dayDate ? (
|
||||
<span className="flex min-w-0 items-center justify-center gap-1.5">
|
||||
<span className="min-w-0 truncate">{dayValue}</span>
|
||||
<span dir="auto" className="min-w-0 truncate">{dayValue}</span>
|
||||
<span className="flex-none font-medium text-m-faint">{dayDate}</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -166,7 +166,7 @@ export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFo
|
||||
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -175,8 +175,9 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2 text-[0.71875rem] font-semibold text-m-ink">
|
||||
{eps.map((ep, i) => (
|
||||
<span key={i} className="inline-flex min-w-0 items-center gap-2">
|
||||
{i > 0 && <TypeIcon size={12} strokeWidth={2.2} className="flex-none" style={{ color: typeColor }} />}
|
||||
<span className="truncate">{ep.name}</span>
|
||||
{/* The vehicle heads for the next stop, so it turns round with the reading direction. */}
|
||||
{i > 0 && <TypeIcon size={12} strokeWidth={2.2} className="flex-none rtl:-scale-x-100" style={{ color: typeColor }} />}
|
||||
<span dir="auto" className="truncate">{ep.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -281,7 +281,7 @@ export function TravelerAvatars({ travelers, label }: {
|
||||
<span className="flex h-[20px] w-[20px] flex-none items-center justify-center overflow-hidden rounded-full bg-m-act text-[0.5625rem] font-extrabold text-m-actfg">
|
||||
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate text-[0.71875rem] font-semibold text-m-ink">{tv.username}</span>
|
||||
<span dir="auto" className="truncate text-[0.71875rem] font-semibold text-m-ink">{tv.username}</span>
|
||||
{!!tv.is_guest && <GuestBadge size="xs" />}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Right to left (Arabic). Layout already follows the reading direction through
|
||||
* the logical utilities (ms-, ps-, start-, text-start); what is here are the
|
||||
* three things a logical utility cannot say.
|
||||
*/
|
||||
|
||||
/*
|
||||
* User text in its own direction. A name, title or address carries dir="auto",
|
||||
* so a Latin one in an Arabic row keeps its punctuation at its own end and, when
|
||||
* it is cut, loses its end rather than its beginning. Cut, clamped or typed into
|
||||
* a field, it still lines up with the row and the form: on the UI's start edge,
|
||||
* wherever its own start is (an empty field reads as left to right, so without
|
||||
* this its placeholder would jump to the far side). A free paragraph shown on
|
||||
* its own (a note, a chat message) keeps the alignment of its own language.
|
||||
* Zero specificity, so an explicit text-center or text-end on the element wins.
|
||||
*/
|
||||
:where(
|
||||
:root[dir='rtl'] [dir='auto']:dir(ltr):is(.truncate, [class*='line-clamp-'], input, textarea),
|
||||
:root[dir='ltr'] [dir='auto']:dir(rtl):is(.truncate, [class*='line-clamp-'], input, textarea)
|
||||
) {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/*
|
||||
* Arrows and chevrons that point along the reading direction (back, forward, a
|
||||
* disclosure row, a date range, a payment from one person to another) turn
|
||||
* round in a right-to-left language. The touch shell draws them all with
|
||||
* lucide, so the shell (and the sheets it portals, which carry m-root too)
|
||||
* mirrors them here instead of every icon carrying its own class. An icon that
|
||||
* names its own RTL treatment keeps it: rtl:-scale-x-100 already turns it, and
|
||||
* rtl:scale-x-100 marks an arrow that is physical on purpose. `scale` is its
|
||||
* own property, so it composes with a rotation the icon carries (an open
|
||||
* disclosure pointing down) instead of replacing it.
|
||||
*/
|
||||
:root[dir='rtl'] .m-root
|
||||
:is(
|
||||
.lucide-chevron-left,
|
||||
.lucide-chevron-right,
|
||||
.lucide-chevrons-left,
|
||||
.lucide-chevrons-right,
|
||||
.lucide-arrow-left,
|
||||
.lucide-arrow-right,
|
||||
.lucide-move-left,
|
||||
.lucide-move-right
|
||||
):not([class*='rtl:']) {
|
||||
scale: -1 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Arabic (and Hebrew) has no capitals, so the touch shell's small uppercase
|
||||
* eyebrows (field labels, stat labels, status pills) lose the weight capitals
|
||||
* give a Latin label and read as grey specks. There they grow by a fifth (about
|
||||
* 2px, padding and pill with them), drop the tracking that pulls joined letters
|
||||
* apart, and take the muted ink instead of the faint one.
|
||||
*/
|
||||
:root:is(:lang(ar), :lang(he)) .m-root .uppercase {
|
||||
zoom: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
:root:is(:lang(ar), :lang(he)) .m-root .uppercase.text-m-faint {
|
||||
color: var(--m-muted);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// FE-RTL-CSS-001 to FE-RTL-CSS-004
|
||||
//
|
||||
// styles/rtl.css carries what the logical utilities cannot say for Arabic: user
|
||||
// text that lines up with the UI in its own direction, arrows that turn round
|
||||
// in the touch shell, and Arabic's caption labels. jsdom applies no
|
||||
// stylesheet, so these read the real file.
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/styles/rtl.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
|
||||
/** The declarations of the rule whose selector text contains every given part. */
|
||||
function ruleBody(...parts: string[]): string {
|
||||
for (const chunk of css.split('}')) {
|
||||
const open = chunk.indexOf('{')
|
||||
if (open < 0) continue
|
||||
const head = chunk.slice(0, open)
|
||||
if (parts.every(part => head.includes(part))) return chunk.slice(open + 1)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
describe('rtl.css', () => {
|
||||
it('FE-RTL-CSS-001: the stylesheet is loaded with the app', () => {
|
||||
const main = readFileSync(resolve(process.cwd(), 'src/main.tsx'), 'utf8')
|
||||
expect(main).toMatch(/import '\.\/styles\/rtl\.css'/)
|
||||
})
|
||||
|
||||
it('FE-RTL-CSS-002: cut, clamped or typed user text in its own direction sits on the UI start edge', () => {
|
||||
const body = ruleBody(":root[dir='rtl'] [dir='auto']:dir(ltr)", '.truncate', "[class*='line-clamp-']", 'input', 'textarea')
|
||||
expect(body).toMatch(/text-align:\s*end/)
|
||||
// The left-to-right UI gets the mirror case: Arabic text in an English row.
|
||||
expect(css).toContain(":root[dir='ltr'] [dir='auto']:dir(rtl)")
|
||||
// Zero specificity: an explicit alignment class on the element still wins.
|
||||
expect(css).toMatch(/:where\(\s*:root\[dir='rtl'\]/)
|
||||
})
|
||||
|
||||
it('FE-RTL-CSS-003: in the touch shell every back, forward and flow arrow turns round in a right-to-left language', () => {
|
||||
const body = ruleBody(":root[dir='rtl'] .m-root", '.lucide-chevron-left', '.lucide-chevron-right', '.lucide-arrow-left', '.lucide-arrow-right')
|
||||
expect(body).toMatch(/scale:\s*-1 1/)
|
||||
// An icon that names its own treatment keeps it.
|
||||
expect(css).toContain(":not([class*='rtl:'])")
|
||||
})
|
||||
|
||||
it('FE-RTL-CSS-004: Arabic and Hebrew caption labels grow, drop the tracking and take the muted ink', () => {
|
||||
expect(ruleBody(':lang(ar)', ':lang(he)', '.m-root .uppercase')).toMatch(/zoom:\s*1\.2[\s\S]*letter-spacing:\s*0/)
|
||||
expect(ruleBody('.uppercase.text-m-faint')).toMatch(/color:\s*var\(--m-muted\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* FE-UTIL-FLOW-001 to FE-UTIL-FLOW-003
|
||||
*
|
||||
* "A → B" as one string that reads in the direction of the UI.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { flowText, isRtlDocument } from './flowText';
|
||||
|
||||
const FSI = '';
|
||||
const PDI = '';
|
||||
|
||||
describe('flowText', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.dir = '';
|
||||
});
|
||||
|
||||
it('FE-UTIL-FLOW-001: a left-to-right UI gets the plain arrow between the names', () => {
|
||||
expect(flowText(['Sam Carter', 'Ada'], false)).toBe('Sam Carter → Ada');
|
||||
expect(flowText(['Kyoto', 'Osaka', 'Nara'], false)).toBe('Kyoto → Osaka → Nara');
|
||||
});
|
||||
|
||||
it('FE-UTIL-FLOW-002: in Arabic each name is isolated and the arrow points left, from the first to the second', () => {
|
||||
expect(flowText(['Sam Carter', 'أنت'], true)).toBe(`${FSI}Sam Carter${PDI} ← ${FSI}أنت${PDI}`);
|
||||
});
|
||||
|
||||
it('FE-UTIL-FLOW-003: without an explicit direction it follows the document', () => {
|
||||
expect(isRtlDocument()).toBe(false);
|
||||
expect(flowText(['A', 'B'])).toBe('A → B');
|
||||
document.documentElement.dir = 'rtl';
|
||||
expect(isRtlDocument()).toBe(true);
|
||||
expect(flowText(['A', 'B'])).toBe(`${FSI}A${PDI} ← ${FSI}B${PDI}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* "Sam → Ada", "Kyoto → Osaka": two names with an arrow from the first to the
|
||||
* second, as one string (a row's label, an aria-label).
|
||||
*
|
||||
* In an Arabic UI the plain form reads backwards. A Latin name and an Arabic one
|
||||
* reorder around the arrow, and the arrow itself never turns, so "Sam Carter →
|
||||
* أنت" shows the arrow pointing back at Sam. There each name goes into its own
|
||||
* isolate (U+2068 … U+2069), so the pair lines up in reading order whatever
|
||||
* script the names are in, and the arrow points left, from the first name to
|
||||
* the second. A left-to-right UI gets the plain string.
|
||||
*/
|
||||
const FSI = '';
|
||||
const PDI = '';
|
||||
|
||||
export function isRtlDocument(): boolean {
|
||||
return typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
}
|
||||
|
||||
export function flowText(parts: readonly string[], rtl: boolean = isRtlDocument()): string {
|
||||
if (!rtl) return parts.join(' → ');
|
||||
return parts.map((part) => `${FSI}${part}${PDI}`).join(' ← ');
|
||||
}
|
||||
@@ -109,6 +109,18 @@ describe('MDashboard', () => {
|
||||
expect(screen.getByText('Lisbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-040: the spotlight title keeps its own direction and its progress fills from the start edge', () => {
|
||||
mocks.dash = buildDash({ spotlight: buildTrip() });
|
||||
render(<MDashboard />);
|
||||
// dir="auto": a Latin title in the Arabic UI is cut at its end, not its beginning.
|
||||
expect(screen.getAllByText('Japan 2026')[0]).toHaveAttribute('dir', 'auto');
|
||||
const fill = document.querySelector('span[style*="width"].rounded-full.bg-white') as HTMLElement;
|
||||
expect(fill).not.toBeNull();
|
||||
// start-0, not left-0: in Arabic the bar fills from the right like every other bar.
|
||||
expect(fill).toHaveClass('start-0');
|
||||
expect(fill.className).not.toMatch(/\bleft-0\b/);
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-002: archived filter swaps card actions to restore + permanent delete', () => {
|
||||
const handleUnarchive = vi.fn();
|
||||
mocks.dash = buildDash({
|
||||
|
||||
@@ -11,7 +11,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-033
|
||||
// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-035
|
||||
|
||||
const ME = 7
|
||||
|
||||
@@ -176,6 +176,18 @@ describe('MCollabChat', () => {
|
||||
expect(screen.getByText('typed').closest('.flex-col')).not.toHaveTextContent('from the assistant')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-035: a message, its quote and the composer each take their direction from their own text', async () => {
|
||||
serveMessages([
|
||||
chatMsg(1, { text: 'Who has the JR Pass vouchers?' }),
|
||||
chatMsg(2, { text: 'Me!', reply_to: 1, reply_text: 'Who has the JR Pass vouchers?', reply_username: 'Alice' }),
|
||||
])
|
||||
await renderChat()
|
||||
// In Arabic a Latin message keeps its question mark at its end.
|
||||
for (const node of screen.getAllByText('Who has the JR Pass vouchers?')) expect(node).toHaveAttribute('dir', 'auto')
|
||||
expect(screen.getByText('Me!')).toHaveAttribute('dir', 'auto')
|
||||
expect(screen.getByPlaceholderText('collab.chat.placeholder')).toHaveAttribute('dir', 'auto')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-008: shows the quoted message above a reply bubble', async () => {
|
||||
serveMessages([
|
||||
chatMsg(1, { text: 'Where do we meet?' }),
|
||||
|
||||
@@ -483,4 +483,25 @@ describe('MCollabPolls', () => {
|
||||
options: ['A', 'B'],
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-030: in Arabic the result fill grows from the start edge, and question and options keep their own direction', async () => {
|
||||
setup([poll({
|
||||
is_closed: true,
|
||||
question: 'Day trip on Saturday?',
|
||||
options: [
|
||||
{ text: 'Nara and the deer', label: 'Nara and the deer', voters: [voter(9, 'lea'), voter(10, 'tom')] },
|
||||
{ text: 'Kurama hike', label: 'Kurama hike', voters: [voter(11, 'ada')] },
|
||||
],
|
||||
})])
|
||||
const question = await screen.findByText('Day trip on Saturday?')
|
||||
// The paragraph is isolated, so its question mark stays at its end in a right-to-left page.
|
||||
expect(question.tagName).toBe('BDI')
|
||||
expect(screen.getByText('Nara and the deer').tagName).toBe('BDI')
|
||||
const option = screen.getByRole('button', { name: /Nara and the deer/ })
|
||||
const fill = option.querySelector('span[style*="width"]') as HTMLElement
|
||||
expect(fill.style.width).toBe('67%')
|
||||
// Logical, not left-0: in Arabic the bar fills from the right, under the option's radio.
|
||||
expect(fill).toHaveClass('start-0')
|
||||
expect(fill.className).not.toMatch(/\bleft-0\b/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -945,4 +945,27 @@ describe('MCostsTab', () => {
|
||||
expect(within(expenses).getByPlaceholderText('costs.searchPlaceholder')).toBeInTheDocument()
|
||||
expect(within(expenses).getByRole('heading', { name: 'costs.expenses' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-055: in Arabic a signed balance and an amount read as one left-to-right run, and a payment names who paid whom in reading order', async () => {
|
||||
const payment = { id: 503, from_user_id: 3, to_user_id: 1, amount: 15, currency: 'USD', created_at: '2026-06-15T09:00:00Z' }
|
||||
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
|
||||
// The page reads right to left for the whole render (the translation provider
|
||||
// would otherwise set it back to the test's English).
|
||||
Object.defineProperty(document.documentElement, 'dir', { configurable: true, get: () => 'rtl', set: () => {} })
|
||||
try {
|
||||
await renderTab()
|
||||
const settle = up(screen.getByText('costs.settleUp'), 3)
|
||||
// The sign stays in front of the figure instead of trailing it.
|
||||
expect(within(settle).getByText('+$12.50')).toHaveAttribute('dir', 'ltr')
|
||||
expect(within(settle).getByText('−$7.25')).toHaveAttribute('dir', 'ltr')
|
||||
expect(within(settle).getByText('$40.00')).toHaveAttribute('dir', 'ltr')
|
||||
// Names keep their own direction, so a Latin one loses its end when cut.
|
||||
expect(within(settle).getByText('Ghost')).toHaveAttribute('dir', 'auto')
|
||||
// Each name isolated, the arrow pointing left from the payer to the payee.
|
||||
expect(screen.getByText('\u2068Bob\u2069 ← \u2068costs.you\u2069')).toBeInTheDocument()
|
||||
} finally {
|
||||
// Back to the prototype's reflected attribute.
|
||||
delete (document.documentElement as { dir?: string }).dir
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { setSizeClass } from '../../../helpers/sizeClass'
|
||||
import { fileRepo } from '../../../../src/repo/fileRepo'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-FTAB-001 to FE-MOB-FTAB-027
|
||||
// FE-MOB-FTAB-001 to FE-MOB-FTAB-028
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({
|
||||
downloadFile: vi.fn(async () => undefined),
|
||||
@@ -189,6 +189,13 @@ describe('MFilesTab', () => {
|
||||
expect(screen.getByText('files.sourceCollab')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-028: in Arabic a size reads "2.0 KB", and names keep their own direction', () => {
|
||||
renderTab()
|
||||
expect(screen.getByText('2.0 KB')).toHaveAttribute('dir', 'ltr')
|
||||
expect(screen.getByText('ada')).toHaveAttribute('dir', 'auto')
|
||||
for (const node of screen.queryAllByText(NAME_RE)) expect(node).toHaveAttribute('dir', 'auto')
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-008: starring calls the api and refreshes the store', async () => {
|
||||
const { planner } = renderTab()
|
||||
expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user