Skip to content

Commit 01bdb66

Browse files
feat(Popper): add dir prop for RTL/LTR support (#2610)
* feat(Popper): add dir prop for `RTL/LTR` support * chore: up * chore: up * chore: up --------- Co-authored-by: malik jouda <m.jouda@approved.tech>
1 parent ac3358f commit 01bdb66

4 files changed

Lines changed: 184 additions & 8 deletions

File tree

packages/core/src/Popper/Popper.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { VueWrapper } from '@vue/test-utils'
22
import { mount } from '@vue/test-utils'
33
import { beforeEach, describe, expect, it } from 'vitest'
4+
import { defineComponent, h } from 'vue'
45
import Popper from './_Popper.vue'
6+
import PopperAnchor from './PopperAnchor.vue'
7+
import PopperContent from './PopperContent.vue'
8+
import PopperRoot from './PopperRoot.vue'
9+
import { transformOrigin } from './utils'
510

611
describe('give default Popper', async () => {
712
let wrapper: VueWrapper<InstanceType<typeof Popper>>
@@ -19,3 +24,152 @@ describe('give default Popper', async () => {
1924
expect(wrapper.element).toMatchSnapshot()
2025
})
2126
})
27+
28+
describe('transformOrigin direction-aware logic', () => {
29+
async function run(
30+
options: Parameters<typeof transformOrigin>[0],
31+
placement: string,
32+
arrowData?: { x?: number, y?: number, centerOffset?: number },
33+
) {
34+
const result = await transformOrigin(options).fn({
35+
placement,
36+
rects: { floating: { width: 100, height: 50 } },
37+
middlewareData: { arrow: arrowData },
38+
} as any)
39+
return result.data as { x: string, y: string }
40+
}
41+
42+
const noArrow = { arrowWidth: 0, arrowHeight: 0 }
43+
44+
describe('bottom placement', () => {
45+
it('rTL flips start origin to 100%', async () => {
46+
expect((await run({ ...noArrow, dir: 'rtl' }, 'bottom-start')).x).toBe('100%')
47+
})
48+
it('lTR keeps start origin at 0%', async () => {
49+
expect((await run({ ...noArrow, dir: 'ltr' }, 'bottom-start')).x).toBe('0%')
50+
})
51+
it('rTL flips end origin to 0%', async () => {
52+
expect((await run({ ...noArrow, dir: 'rtl' }, 'bottom-end')).x).toBe('0%')
53+
})
54+
it('lTR keeps end origin at 100%', async () => {
55+
expect((await run({ ...noArrow, dir: 'ltr' }, 'bottom-end')).x).toBe('100%')
56+
})
57+
it('center is unaffected by dir', async () => {
58+
expect((await run({ ...noArrow, dir: 'rtl' }, 'bottom')).x).toBe('50%')
59+
expect((await run({ ...noArrow, dir: 'ltr' }, 'bottom')).x).toBe('50%')
60+
})
61+
})
62+
63+
describe('top placement', () => {
64+
it('rTL flips start origin to 100%', async () => {
65+
expect((await run({ ...noArrow, dir: 'rtl' }, 'top-start')).x).toBe('100%')
66+
})
67+
it('lTR keeps start origin at 0%', async () => {
68+
expect((await run({ ...noArrow, dir: 'ltr' }, 'top-start')).x).toBe('0%')
69+
})
70+
it('rTL flips end origin to 0%', async () => {
71+
expect((await run({ ...noArrow, dir: 'rtl' }, 'top-end')).x).toBe('0%')
72+
})
73+
it('lTR keeps end origin at 100%', async () => {
74+
expect((await run({ ...noArrow, dir: 'ltr' }, 'top-end')).x).toBe('100%')
75+
})
76+
})
77+
78+
describe('left/right placements use Y-axis alignment, unaffected by dir', () => {
79+
it('right-start: y origin is 0% for both RTL and LTR', async () => {
80+
expect((await run({ ...noArrow, dir: 'rtl' }, 'right-start')).y).toBe('0%')
81+
expect((await run({ ...noArrow, dir: 'ltr' }, 'right-start')).y).toBe('0%')
82+
})
83+
it('right-end: y origin is 100% for both RTL and LTR', async () => {
84+
expect((await run({ ...noArrow, dir: 'rtl' }, 'right-end')).y).toBe('100%')
85+
expect((await run({ ...noArrow, dir: 'ltr' }, 'right-end')).y).toBe('100%')
86+
})
87+
it('left-start: y origin is 0% for both RTL and LTR', async () => {
88+
expect((await run({ ...noArrow, dir: 'rtl' }, 'left-start')).y).toBe('0%')
89+
expect((await run({ ...noArrow, dir: 'ltr' }, 'left-start')).y).toBe('0%')
90+
})
91+
it('left-end: y origin is 100% for both RTL and LTR', async () => {
92+
expect((await run({ ...noArrow, dir: 'rtl' }, 'left-end')).y).toBe('100%')
93+
expect((await run({ ...noArrow, dir: 'ltr' }, 'left-end')).y).toBe('100%')
94+
})
95+
})
96+
97+
describe('default dir (undefined)', () => {
98+
it('bottom placement defaults to LTR: start → 0%, end → 100%', async () => {
99+
expect((await run(noArrow, 'bottom-start')).x).toBe('0%')
100+
expect((await run(noArrow, 'bottom-end')).x).toBe('100%')
101+
})
102+
it('top placement defaults to LTR: start → 0%, end → 100%', async () => {
103+
expect((await run(noArrow, 'top-start')).x).toBe('0%')
104+
expect((await run(noArrow, 'top-end')).x).toBe('100%')
105+
})
106+
})
107+
108+
describe('arrow-visible scenario: arrow-based x/y positioning is not affected by dir', () => {
109+
// arrowXCenter = arrowData.x (20) + arrowWidth (10) / 2 = 25
110+
// arrowYCenter = arrowData.y (15) + arrowHeight (5) / 2 = 17.5
111+
const withArrow = { arrowWidth: 10, arrowHeight: 5 }
112+
const arrowData = { x: 20, y: 15, centerOffset: 0 }
113+
114+
it('bottom placement uses arrow x center regardless of dir', async () => {
115+
expect((await run({ ...withArrow, dir: 'rtl' }, 'bottom-start', arrowData)).x).toBe('25px')
116+
expect((await run({ ...withArrow, dir: 'ltr' }, 'bottom-start', arrowData)).x).toBe('25px')
117+
})
118+
it('top placement uses arrow x center regardless of dir', async () => {
119+
expect((await run({ ...withArrow, dir: 'rtl' }, 'top-end', arrowData)).x).toBe('25px')
120+
expect((await run({ ...withArrow, dir: 'ltr' }, 'top-end', arrowData)).x).toBe('25px')
121+
})
122+
it('right placement uses arrow y center regardless of dir', async () => {
123+
expect((await run({ ...withArrow, dir: 'rtl' }, 'right-start', arrowData)).y).toBe('17.5px')
124+
expect((await run({ ...withArrow, dir: 'ltr' }, 'right-start', arrowData)).y).toBe('17.5px')
125+
})
126+
it('left placement uses arrow y center regardless of dir', async () => {
127+
expect((await run({ ...withArrow, dir: 'rtl' }, 'left-end', arrowData)).y).toBe('17.5px')
128+
expect((await run({ ...withArrow, dir: 'ltr' }, 'left-end', arrowData)).y).toBe('17.5px')
129+
})
130+
})
131+
})
132+
133+
describe('popper wrapper dir attribute', () => {
134+
globalThis.ResizeObserver = class ResizeObserver {
135+
observe() {}
136+
unobserve() {}
137+
disconnect() {}
138+
}
139+
140+
it('sets dir on the floatingRef wrapper so placement and origin are consistent', async () => {
141+
const RtlPopper = defineComponent({
142+
setup() {
143+
return () =>
144+
h(PopperRoot, null, {
145+
default: () => [
146+
h(PopperAnchor, null, { default: () => 'Anchor' }),
147+
h(PopperContent, { dir: 'rtl' }, { default: () => 'Content' }),
148+
],
149+
})
150+
},
151+
})
152+
153+
const wrapper = mount(RtlPopper, { attachTo: document.body })
154+
const wrapperDiv = wrapper.find('[data-reka-popper-content-wrapper]')
155+
expect(wrapperDiv.attributes('dir')).toBe('rtl')
156+
})
157+
158+
it('sets dir=ltr on the wrapper when explicitly passed', async () => {
159+
const LtrPopper = defineComponent({
160+
setup() {
161+
return () =>
162+
h(PopperRoot, null, {
163+
default: () => [
164+
h(PopperAnchor, null, { default: () => 'Anchor' }),
165+
h(PopperContent, { dir: 'ltr' }, { default: () => 'Content' }),
166+
],
167+
})
168+
},
169+
})
170+
171+
const wrapper = mount(LtrPopper, { attachTo: document.body })
172+
const wrapperDiv = wrapper.find('[data-reka-popper-content-wrapper]')
173+
expect(wrapperDiv.attributes('dir')).toBe('ltr')
174+
})
175+
})

packages/core/src/Popper/PopperContent.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import type {
1010
Side,
1111
} from './utils'
1212
import type { PrimitiveProps } from '@/Primitive'
13-
import { createContext, useForwardExpose, useSize } from '@/shared'
13+
import type { Direction } from '@/shared/types'
14+
import { createContext, useDirection, useForwardExpose, useSize } from '@/shared'
1415
1516
export const PopperContentPropsDefaultValue = {
1617
side: 'bottom' as Side,
@@ -178,6 +179,11 @@ export interface PopperContentProps extends PrimitiveProps {
178179
* If provided, it will replace the default anchor element.
179180
*/
180181
reference?: ReferenceElement
182+
183+
/**
184+
* The reading direction of the popper content when applicable. <br> If omitted, inherits globally from `ConfigProvider` or assumes LTR (left-to-right) reading mode.
185+
*/
186+
dir?: Direction
181187
}
182188
183189
export interface PopperContentContext {
@@ -228,6 +234,7 @@ const emits = defineEmits<{
228234
229235
const rootContext = injectPopperRootContext()
230236
const { forwardRef, currentElement: contentElement } = useForwardExpose()
237+
const dir = useDirection(computed(() => props.dir))
231238
232239
const floatingRef = ref<HTMLElement>()
233240
@@ -321,6 +328,7 @@ const computedMiddleware = computed(() => {
321328
transformOrigin({
322329
arrowWidth: arrowWidth.value,
323330
arrowHeight: arrowHeight.value,
331+
dir: dir.value,
324332
}),
325333
props.hideWhenDetached
326334
&& hide({ strategy: 'referenceHidden', ...detectOverflowOptions.value }),
@@ -386,6 +394,7 @@ providePopperContentContext({
386394
<div
387395
ref="floatingRef"
388396
data-reka-popper-content-wrapper=""
397+
:dir="dir"
389398
:style="{
390399
...floatingStyles,
391400
transform: isPositioned ? floatingStyles.transform : 'translate(0, -200%)', // keep off the page when measuring
@@ -439,6 +448,7 @@ providePopperContentContext({
439448
:as="props.as"
440449
:data-side="placedSide"
441450
:data-align="placedAlign"
451+
:dir="dir"
442452
:style="{
443453
// if the PopperContent hasn't been placed yet (not all measurements done)
444454
// we prevent animations so that users's animation don't kick in too early referring wrong sides

packages/core/src/Popper/__snapshots__/Popper.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ exports[`give default Popper > should render correctly and match snapshot 1`] =
1212
</div>
1313
<div
1414
data-reka-popper-content-wrapper=""
15+
dir="ltr"
1516
style="position: fixed; left: 0px; top: 0px; transform: translate(0, -200%); min-width: max-content;"
1617
>
1718
<div
1819
data-align="center"
1920
data-side="bottom"
21+
dir="ltr"
2022
style="animation: none;"
2123
>
2224

packages/core/src/Popper/utils.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Middleware, Placement } from '@floating-ui/vue'
2+
import type { Direction } from '@/shared/types'
23

34
const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const
45
const ALIGN_OPTIONS = ['start', 'center', 'end'] as const
@@ -13,6 +14,7 @@ export function isNotNull<T>(value: T | null): value is T {
1314
export function transformOrigin(options: {
1415
arrowWidth: number
1516
arrowHeight: number
17+
dir?: Direction
1618
}): Middleware {
1719
return {
1820
name: 'transformOrigin',
@@ -26,9 +28,17 @@ export function transformOrigin(options: {
2628
const arrowHeight = isArrowHidden ? 0 : options.arrowHeight
2729

2830
const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement)
29-
const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[
30-
placedAlign
31-
]
31+
const noArrowAlignX = {
32+
start: options.dir === 'rtl' ? '100%' : '0%',
33+
center: '50%',
34+
end: options.dir === 'rtl' ? '0%' : '100%',
35+
}[placedAlign]
36+
37+
const noArrowAlignY = {
38+
start: '0%',
39+
center: '50%',
40+
end: '100%',
41+
}[placedAlign]
3242

3343
const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2
3444
const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2
@@ -37,20 +47,20 @@ export function transformOrigin(options: {
3747
let y = ''
3848

3949
if (placedSide === 'bottom') {
40-
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
50+
x = isArrowHidden ? noArrowAlignX : `${arrowXCenter}px`
4151
y = `${-arrowHeight}px`
4252
}
4353
else if (placedSide === 'top') {
44-
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
54+
x = isArrowHidden ? noArrowAlignX : `${arrowXCenter}px`
4555
y = `${rects.floating.height + arrowHeight}px`
4656
}
4757
else if (placedSide === 'right') {
4858
x = `${-arrowHeight}px`
49-
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
59+
y = isArrowHidden ? noArrowAlignY : `${arrowYCenter}px`
5060
}
5161
else if (placedSide === 'left') {
5262
x = `${rects.floating.width + arrowHeight}px`
53-
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
63+
y = isArrowHidden ? noArrowAlignY : `${arrowYCenter}px`
5464
}
5565
return { data: { x, y } }
5666
},

0 commit comments

Comments
 (0)