Skip to content

Commit bad887b

Browse files
authored
feat(theming): add arrow/menu position and floating placement utilities (#1704)
- getFloatingPlacements() - getMenuPosition() - getArrowPosition()
1 parent b3bbd98 commit bad887b

File tree

10 files changed

+1908
-1720
lines changed

10 files changed

+1908
-1720
lines changed

package-lock.json

Lines changed: 1638 additions & 1719 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/theming/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"sideEffects": false,
2222
"types": "dist/typings/index.d.ts",
2323
"dependencies": {
24+
"@floating-ui/react-dom": "^2.0.0",
2425
"@zendeskgarden/container-focusvisible": "^2.0.0",
2526
"@zendeskgarden/container-utilities": "^2.0.0",
2627
"lodash.memoize": "^4.1.2",

packages/theming/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ export {
1313
/** `retrieveTheme` is a deprecated usage for v7 compatability */
1414
default as retrieveTheme
1515
} from './utils/retrieveComponentStyles';
16+
export { getArrowPosition } from './utils/getArrowPosition';
1617
export { getColor } from './utils/getColor';
18+
export { getFloatingPlacements } from './utils/getFloatingPlacements';
1719
export { getFocusBoxShadow } from './utils/getFocusBoxShadow';
1820
export { default as getLineHeight } from './utils/getLineHeight';
21+
export { getMenuPosition } from './utils/getMenuPosition';
1922
export { default as mediaQuery } from './utils/mediaQuery';
2023
export { default as arrowStyles } from './utils/arrowStyles';
2124
export { useDocument } from './utils/useDocument';
@@ -27,8 +30,10 @@ export { focusStyles, SELECTOR_FOCUS_VISIBLE } from './utils/focusStyles';
2730
export {
2831
ARROW_POSITION as ARRAY_ARROW_POSITION,
2932
MENU_POSITION as ARRAY_MENU_POSITION,
33+
PLACEMENT,
3034
type IGardenTheme,
3135
type IThemeProviderProps,
3236
type ArrowPosition as ARROW_POSITION,
33-
type MenuPosition as MENU_POSITION
37+
type MenuPosition as MENU_POSITION,
38+
type Placement
3439
} from './types';

packages/theming/src/types/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ export const MENU_POSITION = ['top', 'right', 'bottom', 'left'] as const;
2828

2929
export type MenuPosition = (typeof MENU_POSITION)[number];
3030

31+
export const PLACEMENT = [
32+
'top',
33+
'top-start',
34+
'top-end',
35+
'bottom',
36+
'bottom-start',
37+
'bottom-end',
38+
'end',
39+
'end-top',
40+
'end-bottom',
41+
'start',
42+
'start-top',
43+
'start-bottom'
44+
] as const;
45+
46+
export type Placement = (typeof PLACEMENT)[number];
47+
3148
type Hue = Record<number | string, string> | string;
3249

3350
export interface IGardenTheme {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import { Placement } from '@floating-ui/react-dom';
9+
import { POSITION_MAP, getArrowPosition } from './getArrowPosition';
10+
import { ArrowPosition } from '../types';
11+
12+
describe('getArrowPosition', () => {
13+
it.each<[Placement, ArrowPosition]>(Object.entries(POSITION_MAP) as [Placement, ArrowPosition][])(
14+
'converts "%s" to "%s"',
15+
(placement, expected) => {
16+
const position = getArrowPosition(placement);
17+
18+
expect(position).toBe(expected);
19+
}
20+
);
21+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import { Placement } from '@floating-ui/react-dom';
9+
import { ArrowPosition } from '../types';
10+
11+
export const POSITION_MAP: Record<Placement, ArrowPosition> = {
12+
top: 'bottom',
13+
'top-start': 'bottom-left',
14+
'top-end': 'bottom-right',
15+
right: 'left',
16+
'right-start': 'left-top',
17+
'right-end': 'left-bottom',
18+
bottom: 'top',
19+
'bottom-start': 'top-left',
20+
'bottom-end': 'top-right',
21+
left: 'right',
22+
'left-start': 'right-top',
23+
'left-end': 'right-bottom'
24+
};
25+
26+
/**
27+
* Convert Floating-UI placement to a valid `arrowStyles` position.
28+
*
29+
* @param {string} placement A Floating-UI placement.
30+
*
31+
* @returns An `arrowStyles` position.
32+
*/
33+
export const getArrowPosition = (placement: Placement): ArrowPosition => {
34+
return POSITION_MAP[placement];
35+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import { Placement as FloatingPlacement } from '@floating-ui/react-dom';
9+
import { PLACEMENT, Placement } from '../types';
10+
import { PLACEMENT_MAP, RTL_PLACEMENT_MAP, getFloatingPlacements } from './getFloatingPlacements';
11+
import DEFAULT_THEME from '../elements/theme';
12+
13+
describe('getFloatingPlacements', () => {
14+
it.each<[Placement, FloatingPlacement]>(
15+
PLACEMENT.map(placement => [placement, PLACEMENT_MAP[placement] || placement])
16+
)('converts Garden "%s" to Floating-UI "%s"', (placement, expected) => {
17+
const [floatingPlacement, fallbackPlacements] = getFloatingPlacements(DEFAULT_THEME, placement);
18+
19+
expect(floatingPlacement).toBe(expected);
20+
expect(fallbackPlacements).not.toContain(floatingPlacement);
21+
});
22+
23+
it.each<[Placement, FloatingPlacement]>(
24+
PLACEMENT.map(placement => [
25+
placement,
26+
RTL_PLACEMENT_MAP[PLACEMENT_MAP[placement] || placement] ||
27+
PLACEMENT_MAP[placement] ||
28+
placement
29+
])
30+
)('converts RTL Garden "%s" to Floating-UI "%s"', (placement, expected) => {
31+
const theme = { ...DEFAULT_THEME, rtl: true };
32+
const [floatingPlacement, fallbackPlacements] = getFloatingPlacements(theme, placement);
33+
34+
expect(floatingPlacement).toBe(expected);
35+
expect(fallbackPlacements).not.toContain(floatingPlacement);
36+
});
37+
38+
it('handles fallbacks as expected', () => {
39+
const fallbacks = ['top-start', 'top', 'top-end'] as Placement[];
40+
const placements = getFloatingPlacements(DEFAULT_THEME, 'bottom', fallbacks);
41+
42+
expect(placements[1]).toStrictEqual(fallbacks);
43+
});
44+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import { Placement as FloatingPlacement } from '@floating-ui/react-dom';
9+
import { IGardenTheme, MenuPosition, Placement } from '../types';
10+
11+
/* Map Garden to Floating-UI placements */
12+
export const PLACEMENT_MAP: Record<string, FloatingPlacement> = {
13+
end: 'right',
14+
'end-top': 'right-start',
15+
'end-bottom': 'right-end',
16+
start: 'left',
17+
'start-top': 'left-start',
18+
'start-bottom': 'left-end'
19+
};
20+
21+
/* Map Floating-UI to RTL placements */
22+
export const RTL_PLACEMENT_MAP: Record<string, FloatingPlacement> = {
23+
left: 'right',
24+
'left-start': 'right-start',
25+
'left-end': 'right-end',
26+
'top-start': 'top-end',
27+
'top-end': 'top-start',
28+
right: 'left',
29+
'right-start': 'left-start',
30+
'right-end': 'left-end',
31+
'bottom-start': 'bottom-end',
32+
'bottom-end': 'bottom-start'
33+
};
34+
35+
const toFloatingPlacement = (placement: Placement, theme: IGardenTheme): FloatingPlacement => {
36+
let retVal = PLACEMENT_MAP[placement] || placement;
37+
38+
if (theme.rtl) {
39+
retVal = RTL_PLACEMENT_MAP[retVal] || retVal;
40+
}
41+
42+
return retVal;
43+
};
44+
45+
/* Map Floating-UI side placement to fallbacks */
46+
const SIDE_FALLBACKS_MAP: Record<string, FloatingPlacement[]> = {
47+
top: ['top-start', 'top', 'top-end'],
48+
right: ['right-start', 'right', 'right-end'],
49+
bottom: ['bottom-start', 'bottom', 'bottom-end'],
50+
left: ['left-start', 'left', 'left-end']
51+
};
52+
53+
/* Map Floating-UI side placement to opposite side */
54+
const SIDE_OPPOSITE_MAP: Record<string, MenuPosition> = {
55+
top: 'bottom',
56+
right: 'left',
57+
bottom: 'top',
58+
left: 'right'
59+
};
60+
61+
const toFallbackPlacements = (
62+
primaryPlacement: FloatingPlacement,
63+
theme: IGardenTheme,
64+
fallbackPlacements?: Placement[]
65+
): FloatingPlacement[] => {
66+
if (Array.isArray(fallbackPlacements) && fallbackPlacements.length > 0) {
67+
return fallbackPlacements.map(fallbackPlacement =>
68+
toFloatingPlacement(fallbackPlacement, theme)
69+
);
70+
}
71+
72+
const side = primaryPlacement.split('-')[0];
73+
const sameSideFallbackPlacements = [...SIDE_FALLBACKS_MAP[side]];
74+
const oppositeSideFallbackPlacements = SIDE_FALLBACKS_MAP[SIDE_OPPOSITE_MAP[side]];
75+
76+
// Remove the primary placement from the list of same-side fallbacks to
77+
// prevent extra work for Floating-UI
78+
sameSideFallbackPlacements.splice(sameSideFallbackPlacements.indexOf(primaryPlacement), 1);
79+
80+
return [...sameSideFallbackPlacements, ...oppositeSideFallbackPlacements];
81+
};
82+
83+
/**
84+
* Convert Garden placements to valid placements for Floating-UI.
85+
*
86+
* @param {Object} theme Context `theme` object used to determine if layout is right-to-left.
87+
* @param {string} placement A Garden placement.
88+
* @param {Array} fallbackPlacements Optional list of Garden fallback placements.
89+
*
90+
* @returns A Floating-UI (placement, fallbackPlacements) tuple.
91+
*/
92+
export const getFloatingPlacements = (
93+
theme: IGardenTheme,
94+
placement: Placement,
95+
fallbackPlacements?: Placement[]
96+
): [FloatingPlacement, FloatingPlacement[]] => {
97+
const floatingPlacement = toFloatingPlacement(placement, theme);
98+
const floatingFallbackPlacements = toFallbackPlacements(
99+
floatingPlacement,
100+
theme,
101+
fallbackPlacements
102+
);
103+
104+
return [floatingPlacement, floatingFallbackPlacements];
105+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import DEFAULT_THEME from '../elements/theme';
9+
import { PLACEMENT } from '../types';
10+
import { getFloatingPlacements } from './getFloatingPlacements';
11+
import { getMenuPosition } from './getMenuPosition';
12+
13+
describe('getMenuPosition', () => {
14+
it.each(PLACEMENT)('converts "%s" as expected', placement => {
15+
const placements = getFloatingPlacements(DEFAULT_THEME, placement);
16+
const position = getMenuPosition(placements[0]);
17+
const expected = placements[0].split('-')[0];
18+
19+
expect(position).toBe(expected);
20+
});
21+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright Zendesk, Inc.
3+
*
4+
* Use of this source code is governed under the Apache License, Version 2.0
5+
* found at http://www.apache.org/licenses/LICENSE-2.0.
6+
*/
7+
8+
import { Placement } from '@floating-ui/react-dom';
9+
import { MenuPosition } from '../types';
10+
11+
/**
12+
* Convert Floating-UI placement to a valid `menuStyles` position.
13+
*
14+
* @param {string} placement A Floating-UI placement.
15+
*
16+
* @returns A `menuStyles` position.
17+
*/
18+
export const getMenuPosition = (placement: Placement): MenuPosition => {
19+
return placement.split('-')[0] as MenuPosition;
20+
};

0 commit comments

Comments
 (0)