@bacon-pos/ui 0.7.0 → 0.7.1

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.
Files changed (63) hide show
  1. package/lib/commonjs/components/Menu/Menu.js +25 -52
  2. package/lib/commonjs/components/Menu/Menu.js.map +1 -1
  3. package/lib/commonjs/components/Menu/MenuButton.js +4 -0
  4. package/lib/commonjs/components/Menu/MenuButton.js.map +1 -1
  5. package/lib/commonjs/components/Menu/SelectButton.js +4 -0
  6. package/lib/commonjs/components/Menu/SelectButton.js.map +1 -1
  7. package/lib/commonjs/components/Menu/UserMenu.js +4 -0
  8. package/lib/commonjs/components/Menu/UserMenu.js.map +1 -1
  9. package/lib/commonjs/components/Menu/placement.js +84 -0
  10. package/lib/commonjs/components/Menu/placement.js.map +1 -0
  11. package/lib/commonjs/components/Menu/placement.test.js +186 -0
  12. package/lib/commonjs/components/Menu/placement.test.js.map +1 -0
  13. package/lib/commonjs/components/Select/Select.js +2 -0
  14. package/lib/commonjs/components/Select/Select.js.map +1 -1
  15. package/lib/module/components/Menu/Menu.js +27 -54
  16. package/lib/module/components/Menu/Menu.js.map +1 -1
  17. package/lib/module/components/Menu/MenuButton.js +4 -0
  18. package/lib/module/components/Menu/MenuButton.js.map +1 -1
  19. package/lib/module/components/Menu/SelectButton.js +4 -0
  20. package/lib/module/components/Menu/SelectButton.js.map +1 -1
  21. package/lib/module/components/Menu/UserMenu.js +4 -0
  22. package/lib/module/components/Menu/UserMenu.js.map +1 -1
  23. package/lib/module/components/Menu/placement.js +80 -0
  24. package/lib/module/components/Menu/placement.js.map +1 -0
  25. package/lib/module/components/Menu/placement.test.js +186 -0
  26. package/lib/module/components/Menu/placement.test.js.map +1 -0
  27. package/lib/module/components/Select/Select.js +2 -0
  28. package/lib/module/components/Select/Select.js.map +1 -1
  29. package/lib/typescript/commonjs/components/Menu/Menu.d.ts +7 -1
  30. package/lib/typescript/commonjs/components/Menu/Menu.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/components/Menu/MenuButton.d.ts +6 -2
  32. package/lib/typescript/commonjs/components/Menu/MenuButton.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/components/Menu/SelectButton.d.ts +6 -2
  34. package/lib/typescript/commonjs/components/Menu/SelectButton.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/components/Menu/UserMenu.d.ts +6 -2
  36. package/lib/typescript/commonjs/components/Menu/UserMenu.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/components/Menu/placement.d.ts +47 -0
  38. package/lib/typescript/commonjs/components/Menu/placement.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/components/Menu/placement.test.d.ts +2 -0
  40. package/lib/typescript/commonjs/components/Menu/placement.test.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/components/Select/Select.d.ts +3 -1
  42. package/lib/typescript/commonjs/components/Select/Select.d.ts.map +1 -1
  43. package/lib/typescript/module/components/Menu/Menu.d.ts +7 -1
  44. package/lib/typescript/module/components/Menu/Menu.d.ts.map +1 -1
  45. package/lib/typescript/module/components/Menu/MenuButton.d.ts +6 -2
  46. package/lib/typescript/module/components/Menu/MenuButton.d.ts.map +1 -1
  47. package/lib/typescript/module/components/Menu/SelectButton.d.ts +6 -2
  48. package/lib/typescript/module/components/Menu/SelectButton.d.ts.map +1 -1
  49. package/lib/typescript/module/components/Menu/UserMenu.d.ts +6 -2
  50. package/lib/typescript/module/components/Menu/UserMenu.d.ts.map +1 -1
  51. package/lib/typescript/module/components/Menu/placement.d.ts +47 -0
  52. package/lib/typescript/module/components/Menu/placement.d.ts.map +1 -0
  53. package/lib/typescript/module/components/Menu/placement.test.d.ts +2 -0
  54. package/lib/typescript/module/components/Menu/placement.test.d.ts.map +1 -0
  55. package/lib/typescript/module/components/Select/Select.d.ts +3 -1
  56. package/lib/typescript/module/components/Select/Select.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/components/Menu/Menu.tsx +37 -46
  59. package/src/components/Menu/MenuButton.tsx +9 -1
  60. package/src/components/Menu/SelectButton.tsx +9 -1
  61. package/src/components/Menu/UserMenu.tsx +9 -1
  62. package/src/components/Menu/placement.ts +107 -0
  63. package/src/components/Select/Select.tsx +4 -0
