@tastic/profile 0.7.0 → 0.9.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,62 @@ 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
+
139
+
140
+ #### The Profiles screen's color swatch: `useColorPickerAlign`
141
+
142
+ `ProfilesManager` (and `ProfilesScreen`, which forwards it) renders `@tastic/hud`'s `InlineColorPicker`
143
+ for the swatch popover in an edit row, and owns the popover state internally — so the host cannot pass
144
+ `alignOverride` to it directly. Inside a fake-rotated frame that popover has the same misplacement (and
145
+ `InlineColorPicker` also feeds `useAutoAlign` its ambient rotation, double-rotating an already-transformed
146
+ rect). Pass a placement **hook** as `useColorPickerAlign`:
147
+
148
+ ```tsx
149
+ <ProfilesScreen useColorPickerAlign={useFrameAutoAlign} /* ...the usual props */ />
150
+ ```
151
+
152
+ It is called as `useColorPickerAlign(open, contentWidth, contentHeight)` — `open` is whether the swatch
153
+ popover is showing, and the size is what `InlineColorPicker` itself computes (`@tastic/hud`'s
154
+ `getInlineColorPickerContentSize` for the default swatch list at the raw window width) — and must return a
155
+ `ProfilePickerAlignResult`, which is handed to `InlineColorPicker` as its `alignOverride` (its `triggerRef`
156
+ is the ref that gets measured). It is a hook, not a plain function: give it a stable identity for the life of
157
+ the mount (a module-level function), since switching it at runtime remounts the swatch. Omit it and behaviour is
158
+ identical to before. **Needs `@tastic/hud` >= 0.12.0** to have any effect — older hud has no `alignOverride`
159
+ on `InlineColorPicker` and ignores it.
160
+
101
161
  ### Routed screens: `ProfilesScreen`
102
162
 
