Skip to content

Commit 4368b78

Browse files
feat: add top-start/end and bottom-start/end logical position aliases
1 parent 45d8940 commit 4368b78

File tree

5 files changed

+67
-7
lines changed

5 files changed

+67
-7
lines changed

src/index.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { toast, ToastState } from './state';
99
import './styles.css';
1010
import {
1111
isAction,
12+
Position,
1213
SwipeDirection,
1314
type ExternalToast,
1415
type HeightT,
@@ -42,6 +43,25 @@ const SWIPE_THRESHOLD = 45;
4243
// Equal to exit animation duration
4344
const TIME_BEFORE_UNMOUNT = 200;
4445

46+
// Resolve logical positions to physical ones based on text direction
47+
function resolvePosition(position: Position, dir: 'ltr' | 'rtl' | 'auto'): Position {
48+
const resolvedDir = dir === 'auto' ? getDocumentDirection() : dir;
49+
const isRTL = resolvedDir === 'rtl';
50+
51+
switch (position) {
52+
case 'top-start':
53+
return isRTL ? 'top-right' : 'top-left';
54+
case 'top-end':
55+
return isRTL ? 'top-left' : 'top-right';
56+
case 'bottom-start':
57+
return isRTL ? 'bottom-right' : 'bottom-left';
58+
case 'bottom-end':
59+
return isRTL ? 'bottom-left' : 'bottom-right';
60+
default:
61+
return position;
62+
}
63+
}
64+
4565
function cn(...classes: (string | undefined)[]) {
4666
return classes.filter(Boolean).join(' ');
4767
}
@@ -621,10 +641,15 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
621641
return toasts.filter((toast) => !toast.toasterId);
622642
}, [toasts, id]);
623643
const possiblePositions = React.useMemo(() => {
644+
const resolved = resolvePosition(position, dir);
624645
return Array.from(
625-
new Set([position].concat(filteredToasts.filter((toast) => toast.position).map((toast) => toast.position))),
646+
new Set(
647+
[resolved].concat(
648+
filteredToasts.filter((toast) => toast.position).map((toast) => resolvePosition(toast.position, dir)),
649+
),
650+
),
626651
);
627-
}, [filteredToasts, position]);
652+
}, [filteredToasts, position, dir]);
628653
const [heights, setHeights] = React.useState<HeightT[]>([]);
629654
const [expanded, setExpanded] = React.useState(false);
630655
const [interacting, setInteracting] = React.useState(false);
@@ -783,7 +808,10 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
783808
data-react-aria-top-layer
784809
>
785810
{possiblePositions.map((position, index) => {
786-
const [y, x] = position.split('-');
811+
console.log('position', position);
812+
console.log('dir', dir);
813+
const resolvedPos = resolvePosition(position, dir === 'auto' ? getDocumentDirection() : dir);
814+
const [y, x] = resolvedPos.split('-');
787815

788816
if (!filteredToasts.length) return null;
789817

@@ -861,7 +889,7 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
861889
visibleToasts={visibleToasts}
862890
closeButton={toastOptions?.closeButton ?? closeButton}
863891
interacting={interacting}
864-
position={position}
892+
position={resolvedPos}
865893
style={toastOptions?.style}
866894
unstyled={toastOptions?.unstyled}
867895
classNames={toastOptions?.classNames}

src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,17 @@ export function isAction(action: Action | React.ReactNode): action is Action {
9494
return (action as Action).label !== undefined;
9595
}
9696

97-
export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center';
97+
export type Position =
98+
| 'top-left'
99+
| 'top-right'
100+
| 'bottom-left'
101+
| 'bottom-right'
102+
| 'top-center'
103+
| 'bottom-center'
104+
| 'top-start'
105+
| 'bottom-start'
106+
| 'top-end'
107+
| 'bottom-end';
98108
export interface HeightT {
99109
height: number;
100110
toastId: number | string;

test/src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const metadata = {
55

66
export default function RootLayout({ children }: { children: React.ReactNode }) {
77
return (
8-
<html lang="en">
8+
<html lang="en" dir="rtl">
99
<body>{children}</body>
1010
</html>
1111
);

test/tests/basic.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,4 +337,15 @@ test.describe('Basic functionality', () => {
337337
await expect(page.getByTestId('promise-test-toast')).toHaveText('Loading...');
338338
await expect(page.getByTestId('promise-test-toast')).toHaveText('Loaded');
339339
});
340+
341+
test('top-start resolves to top-left in LTR', async ({ page }) => {
342+
const toaster = page.locator('[data-sonner-toaster]');
343+
await expect(toaster).toHaveAttribute('data-y-position', 'top');
344+
await expect(toaster).toHaveAttribute('data-x-position', 'left');
345+
});
346+
347+
test('top-start resolves to top-right in RTL', async ({ page }) => {
348+
const toaster = page.locator('[data-sonner-toaster]');
349+
await expect(toaster).toHaveAttribute('data-x-position', 'right');
350+
});
340351
});

website/src/components/Position/index.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@ import { toast, useSonner } from 'sonner';
22
import { CodeBlock } from '../CodeBlock';
33
import React from 'react';
44

5-
const positions = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const;
5+
const positions = [
6+
'top-left',
7+
'top-center',
8+
'top-right',
9+
'bottom-left',
10+
'bottom-center',
11+
'bottom-right',
12+
'top-start',
13+
'bottom-start',
14+
'top-end',
15+
'bottom-end',
16+
] as const;
617

718
export type Position = (typeof positions)[number];
819

0 commit comments

Comments
 (0)