@tastic/profile 0.7.0 → 0.8.0

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 CHANGED
@@ -49,7 +49,11 @@ see its own doc comment for why that component is generic over your own `Profile
49
49
  - **`ProfilePicker`** — a name trigger + selection popover: pick an existing profile, fall back to a
50
50
  null/"guest" selection, or (if `onManage` is passed) navigate to a management screen. Selection
51
51
  only — creating, renaming, and deleting all live in `ProfilesManager`. Generic over your own
52
- `Profile` type (see above).
52
+ `Profile` type (see above). Placement props (all optional, see "Fake-rotated frames" below):
53
+ `align` (force a horizontal side), `alignOverride` (substitute the whole placement result), and
54
+ `rotation` (forwarded to the automatic measurement). Also exports
55
+ `getProfilePickerContentSize(profileCount, hasManageRow?)` — the popover's
56
+ `{ width, height }`, for sizing your own placement hook.
53
57
  - **`ProfilesManager`** — the roster screen itself: create/rename/recolor/retag/delete, editing
54
58
  inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
55
59
  router, no back button) — a host app renders this as a routed screen's body and supplies its own
@@ -98,6 +102,40 @@ see its own doc comment for why that component is generic over your own `Profile
98
102
  { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
99
103
  ```
100
104
 
105
+ ### Fake-rotated frames: `ProfilePicker` placement
106
+
107
+ `ProfilePicker` positions its popover with `@tastic/hud`'s `useAutoAlign`, which measures the
108
+ trigger with `measureInWindow` and compares that rect against the raw window. Inside a *fake*
109
+ rotation (a `View` turned +-90deg while the OS stays portrait-locked) the rect is in true screen
110
+ space but the frame is not, so the hook measures along the wrong axes and can pick the wrong side —
111
+ flipping the list upward off-screen, or running it past the frame's bottom. Two optional props give
112
+ you the same seam `@tastic/hud`'s `InlineColorPicker` has:
113
+
114
+ | Prop | Type | Default | Effect |
115
+ | --- | --- | --- | --- |
116
+ | `align` | `'left' \| 'right' \| 'center'` | — | Forces the horizontal side; always beats the placement's own `align`. |
117
+ | `alignOverride` | `ProfilePickerAlignResult` (`{ align, verticalAlign, maxHeight, measured, triggerRef }`, the same shape as `useAutoAlign`'s return / `@tastic/hud`'s `AlignResult`) | — | Replaces the automatic placement wholesale: its `align`/`verticalAlign`/`maxHeight`/`measured` drive the popover, and its `triggerRef` is the ref attached to the measured trigger. |
118
+ | `rotation` | `0 \| 90 \| -90 \| 180` | `0` | Forwarded to the automatic `useAutoAlign` call (needs `@tastic/hud` >= 0.8.0). Ignored when `alignOverride` is given. |
119
+
120
+ With none of them set the picker behaves exactly as it always has. Note `rotation` is never read
121
+ from an ambient provider here (unlike `@tastic/hud`'s own pickers) — this package doesn't touch
122
+ `@tastic/core` at runtime — so pass it explicitly.
123
+
124
+ To supply your own placement, run a rotation-aware placement hook against the popover's real size,
125
+ using `getProfilePickerContentSize` so the two can't drift:
126
+
127
+ ```tsx
128
+ const open = host.openId === `${idPrefix}-profile`
129
+ const { width, height } = getProfilePickerContentSize(profiles.length, !!onManage)
130
+ const placement = useMyRotatedAlign(open, width, height) // returns a ProfilePickerAlignResult
131
+
132
+ <ProfilePicker idPrefix={idPrefix} host={host} profiles={profiles} /* ... */ alignOverride={placement} />
133
+ ```
134
+
135
+ `getProfilePickerContentSize(profileCount, hasManageRow = false)` returns `{ width, height }`: 220
136
+ wide, and `8 * 2 + (profileCount + 1 + (hasManageRow ? 1 : 0)) * 36` tall (the null/"guest" row, one
137
+ row per profile, and the Manage row when `onManage` is passed).
138
+
101
139
  ### Routed screens: `ProfilesScreen`
102
140
 
103
141
  `ProfilesScreen` is the routed-screen shell every app in the fleet was hand-rolling on its own
package/dist/index.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, Ref } from 'react';
3
- import { PopoverHost } from '@tastic/hud';
2
+ import { RefObject, ReactNode, Ref } from 'react';
3
+ import { PopoverHost, PopoverRotation } from '@tastic/hud';
4
+ import { View } from 'react-native';
4
5
 
5
6
  interface Profile {
6
7
  id: string;
@@ -18,6 +19,18 @@ interface Props {
18
19
  }
19
20
  declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
20
21
 
22
+ interface ProfilePickerAlignResult {
23
+ align: 'left' | 'right' | 'center';
24
+ verticalAlign: 'below' | 'above';
25
+ maxHeight: number;
26
+ measured: boolean;
27
+ triggerRef: RefObject<View | null>;
28
+ }
29
+ interface ProfilePickerContentSize {
30
+ width: number;
31
+ height: number;
32
+ }
33
+ declare function getProfilePickerContentSize(profileCount: number, hasManageRow?: boolean): ProfilePickerContentSize;
21
34
  interface ProfilePickerProps<P extends Profile> {
22
35
  idPrefix: string;
23
36
  host: PopoverHost;
@@ -27,13 +40,15 @@ interface ProfilePickerProps<P extends Profile> {
27
40
  color: string;
28
41
  dark: boolean;
29
42
  align?: 'left' | 'right' | 'center';
43
+ alignOverride?: ProfilePickerAlignResult;
44
+ rotation?: PopoverRotation;
30
45
  guestLabel: string;
31
46
  nullLabel?: string;
32
47
  nullIcon?: string;
33
48
  onSelect: (profile: P | null) => void;
34
49
  onManage?: () => void;
35
50
  }
36
- declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
51
+ declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
52
 
38
53
  type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
39
54
  interface ProfileEditPatch {
@@ -190,4 +205,4 @@ declare const isSharedProfileStoreAvailable: boolean;
190
205
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
191
206
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
192
207
 
193
- export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
208
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, Ref } from 'react';
3
- import { PopoverHost } from '@tastic/hud';
2
+ import { RefObject, ReactNode, Ref } from 'react';
3
+ import { PopoverHost, PopoverRotation } from '@tastic/hud';
4
+ import { View } from 'react-native';
4
5
 
5
6
  interface Profile {
6
7
  id: string;
@@ -18,6 +19,18 @@ interface Props {
18
19
  }
19
20
  declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
20
21
 
22
+ interface ProfilePickerAlignResult {
23
+ align: 'left' | 'right' | 'center';
24
+ verticalAlign: 'below' | 'above';
25
+ maxHeight: number;
26
+ measured: boolean;
27
+ triggerRef: RefObject<View | null>;
28
+ }
29
+ interface ProfilePickerContentSize {
30
+ width: number;
31
+ height: number;
32
+ }
33
+ declare function getProfilePickerContentSize(profileCount: number, hasManageRow?: boolean): ProfilePickerContentSize;
21
34
  interface ProfilePickerProps<P extends Profile> {
22
35
  idPrefix: string;
23
36
  host: PopoverHost;
@@ -27,13 +40,15 @@ interface ProfilePickerProps<P extends Profile> {
27
40
  color: string;
28
41
  dark: boolean;
29
42
  align?: 'left' | 'right' | 'center';
43
+ alignOverride?: ProfilePickerAlignResult;
44
+ rotation?: PopoverRotation;
30
45
  guestLabel: string;
31
46
  nullLabel?: string;
32
47
  nullIcon?: string;
33
48
  onSelect: (profile: P | null) => void;
34
49
  onManage?: () => void;
35
50
  }
36
- declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
51
+ declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
52
 
38
53
  type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
39
54
  interface ProfileEditPatch {
@@ -190,4 +205,4 @@ declare const isSharedProfileStoreAvailable: boolean;
190
205
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
191
206
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
192
207
 
193
- export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
208
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ __export(index_exports, {
30
30
  createProfileRecord: () => createProfileRecord,
31
31
  createProfileSelectionSlice: () => createProfileSelectionSlice,
32
32
  createSeatColorsSlice: () => createSeatColorsSlice,
33
+ getProfilePickerContentSize: () => getProfilePickerContentSize,
33
34
  isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
34
35
  isValidProfile: () => isValidProfile,
35
36
  isValidTag: () => isValidTag,
@@ -76,17 +77,22 @@ var TRIGGER_HEIGHT = 28;
76
77
  var POPOVER_WIDTH = 220;
77
78
  var ROW_HEIGHT = 36;
78
79
  var LIST_PADDING = 8;
79
- function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
80
+ function getProfilePickerContentSize(profileCount, hasManageRow = false) {
81
+ const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0);
82
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT };
83
+ }
84
+ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
80
85
  const id = `${idPrefix}-profile`;
81
86
  const menuBg = dark ? "#000000" : "#FFFFFF";
82
87
  const fg = dark ? "#FFFFFF" : "#000000";
83
88
  const open = host.openId === id;
84
89
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
85
90
  const sortedProfiles = (0, import_react.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
86
- const rowCount = profiles.length + 1 + (onManage ? 1 : 0);
87
- const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT;
88
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = (0, import_hud.useAutoAlign)(open, POPOVER_WIDTH, contentHeight);
89
- const align = alignOverride ?? autoAlign;
91
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
92
+ const auto = (0, import_hud.useAutoAlign)(open, contentWidth, contentHeight, rotation);
93
+ const placement = alignOverride ?? auto;
94
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement;
95
+ const align = forcedAlign ?? placement.align;
90
96
  const handleSelectGuest = () => {
91
97
  onSelect(null);
92
98
  host.close();
@@ -725,6 +731,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
725
731
  createProfileRecord,
726
732
  createProfileSelectionSlice,
727
733
  createSeatColorsSlice,
734
+ getProfilePickerContentSize,
728
735
  isSharedProfileStoreAvailable,
729
736
  isValidProfile,
730
737
  isValidTag,
package/dist/index.mjs CHANGED
@@ -39,17 +39,22 @@ var TRIGGER_HEIGHT = 28;
39
39
  var POPOVER_WIDTH = 220;
40
40
  var ROW_HEIGHT = 36;
41
41
  var LIST_PADDING = 8;
42
- function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
42
+ function getProfilePickerContentSize(profileCount, hasManageRow = false) {
43
+ const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0);
44
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT };
45
+ }
46
+ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
43
47
  const id = `${idPrefix}-profile`;
44
48
  const menuBg = dark ? "#000000" : "#FFFFFF";
45
49
  const fg = dark ? "#FFFFFF" : "#000000";
46
50
  const open = host.openId === id;
47
51
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
48
52
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
49
- const rowCount = profiles.length + 1 + (onManage ? 1 : 0);
50
- const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT;
51
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, POPOVER_WIDTH, contentHeight);
52
- const align = alignOverride ?? autoAlign;
53
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
54
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
55
+ const placement = alignOverride ?? auto;
56
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement;
57
+ const align = forcedAlign ?? placement.align;
53
58
  const handleSelectGuest = () => {
54
59
  onSelect(null);
55
60
  host.close();
@@ -687,6 +692,7 @@ export {
687
692
  createProfileRecord,
688
693
  createProfileSelectionSlice,
689
694
  createSeatColorsSlice,
695
+ getProfilePickerContentSize,
690
696
  isSharedProfileStoreAvailable,
691
697
  isValidProfile,
692
698
  isValidTag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
5
5
  "keywords": [
6
6
  "react-native",
@@ -69,9 +69,9 @@
69
69
  "@rific/core": "^0.2.0",
70
70
  "@rific/feedback-press": "^0.12.0",
71
71
  "@rific/focus-chain": "^0.4.7",
72
- "@rific/toaster": "^0.6.3",
72
+ "@rific/toaster": "^0.7.0",
73
73
  "@tastic/core": "^0.8.0",
74
- "@tastic/hud": "^0.8.5",
74
+ "@tastic/hud": "^0.12.0",
75
75
  "@testing-library/dom": "^10.4.1",
76
76
  "@testing-library/react": "^16.3.2",
77
77
  "@types/jest": "^30.0.0",
@@ -97,7 +97,7 @@
97
97
  "@rific/focus-chain": ">=0.4.0",
98
98
  "@rific/toaster": ">=0.4.0",
99
99
  "@tastic/core": ">=0.1.0",
100
- "@tastic/hud": ">=0.1.3",
100
+ "@tastic/hud": ">=0.8.0",
101
101
  "expo-modules-core": ">=1.0.0",
102
102
  "react": ">=19.0.0",
103
103
  "react-native": ">=0.76.0",
@@ -1,7 +1,7 @@
1
1
  import { getContrastColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { PopoverBody, PopoverHost, useAutoAlign } from '@tastic/hud'
4
- import { useMemo } from 'react'
3
+ import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
4
+ import { RefObject, useMemo } from 'react'
5
5
  import { ScrollView, StyleSheet, View } from 'react-native'
6
6
  import { Icon, Text } from 'react-native-paper'
7
7
 
@@ -13,6 +13,39 @@ const POPOVER_WIDTH = 220
13
13
  const ROW_HEIGHT = 36
14
14
  const LIST_PADDING = 8
15
15
 
16
+ // A complete placement decision for the popover: which way it opens, how tall it may grow, whether
17
+ // this open's measurement has landed yet, and the ref attached to the measured trigger.
18
+ // Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
19
+ // same shape `useAutoAlign` itself returns) — declared here rather than imported so this package's
20
+ // public types don't start requiring a newer @tastic/hud than its peer range already admits. A
21
+ // hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
22
+ export interface ProfilePickerAlignResult {
23
+ align: 'left' | 'right' | 'center'
24
+ verticalAlign: 'below' | 'above'
25
+ maxHeight: number
26
+ measured: boolean
27
+ triggerRef: RefObject<View | null>
28
+ }
29
+
30
+ export interface ProfilePickerContentSize {
31
+ width: number
32
+ height: number
33
+ }
34
+
35
+ // The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
36
+ // for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
37
+ // given an `onManage`) the trailing Manage row, plus the list's own padding. This is the size
38
+ // ProfilePicker hands to `useAutoAlign` — the ONE formula, used by the component itself (it leaves
39
+ // out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
40
+ // estimate, not a pixel-exact measurement) — exported
41
+ // so a host supplying its own placement through `alignOverride` can size that placement hook (its
42
+ // own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
43
+ // instead of copying these constants and drifting whenever they change here.
44
+ export function getProfilePickerContentSize(profileCount: number, hasManageRow = false): ProfilePickerContentSize {
45
+ const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0)
46
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT }
47
+ }
48
+
16
49
  interface ProfilePickerProps<P extends Profile> {
17
50
  // Namespaces this trigger's id — pass something unique per instance whenever more than one of
18
51
  // these shares a PopoverHost (e.g. one per seat).
@@ -31,7 +64,30 @@ interface ProfilePickerProps<P extends Profile> {
31
64
  // trigger reads as one identity with whatever it's paired with.
32
65
  color: string
33
66
  dark: boolean
67
+ // Forces a specific horizontal alignment, beating whatever the placement (the automatic
68
+ // measurement, or `alignOverride` below) would have picked. Omit to let placement decide.
34
69
  align?: 'left' | 'right' | 'center'
70
+ // Substitutes the popover's whole placement — horizontal/vertical alignment, max height, the
71
+ // `measured` gate and the trigger ref — for the automatic `useAutoAlign` one, for a host whose
72
+ // trigger sits somewhere that hook's plain window-relative measurement gets wrong. The motivating
73
+ // case is a FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked):
74
+ // `measureInWindow` reports a rect in true screen space, `useAutoAlign` compares it against the
75
+ // raw portrait window, and so measures along the wrong axes — flipping the list upward off-screen
76
+ // or running it past the frame's bottom. The host runs its own rotation-aware placement hook
77
+ // (sized with `getProfilePickerContentSize`, passing `open` from its own `host.openId === ...`
78
+ // check) and hands the result in here; `align` above still wins over this result's own `align`.
79
+ // The result's `triggerRef` is the one attached to the measured trigger, so the hook that
80
+ // produced it is measuring the right element. Omit for the default automatic placement.
81
+ alignOverride?: ProfilePickerAlignResult
82
+ // Rotation of an ancestor frame turned by a fake landscape (or equivalent), forwarded to the
83
+ // automatic `useAutoAlign` measurement — the same seam @tastic/hud's InlineColorPicker/
84
+ // SectionedDropdown expose (needs @tastic/hud >= 0.8.0; older versions ignore it). Defaults to 0
85
+ // (measurement used as-is), which is exactly how this picker behaved before this prop existed.
86
+ // Deliberately NOT read from an ambient provider the way hud's own pickers do: this component
87
+ // never touches @tastic/core at runtime, and an ambient read would silently change placement for
88
+ // every existing caller already living under a rotation provider. Ignored when `alignOverride` is
89
+ // supplied (the override replaces the placement this feeds).
90
+ rotation?: PopoverRotation
35
91
  // Trigger's own idle-state text (uppercased here regardless of casing passed in) — shown until a
36
92
  // profile is selected.
37
93
  guestLabel: string
@@ -63,7 +119,7 @@ interface ProfilePickerProps<P extends Profile> {
63
119
  // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven —
64
120
  // no internal profile-storage access of its own, so the host app stays the one place that reads
65
121
  // its own persistence hook.
66
- export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
122
+ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
67
123
  const id = `${idPrefix}-profile`
68
124
  const menuBg = dark ? '#000000' : '#FFFFFF'
69
125
  const fg = dark ? '#FFFFFF' : '#000000'
@@ -74,10 +130,14 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
74
130
  // most from staying scannable once there are more than a handful of saved profiles.
75
131
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
76
132
  // Null row + each saved profile + (if passed) the manage row.
77
- const rowCount = profiles.length + 1 + (onManage ? 1 : 0)
78
- const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT
79
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
80
- const align = alignOverride ?? autoAlign
133
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
134
+ // Always called, even when `alignOverride` is supplied and this result goes unused — hooks can't
135
+ // be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
136
+ // override its triggerRef is never attached, so this hook's own measurement is a no-op.
137
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
138
+ const placement = alignOverride ?? auto
139
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement
140
+ const align = forcedAlign ?? placement.align
81
141
 
82
142
  const handleSelectGuest = () => {
83
143
  onSelect(null)
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ProfileChip } from './ProfileChip'
2
- export { ProfilePicker } from './ProfilePicker'
2
+ export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize } from './ProfilePicker'
3
3
  export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
4
4
  export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
5
5
  export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'