@usequeek/theme-kit 0.1.22 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -5
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +39 -8
- package/package.json +1 -1
- package/tests/fixtures/external-theme.tsx +7 -2
- package/tests/language-menu.test.tsx +598 -0
- package/tests/menu-placement.test.ts +63 -0
- package/utils/menu-placement.ts +91 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover collision handling for the language menu — pure (no DOM reads, no
|
|
3
|
+
* React) so it is unit-testable with plain numbers.
|
|
4
|
+
*
|
|
5
|
+
* The menu is positioned by CSS (under or over its trigger, aligned to the
|
|
6
|
+
* trigger's inline-start or inline-end edge). This module only decides
|
|
7
|
+
* whether that preferred placement fits the viewport, and if not, which of
|
|
8
|
+
* the alternatives to use: flip to the other side, flip the alignment, then
|
|
9
|
+
* shift horizontally to keep a gutter.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type MenuSide = 'bottom' | 'top';
|
|
13
|
+
export type MenuAlign = 'start' | 'end';
|
|
14
|
+
|
|
15
|
+
export interface Box {
|
|
16
|
+
top: number;
|
|
17
|
+
right: number;
|
|
18
|
+
bottom: number;
|
|
19
|
+
left: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlacementInput {
|
|
23
|
+
trigger: Box;
|
|
24
|
+
/** The popover's size — independent of where it is placed. */
|
|
25
|
+
popover: { width: number; height: number };
|
|
26
|
+
viewport: { width: number; height: number };
|
|
27
|
+
side: MenuSide;
|
|
28
|
+
align: MenuAlign;
|
|
29
|
+
/** Page direction: `'end'` is the right edge in `ltr` and the left edge in `rtl`. */
|
|
30
|
+
dir: 'ltr' | 'rtl';
|
|
31
|
+
/** Space kept between the popover and the viewport edge. */
|
|
32
|
+
gutter?: number;
|
|
33
|
+
/** Gap between the trigger and the popover. */
|
|
34
|
+
offset?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Placement {
|
|
38
|
+
side: MenuSide;
|
|
39
|
+
align: MenuAlign;
|
|
40
|
+
/** Horizontal correction in px, in the physical direction (positive = right). */
|
|
41
|
+
shift: number;
|
|
42
|
+
/** Room on the chosen side in px: the popover's height cap. */
|
|
43
|
+
available: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_GUTTER = 16;
|
|
47
|
+
const DEFAULT_OFFSET = 8;
|
|
48
|
+
|
|
49
|
+
/** Left edge of the popover for an alignment. */
|
|
50
|
+
function leftFor(align: MenuAlign, dir: 'ltr' | 'rtl', trigger: Box, width: number): number {
|
|
51
|
+
const rightEdgeAligned = (align === 'end') === (dir === 'ltr');
|
|
52
|
+
return rightEdgeAligned ? trigger.right - width : trigger.left;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Overflow (px) of a left edge past the gutters, 0 when it fits. */
|
|
56
|
+
function overflow(left: number, width: number, viewportWidth: number, gutter: number): number {
|
|
57
|
+
if (left < gutter) return gutter - left;
|
|
58
|
+
if (left + width > viewportWidth - gutter) return viewportWidth - gutter - (left + width);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function computePlacement(input: PlacementInput): Placement {
|
|
63
|
+
const { trigger, popover, viewport, dir } = input;
|
|
64
|
+
const gutter = input.gutter ?? DEFAULT_GUTTER;
|
|
65
|
+
const offset = input.offset ?? DEFAULT_OFFSET;
|
|
66
|
+
|
|
67
|
+
const room = {
|
|
68
|
+
bottom: viewport.height - trigger.bottom - offset - gutter / 2,
|
|
69
|
+
top: trigger.top - offset - gutter / 2,
|
|
70
|
+
};
|
|
71
|
+
const other: MenuSide = input.side === 'bottom' ? 'top' : 'bottom';
|
|
72
|
+
const side =
|
|
73
|
+
popover.height <= room[input.side] || room[input.side] >= room[other] ? input.side : other;
|
|
74
|
+
|
|
75
|
+
const otherAlign: MenuAlign = input.align === 'end' ? 'start' : 'end';
|
|
76
|
+
const preferred = overflow(leftFor(input.align, dir, trigger, popover.width), popover.width, viewport.width, gutter);
|
|
77
|
+
let align = input.align;
|
|
78
|
+
let shift = preferred;
|
|
79
|
+
if (preferred !== 0) {
|
|
80
|
+
const alternative = overflow(leftFor(otherAlign, dir, trigger, popover.width), popover.width, viewport.width, gutter);
|
|
81
|
+
if (alternative === 0) {
|
|
82
|
+
align = otherAlign;
|
|
83
|
+
shift = 0;
|
|
84
|
+
} else if (Math.abs(alternative) < Math.abs(preferred)) {
|
|
85
|
+
align = otherAlign;
|
|
86
|
+
shift = alternative;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { side, align, shift, available: Math.max(0, Math.floor(room[side])) };
|
|
91
|
+
}
|