103
163
  `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 {
@@ -57,8 +72,9 @@ interface ProfilesManagerProps {
57
72
  cardBg?: string;
58
73
  titleVariant?: MD3TextVariant$1;
59
74
  colorPreview?: (hex: string) => string;
75
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
60
76
  }
61
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
77
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
62
78
 
63
79
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
64
80
  interface ProfilesScreenProps {
@@ -74,8 +90,9 @@ interface ProfilesScreenProps {
74
90
  cardBg?: string;
75
91
  titleVariant?: MD3TextVariant;
76
92
  colorPreview?: (hex: string) => string;
93
+ useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign'];
77
94
  }
78
- declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
95
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps): react.JSX.Element;
79
96
 
80
97
  declare const MAX_PROFILE_NAME_LENGTH = 20;
81
98
  declare const MAX_TAG_LENGTH = 3;
@@ -190,4 +207,4 @@ declare const isSharedProfileStoreAvailable: boolean;
190
207
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
191
208
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
192
209
 
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 };
210
+ 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 {
@@ -57,8 +72,9 @@ interface ProfilesManagerProps {
57
72
  cardBg?: string;
58
73
  titleVariant?: MD3TextVariant$1;
59
74
  colorPreview?: (hex: string) => string;
75
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
60
76
  }
61
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
77
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
62
78
 
63
79
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
64
80
  interface ProfilesScreenProps {
@@ -74,8 +90,9 @@ interface ProfilesScreenProps {
74
90
  cardBg?: string;
75
91
  titleVariant?: MD3TextVariant;
76
92
  colorPreview?: (hex: string) => string;
93
+ useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign'];
77
94
  }
78
- declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
95
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps): react.JSX.Element;
79
96
 
80
97
  declare const MAX_PROFILE_NAME_LENGTH = 20;
81
98
  declare const MAX_TAG_LENGTH = 3;
@@ -190,4 +207,4 @@ declare const isSharedProfileStoreAvailable: boolean;
190
207
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
191
208
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
192
209
 
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 };
210
+ 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();
@@ -296,7 +302,7 @@ function deriveTag(name) {
296
302
  const trimmed = name.trim();
297
303
  return trimmed ? trimmed[0].toUpperCase() : "";
298
304
  }
299
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
305
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview, useColorPickerAlign }) {
300
306
  const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
301
307
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
302
308
  const { error: showErrorToast } = (0, import_toaster.useToast)();
@@ -382,11 +388,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
382
388
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
383
389
  ] }) }, "new");
384
390
  }
385
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview }, "new");
391
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
386
392
  }
387
393
  const { profile } = row;
388
394
  if (editingId === profile.id) {
389
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
395
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
390
396
  }
391
397
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
392
398
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -406,7 +412,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
406
412
  ] }) }) })
407
413
  ] });
408
414
  }
409
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
415
+ function AlignedColorPicker({ useAlign, ...props }) {
416
+ const { width: windowWidth } = (0, import_react_native3.useWindowDimensions)();
417
+ const { width, height } = (0, import_hud2.getInlineColorPickerContentSize)(import_auto_paper3.defaultColors.length, windowWidth);
418
+ const alignOverride = useAlign(props.host.openId === props.id, width, height);
419
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { ...props, alignOverride });
420
+ }
421
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
410
422
  const register = (0, import_focus_chain.useFocusChain)();
411
423
  const tag = register();
412
424
  const name = register();
@@ -418,7 +430,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
418
430
  if (host.openId === "color") tagFocusRef.current();
419
431
  }, [host.openId]);
420
432
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
421
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
433
+ useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
422
434
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
423
435
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
424
436
  onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
@@ -523,7 +535,7 @@ var import_feedback_press3 = require("@rific/feedback-press");
523
535
  var import_react3 = require("react");
524
536
  var import_react_native4 = require("react-native");
525
537
  var import_jsx_runtime4 = require("react/jsx-runtime");
526
- function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
538
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }) {
527
539
  const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
528
540
  const managerRef = (0, import_react3.useRef)(null);
529
541
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
@@ -532,7 +544,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
532
544
  managerRef.current?.commitPendingEdit();
533
545
  onBack();
534
546
  };
535
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
547
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
536
548
  }
537
549
  var styles4 = import_react_native4.StyleSheet.create({
538
550
  container: {
@@ -725,6 +737,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
725
737
  createProfileRecord,
726
738
  createProfileSelectionSlice,
727
739
  createSeatColorsSlice,
740
+ getProfilePickerContentSize,
728
741
  isSharedProfileStoreAvailable,
729
742
  isValidProfile,
730
743
  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();
@@ -210,13 +215,13 @@ var styles2 = StyleSheet2.create({
210
215
  });
211
216
 
212
217
  // src/ProfilesManager.tsx
213
- import { useAutoPaperTheme } from "@rific/auto-paper";
218
+ import { defaultColors, useAutoPaperTheme } from "@rific/auto-paper";
214
219
  import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
215
220
  import { useFocusChain } from "@rific/focus-chain";
216
221
  import { useToast } from "@rific/toaster";
217
- import { InlineColorPicker, usePopoverHost } from "@tastic/hud";
222
+ import { getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from "@tastic/hud";
218
223
  import { useCallback, useEffect, useImperativeHandle, useMemo as useMemo2, useRef, useState } from "react";
219
- import { KeyboardAvoidingView, Platform, ScrollView as ScrollView2, StyleSheet as StyleSheet3, View as View3 } from "react-native";
224
+ import { KeyboardAvoidingView, Platform, ScrollView as ScrollView2, StyleSheet as StyleSheet3, useWindowDimensions, View as View3 } from "react-native";
220
225
  import { Button, Icon as Icon3, Portal, Text as Text3, TextInput } from "react-native-paper";
221
226
  import { useSafeAreaInsets } from "react-native-safe-area-context";
222
227
 
@@ -259,7 +264,7 @@ function deriveTag(name) {
259
264
  const trimmed = name.trim();
260
265
  return trimmed ? trimmed[0].toUpperCase() : "";
261
266
  }
262
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
267
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview, useColorPickerAlign }) {
263
268
  const { dark, colors } = useAutoPaperTheme();
264
269
  const insets = useSafeAreaInsets();
265
270
  const { error: showErrorToast } = useToast();
@@ -345,11 +350,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
345
350
  /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
346
351
  ] }) }, "new");
347
352
  }
348
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview }, "new");
353
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
349
354
  }
350
355
  const { profile } = row;
351
356
  if (editingId === profile.id) {
352
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
357
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
353
358
  }
354
359
  return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
355
360
  /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -369,7 +374,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
369
374
  ] }) }) })
370
375
  ] });
371
376
  }
372
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
377
+ function AlignedColorPicker({ useAlign, ...props }) {
378
+ const { width: windowWidth } = useWindowDimensions();
379
+ const { width, height } = getInlineColorPickerContentSize(defaultColors.length, windowWidth);
380
+ const alignOverride = useAlign(props.host.openId === props.id, width, height);
381
+ return /* @__PURE__ */ jsx3(InlineColorPicker, { ...props, alignOverride });
382
+ }
383
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
373
384
  const register = useFocusChain();
374
385
  const tag = register();
375
386
  const name = register();
@@ -381,7 +392,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
381
392
  if (host.openId === "color") tagFocusRef.current();
382
393
  }, [host.openId]);
383
394
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
384
- /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
395
+ useColorPickerAlign ? /* @__PURE__ */ jsx3(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
385
396
  /* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
386
397
  /* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
387
398
  onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
@@ -486,7 +497,7 @@ import { IconButton } from "@rific/feedback-press";
486
497
  import { useRef as useRef2 } from "react";
487
498
  import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
488
499
  import { jsx as jsx4 } from "react/jsx-runtime";
489
- function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
500
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }) {
490
501
  const { dark } = useAutoPaperTheme2();
491
502
  const managerRef = useRef2(null);
492
503
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
@@ -495,7 +506,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
495
506
  managerRef.current?.commitPendingEdit();
496
507
  onBack();
497
508
  };
498
- return /* @__PURE__ */ jsx4(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx4(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, headerLeft: /* @__PURE__ */ jsx4(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
509
+ return /* @__PURE__ */ jsx4(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx4(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ jsx4(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
499
510
  }
500
511
  var styles4 = StyleSheet4.create({
501
512
  container: {
@@ -687,6 +698,7 @@ export {
687
698
  createProfileRecord,
688
699
  createProfileSelectionSlice,
689
700
  createSeatColorsSlice,
701
+ getProfilePickerContentSize,
690
702
  isSharedProfileStoreAvailable,
691
703
  isValidProfile,
692
704
  isValidTag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
73
- "@tastic/core": "^0.8.0",
74
- "@tastic/hud": "^0.8.5",
72
+ "@rific/toaster": "^0.7.0",
73
+ "@tastic/core": "^0.9.0",
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)
@@ -1,14 +1,15 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { useFocusChain } from '@rific/focus-chain'
4
4
  import { useToast } from '@rific/toaster'
5
- import { InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
6
- import { ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
7
- import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'
5
+ import { getInlineColorPickerContentSize, InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
6
+ import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
7
+ import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
8
8
  import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
9
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
10
 
11
11
  import { ProfileChip } from './ProfileChip'
12
+ import { type ProfilePickerAlignResult } from './ProfilePicker'
12
13
  import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
13
14
  import { Profile } from './types'
14
15
 
@@ -79,6 +80,18 @@ export interface ProfilesManagerProps {
79
80
  // InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
80
81
  // color value untouched; only what's rendered changes.
81
82
  colorPreview?: (hex: string) => string
83
+ // Substitutes the color swatch popover's own placement, for a host that renders this screen inside a
84
+ // FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked). @tastic/hud's
85
+ // InlineColorPicker places its popover with useAutoAlign, which compares a screen-space
86
+ // measureInWindow rect against the raw portrait window and (through its ambient rotation) double-rotates
87
+ // an already-transformed rect - so under a fake rotation it lands off its trigger. A host that knows its
88
+ // frame passes its own placement HOOK here; it is called with the swatch popover's open state and content
89
+ // size, and its result is handed to InlineColorPicker as `alignOverride` (its `triggerRef` is what gets
90
+ // measured). It is a hook, not a plain function: give it a STABLE identity for the life of the mount (a
91
+ // module-level function) - switching it at runtime remounts the swatch. Omit for the ordinary case;
92
+ // behaviour is then identical to before. Needs @tastic/hud >= 0.12.0 to have any effect (older hud has no
93
+ // `alignOverride` and ignores it).
94
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult
82
95
  }
83
96
 
84
97
  type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
@@ -101,7 +114,7 @@ function deriveTag(name: string): string {
101
114
  // really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
102
115
  // react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
103
116
  // instead of relying on the unmount fallback alone.
104
- export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview }: ProfilesManagerProps) {
117
+ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview, useColorPickerAlign }: ProfilesManagerProps) {
105
118
  const { dark, colors } = useAutoPaperTheme()
106
119
  const insets = useSafeAreaInsets()
107
120
  const { error: showErrorToast } = useToast()
@@ -253,12 +266,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
253
266
  </TouchableRipple>
254
267
  )
255
268
  }
256
- return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} />
269
+ return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
257
270
  }
258
271
 
259
272
  const { profile } = row
260
273
  if (editingId === profile.id) {
261
- return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} />
274
+ return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
262
275
  }
263
276
 
264
277
  // No delete affordance at rest — tap the row to start editing, which is the one place
@@ -323,6 +336,19 @@ interface EditRowProps {
323
336
  host: PopoverHost
324
337
  dark: boolean
325
338
  colorPreview?: (hex: string) => string
339
+ useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign']
340
+ }
341
+
342
+ // InlineColorPicker with its placement supplied by a host hook (see ProfilesManagerProps.useColorPickerAlign).
343
+ // A component of its own - rather than a conditional hook call inside EditRow - so the hook is always called
344
+ // unconditionally here, and the plain path below stays byte-identical to what it was. Sized exactly as
345
+ // InlineColorPicker sizes itself (the default swatch list at the raw window width), so the host's placement and
346
+ // the popover it positions agree on its footprint.
347
+ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']> }) {
348
+ const { width: windowWidth } = useWindowDimensions()
349
+ const { width, height } = getInlineColorPickerContentSize(defaultColors.length, windowWidth)
350
+ const alignOverride = useAlign(props.host.openId === props.id, width, height)
351
+ return <InlineColorPicker {...props} alignOverride={alignOverride} />
326
352
  }
327
353
 
328
354
  // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
@@ -337,7 +363,7 @@ interface EditRowProps {
337
363
  // own — submitting the *name* field is what actually commits the row, so its registration's own
338
364
  // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
339
365
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
340
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }: EditRowProps) {
366
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
341
367
  const register = useFocusChain()
342
368
  const tag = register()
343
369
  const name = register()
@@ -364,7 +390,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
364
390
 
365
391
  return (
366
392
  <View style={styles.editRow}>
367
- <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />
393
+ {useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />}
368
394
  {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
369
395
  doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
370
396
  value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
@@ -3,7 +3,7 @@ import { IconButton } from '@rific/feedback-press'
3
3
  import { useRef } from 'react'
4
4
  import { StyleSheet, View } from 'react-native'
5
5
 
6
- import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle } from './ProfilesManager'
6
+ import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle, ProfilesManagerProps } from './ProfilesManager'
7
7
  import { Profile } from './types'
8
8
 
9
9
  // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
@@ -40,6 +40,10 @@ export interface ProfilesScreenProps {
40
40
  cardBg?: string
41
41
  titleVariant?: MD3TextVariant
42
42
  colorPreview?: (hex: string) => string
43
+ // Forwarded straight through to the internal ProfilesManager - a placement hook for the color swatch popover, for
44
+ // a host inside a fake-rotated frame. See ProfilesManagerProps.useColorPickerAlign for what it is and its hud >= 0.12.0
45
+ // requirement.
46
+ useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign']
43
47
  }
44
48
 
45
49
  // The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
@@ -49,7 +53,7 @@ export interface ProfilesScreenProps {
49
53
  // still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself — those two
50
54
  // hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
51
55
  // app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
52
- export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps) {
56
+ export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps) {
53
57
  const { dark } = useAutoPaperTheme()
54
58
  // Owns the ref itself — nothing outside this component ever needs it, since this is now the one
55
59
  // thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
@@ -71,7 +75,7 @@ export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDel
71
75
 
72
76
  return (
73
77
  <View style={[styles.container, { backgroundColor: bg }]}>
74
- <ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted} cardBg={cardBg} titleVariant={titleVariant} colorPreview={colorPreview} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
78
+ <ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted} cardBg={cardBg} titleVariant={titleVariant} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
75
79
  </View>
76
80
  )
77
81
  }
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'