@@ -6,11 +6,13 @@ import {
6
6
  Modal,
7
7
  Platform,
8
8
  Pressable,
9
+ StatusBar,
9
10
  View,
10
11
  type LayoutChangeEvent,
11
12
  type StyleProp,
12
13
  type ViewStyle,
13
14
  } from 'react-native';
15
+ import { computeMenuPlacement } from './placement';
14
16
 
15
17
  export type OriginVertical = 'top' | 'center' | 'bottom';
16
18
  export type OriginHorizontal = 'left' | 'center' | 'right';
@@ -21,9 +23,6 @@ export interface MenuOrigin {
21
23
  horizontal: OriginHorizontal;
22
24
  }
23
25
 
24
- const V_FRAC: Record<OriginVertical, number> = { top: 0, center: 0.5, bottom: 1 };
25
- const H_FRAC: Record<OriginHorizontal, number> = { left: 0, center: 0.5, right: 1 };
26
-
27
26
  /** State handed to the trigger renderer and (optionally) the content renderer. */
28
27
  export interface MenuTriggerState {
29
28
  open: boolean;
@@ -56,6 +55,12 @@ export interface MenuProps {
56
55
  autoFit?: boolean;
57
56
  /** Safe-area insets to keep clear (pass from `react-native-safe-area-context` if you use it). */
58
57
  insets?: EdgeInsets;
58
+ /**
59
+ * Offset between window coordinates (where the trigger is measured) and the
60
+ * full-screen Modal the menu paints into. Defaults to the Android status bar
61
+ * height (0 on iOS / web). Pass 0 when the host runs edge-to-edge / immersive.
62
+ */
63
+ statusBarHeight?: number;
59
64
  /** Controlled open state. Omit for uncontrolled. */
60
65
  open?: boolean;
61
66
  /** Notified whenever the open state changes. */
@@ -75,6 +80,9 @@ export interface EdgeInsets {
75
80
 
76
81
  const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
77
82
 
83
+ /** Default coordinate offset for the translucent Modal (Android status bar). */
84
+ const DEFAULT_STATUS_BAR_HEIGHT = Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 0;
85
+
78
86
  interface Rect {
79
87
  x: number;
80
88
  y: number;
@@ -82,11 +90,6 @@ interface Rect {
82
90
  height: number;
83
91
  }
84
92
 
85
- function clamp(value: number, min: number, max: number): number {
86
- if (max < min) return min;
87
- return Math.min(Math.max(value, min), max);
88
- }
89
-
90
93
  /**
91
94
  * Anchored overlay menu. Renders a trigger inline and, when open, a floating
92
95
  * panel positioned relative to the trigger via two origins — `anchorOrigin`
@@ -102,6 +105,7 @@ export function Menu({
102
105
  offset = 8,
103
106
  autoFit = true,
104
107
  insets = ZERO_INSETS,
108
+ statusBarHeight = DEFAULT_STATUS_BAR_HEIGHT,
105
109
  open: openProp,
106
110
  onOpenChange,
107
111
  screenPadding = 8,
@@ -161,44 +165,31 @@ export function Menu({
161
165
  if (naturalHeight === null) setNaturalHeight(height);
162
166
  };
163
167
 
164
- // Auto-fit: choose a vertical direction with room and cap the height so the
165
- // menu never runs off-screen. Only meaningful for a "drop" origin config
166
- // (top↔bottom); otherwise we keep the origins as given.
167
- const isDropConfig =
168
- (anchorOrigin.vertical === 'bottom' && menuOrigin.vertical === 'top') ||
169
- (anchorOrigin.vertical === 'top' && menuOrigin.vertical === 'bottom');
170
- const preferredUp = anchorOrigin.vertical === 'top' && menuOrigin.vertical === 'bottom';
171
-
172
- let effAnchor = anchorOrigin;
173
- let effMenu = menuOrigin;
174
- let autoMaxHeight: number | undefined;
175
-
176
- if (autoFit && isDropConfig && rect && naturalHeight !== null) {
177
- const screen = Dimensions.get('window');
178
- const spaceBelow = screen.height - insets.bottom - (rect.y + rect.height) - offset - screenPadding;
179
- const spaceAbove = rect.y - insets.top - offset - screenPadding;
180
- let up = preferredUp;
181
- if (!preferredUp) up = naturalHeight > spaceBelow && spaceAbove > spaceBelow;
182
- else up = !(naturalHeight > spaceAbove && spaceBelow > spaceAbove);
183
- const chosen = up ? spaceAbove : spaceBelow;
184
- if (naturalHeight > chosen && chosen > 0) autoMaxHeight = chosen;
185
- effAnchor = { horizontal: anchorOrigin.horizontal, vertical: up ? 'top' : 'bottom' };
186
- effMenu = { horizontal: menuOrigin.horizontal, vertical: up ? 'bottom' : 'top' };
187
- }
188
-
189
- let top = 0;
190
- let left = 0;
191
- if (rect && menuSize) {
192
- const screen = Dimensions.get('window');
193
- const anchorX = rect.x + H_FRAC[effAnchor.horizontal] * rect.width;
194
- const anchorY = rect.y + V_FRAC[effAnchor.vertical] * rect.height;
195
- const offsetY =
196
- effMenu.vertical === 'top' ? offset : effMenu.vertical === 'bottom' ? -offset : 0;
197
- left = anchorX - H_FRAC[effMenu.horizontal] * menuSize.width;
198
- top = anchorY - V_FRAC[effMenu.vertical] * menuSize.height + offsetY;
199
- left = clamp(left, screenPadding + insets.left, screen.width - insets.right - menuSize.width - screenPadding);
200
- top = clamp(top, screenPadding + insets.top, screen.height - insets.bottom - menuSize.height - screenPadding);
201
- }
168
+ // All positioning math lives in the pure `computeMenuPlacement` (unit-tested):
169
+ // it applies the status-bar offset, auto-fit flip, height cap and screen clamp.
170
+ const screen = Dimensions.get('window');
171
+ const placement =
172
+ rect && naturalHeight !== null
173
+ ? computeMenuPlacement({
174
+ rect,
175
+ menuWidth: menuSize?.width ?? 0,
176
+ menuHeight: menuSize?.height ?? naturalHeight,
177
+ naturalHeight,
178
+ anchorOrigin,
179
+ menuOrigin,
180
+ offset,
181
+ screenPadding,
182
+ insets,
183
+ screenWidth: screen.width,
184
+ screenHeight: screen.height,
185
+ statusBarHeight,
186
+ autoFit,
187
+ })
188
+ : null;
189
+
190
+ const top = placement?.top ?? 0;
191
+ const left = placement?.left ?? 0;
192
+ const autoMaxHeight = placement?.maxHeight;
202
193
 
203
194
  const placed = Boolean(rect && menuSize);
204
195
  const content =
@@ -1,7 +1,7 @@
1
1
  import { Dropdown, type DropdownEntry } from '../Dropdown/Dropdown';
2
2
  import { IconButton, type IconButtonShape, type IconButtonSize, type IconButtonVariant } from '../IconButton/IconButton';
3
3
  import type { IconName } from '../Icon/icons.generated';
4
- import { Menu, type MenuOrigin } from './Menu';
4
+ import { Menu, type EdgeInsets, type MenuOrigin } from './Menu';
5
5
 
6
6
  export interface MenuButtonProps {
7
7
  /** The menu rows (see `Dropdown`). */
@@ -26,6 +26,10 @@ export interface MenuButtonProps {
26
26
  menuOrigin?: MenuOrigin;
27
27
  /** Gap between trigger and menu. Defaults to 8. */
28
28
  offset?: number;
29
+ /** Safe-area insets to keep clear. */
30
+ insets?: EdgeInsets;
31
+ /** Status-bar coordinate offset for the menu (see `Menu`). Pass 0 when immersive. */
32
+ statusBarHeight?: number;
29
33
  /** Menu width. Defaults to the `Dropdown` default (350). */
30
34
  menuWidth?: number;
31
35
  /** Draw dividers between rows. Defaults to the `Dropdown` default (true). */
@@ -56,6 +60,8 @@ export function MenuButton({
56
60
  anchorOrigin = { vertical: 'bottom', horizontal: 'right' },
57
61
  menuOrigin = { vertical: 'top', horizontal: 'right' },
58
62
  offset = 8,
63
+ insets,
64
+ statusBarHeight,
59
65
  menuWidth,
60
66
  showDividers,
61
67
  closeOnSelect = true,
@@ -67,6 +73,8 @@ export function MenuButton({
67
73
  anchorOrigin={anchorOrigin}
68
74
  menuOrigin={menuOrigin}
69
75
  offset={offset}
76
+ insets={insets}
77
+ statusBarHeight={statusBarHeight}
70
78
  open={open}
71
79
  onOpenChange={onOpenChange}
72
80
  renderTrigger={({ toggle }) => (
@@ -4,7 +4,7 @@ import { Button, type ButtonSize, type ButtonVariant } from '../Button/Button';
4
4
  import { Dropdown, type DropdownEntry } from '../Dropdown/Dropdown';
5
5
  import { Icon } from '../Icon/Icon';
6
6
  import type { IconName } from '../Icon/icons.generated';
7
- import { Menu, type MenuOrigin } from './Menu';
7
+ import { Menu, type EdgeInsets, type MenuOrigin } from './Menu';
8
8
 
9
9
  /** A selectable option. Its `icon` shows both in the row and on the trigger when chosen. */
10
10
  export interface SelectButtonOption {
@@ -45,6 +45,10 @@ export interface SelectButtonProps {
45
45
  menuOrigin?: MenuOrigin;
46
46
  /** Gap between trigger and menu. Defaults to 8. */
47
47
  offset?: number;
48
+ /** Safe-area insets to keep clear. */
49
+ insets?: EdgeInsets;
50
+ /** Status-bar coordinate offset for the menu (see `Menu`). Pass 0 when immersive. */
51
+ statusBarHeight?: number;
48
52
  /** Menu width. Defaults to the `Dropdown` default (350). */
49
53
  menuWidth?: number;
50
54
  /** Draw dividers between rows. Defaults to the `Dropdown` default (true). */
@@ -98,6 +102,8 @@ export function SelectButton({
98
102
  anchorOrigin = { vertical: 'bottom', horizontal: 'left' },
99
103
  menuOrigin = { vertical: 'top', horizontal: 'left' },
100
104
  offset = 8,
105
+ insets,
106
+ statusBarHeight,
101
107
  menuWidth,
102
108
  showDividers,
103
109
  closeOnSelect = true,
@@ -134,6 +140,8 @@ export function SelectButton({
134
140
  anchorOrigin={anchorOrigin}
135
141
  menuOrigin={menuOrigin}
136
142
  offset={offset}
143
+ insets={insets}
144
+ statusBarHeight={statusBarHeight}
137
145
  open={open}
138
146
  onOpenChange={onOpenChange}
139
147
  triggerStyle={fullWidth ? { alignSelf: 'stretch' } : undefined}
@@ -1,7 +1,7 @@
1
1
  import type { ImageSourcePropType } from 'react-native';
2
2
  import { Dropdown, type DropdownEntry } from '../Dropdown/Dropdown';
3
3
  import { UserChip, type UserChipSize } from '../UserChip/UserChip';
4
- import { Menu, type MenuOrigin } from './Menu';
4
+ import { Menu, type EdgeInsets, type MenuOrigin } from './Menu';
5
5
 
6
6
  export interface UserMenuProps {
7
7
  /** The menu rows (see `Dropdown`). */
@@ -28,6 +28,10 @@ export interface UserMenuProps {
28
28
  menuOrigin?: MenuOrigin;
29
29
  /** Gap between trigger and menu. Defaults to 8. */
30
30
  offset?: number;
31
+ /** Safe-area insets to keep clear. */
32
+ insets?: EdgeInsets;
33
+ /** Status-bar coordinate offset for the menu (see `Menu`). Pass 0 when immersive. */
34
+ statusBarHeight?: number;
31
35
  /** Menu width. Defaults to the `Dropdown` default (350). */
32
36
  menuWidth?: number;
33
37
  /** Draw dividers between rows. Defaults to the `Dropdown` default (true). */
@@ -58,6 +62,8 @@ export function UserMenu({
58
62
  anchorOrigin = { vertical: 'bottom', horizontal: 'left' },
59
63
  menuOrigin = { vertical: 'top', horizontal: 'left' },
60
64
  offset = 8,
65
+ insets,
66
+ statusBarHeight,
61
67
  menuWidth,
62
68
  showDividers,
63
69
  closeOnSelect = true,
@@ -69,6 +75,8 @@ export function UserMenu({
69
75
  anchorOrigin={anchorOrigin}
70
76
  menuOrigin={menuOrigin}
71
77
  offset={offset}
78
+ insets={insets}
79
+ statusBarHeight={statusBarHeight}
72
80
  open={open}
73
81
  onOpenChange={onOpenChange}
74
82
  renderTrigger={({ toggle }) => (
@@ -0,0 +1,107 @@
1
+ import type { EdgeInsets, MenuOrigin } from './Menu';
2
+
3
+ const V_FRAC = { top: 0, center: 0.5, bottom: 1 } as const;
4
+ const H_FRAC = { left: 0, center: 0.5, right: 1 } as const;
5
+
6
+ export interface PlacementInput {
7
+ /** Trigger rect in window coordinates (as returned by `measureInWindow`). */
8
+ rect: { x: number; y: number; width: number; height: number };
9
+ /** Measured menu size (0 before the first layout). */
10
+ menuWidth: number;
11
+ menuHeight: number;
12
+ /** The content's natural (uncapped) height — drives the flip / cap decisions. */
13
+ naturalHeight: number;
14
+ anchorOrigin: MenuOrigin;
15
+ menuOrigin: MenuOrigin;
16
+ offset: number;
17
+ screenPadding: number;
18
+ insets: EdgeInsets;
19
+ /** Window size (`Dimensions.get('window')`). */
20
+ screenWidth: number;
21
+ screenHeight: number;
22
+ /**
23
+ * Difference between the window's coordinate origin (where `measureInWindow`
24
+ * reports) and the full-screen Modal's origin. On Android with a translucent
25
+ * status bar this is the status bar height; 0 on iOS / web / immersive.
26
+ */
27
+ statusBarHeight: number;
28
+ autoFit: boolean;
29
+ }
30
+
31
+ export interface Placement {
32
+ top: number;
33
+ left: number;
34
+ /** Auto-fit cap for the content so it scrolls instead of overflowing. */
35
+ maxHeight?: number;
36
+ }
37
+
38
+ function clamp(value: number, min: number, max: number): number {
39
+ if (max < min) return min;
40
+ return Math.min(Math.max(value, min), max);
41
+ }
42
+
43
+ /**
44
+ * Pure placement math for `Menu`. Kept side-effect-free so it can be unit-tested
45
+ * exhaustively (status-bar offset, flip, cap, clamp) independent of RN layout.
46
+ *
47
+ * The key correctness point: the trigger is measured in *window* coordinates but
48
+ * the menu is painted in a full-screen (`statusBarTranslucent`) Modal, so we add
49
+ * `statusBarHeight` when converting the trigger position into the Modal's space.
50
+ * Available-space math cancels the offset out, so it uses window coordinates.
51
+ */
52
+ export function computeMenuPlacement(input: PlacementInput): Placement {
53
+ const {
54
+ rect,
55
+ menuWidth,
56
+ menuHeight,
57
+ naturalHeight,
58
+ anchorOrigin,
59
+ menuOrigin,
60
+ offset,
61
+ screenPadding,
62
+ insets,
63
+ screenWidth,
64
+ screenHeight,
65
+ statusBarHeight,
66
+ autoFit,
67
+ } = input;
68
+
69
+ const isDropConfig =
70
+ (anchorOrigin.vertical === 'bottom' && menuOrigin.vertical === 'top') ||
71
+ (anchorOrigin.vertical === 'top' && menuOrigin.vertical === 'bottom');
72
+ const preferredUp = anchorOrigin.vertical === 'top' && menuOrigin.vertical === 'bottom';
73
+
74
+ let effAnchor = anchorOrigin;
75
+ let effMenu = menuOrigin;
76
+ let maxHeight: number | undefined;
77
+
78
+ if (autoFit && isDropConfig) {
79
+ // Space math is in window coordinates (the status-bar offset cancels out).
80
+ const spaceBelow = screenHeight - insets.bottom - (rect.y + rect.height) - offset - screenPadding;
81
+ const spaceAbove = rect.y - insets.top - offset - screenPadding;
82
+ let up = preferredUp;
83
+ if (!preferredUp) up = naturalHeight > spaceBelow && spaceAbove > spaceBelow;
84
+ else up = !(naturalHeight > spaceAbove && spaceBelow > spaceAbove);
85
+ const chosen = up ? spaceAbove : spaceBelow;
86
+ if (naturalHeight > chosen && chosen > 0) maxHeight = chosen;
87
+ effAnchor = { horizontal: anchorOrigin.horizontal, vertical: up ? 'top' : 'bottom' };
88
+ effMenu = { horizontal: menuOrigin.horizontal, vertical: up ? 'bottom' : 'top' };
89
+ }
90
+
91
+ // Convert the trigger position into the Modal's (full-screen) coordinate space.
92
+ const anchorX = rect.x + H_FRAC[effAnchor.horizontal] * rect.width;
93
+ const anchorY = rect.y + statusBarHeight + V_FRAC[effAnchor.vertical] * rect.height;
94
+ const offsetY = effMenu.vertical === 'top' ? offset : effMenu.vertical === 'bottom' ? -offset : 0;
95
+
96
+ let left = anchorX - H_FRAC[effMenu.horizontal] * menuWidth;
97
+ let top = anchorY - V_FRAC[effMenu.vertical] * menuHeight + offsetY;
98
+
99
+ left = clamp(left, screenPadding + insets.left, screenWidth - insets.right - menuWidth - screenPadding);
100
+ top = clamp(
101
+ top,
102
+ statusBarHeight + screenPadding + insets.top,
103
+ screenHeight + statusBarHeight - insets.bottom - menuHeight - screenPadding,
104
+ );
105
+
106
+ return { top, left, maxHeight };
107
+ }
@@ -96,6 +96,8 @@ export interface SelectProps {
96
96
  autoFit?: boolean;
97
97
  /** Safe-area insets to keep clear. */
98
98
  insets?: EdgeInsets;
99
+ /** Status-bar coordinate offset for the menu (see `Menu`). Pass 0 when immersive. */
100
+ statusBarHeight?: number;
99
101
  /** Close after selecting. Defaults to `!multiple`. */
100
102
  closeOnSelect?: boolean;
101
103
  /** Container style. */
@@ -133,6 +135,7 @@ export function Select({
133
135
  offset = 8,
134
136
  autoFit = true,
135
137
  insets,
138
+ statusBarHeight,
136
139
  closeOnSelect,
137
140
  style,
138
141
  }: SelectProps) {
@@ -216,6 +219,7 @@ export function Select({
216
219
  offset={offset}
217
220
  autoFit={autoFit}
218
221
  insets={insets}
222
+ statusBarHeight={statusBarHeight}
219
223
  triggerStyle={{ alignSelf: 'stretch' }}
220
224
  renderTrigger={({ open, toggle }) => {
221
225
  const showRing = open && !disabled;