@poly-x/react 0.1.0-alpha.10 → 0.1.0-alpha.12

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/dist/index.cjs CHANGED
@@ -1409,6 +1409,59 @@ function AuthCallback({ children = null }) {
1409
1409
  return children;
1410
1410
  }
1411
1411
  //#endregion
1412
+ //#region src/components/placement.ts
1413
+ const OPPOSITE = {
1414
+ top: "bottom",
1415
+ bottom: "top",
1416
+ left: "right",
1417
+ right: "left"
1418
+ };
1419
+ /** Preference order when nothing is requested: vertical reads better for a menu than sideways. */
1420
+ const AUTO_ORDER = [
1421
+ "bottom",
1422
+ "top",
1423
+ "right",
1424
+ "left"
1425
+ ];
1426
+ /** Room between the trigger and each viewport edge. */
1427
+ function spaceAround(trigger, viewport) {
1428
+ return {
1429
+ top: trigger.top,
1430
+ bottom: viewport.height - trigger.bottom,
1431
+ left: trigger.left,
1432
+ right: viewport.width - trigger.right
1433
+ };
1434
+ }
1435
+ function required(menu, gap) {
1436
+ return {
1437
+ top: menu.height + gap,
1438
+ bottom: menu.height + gap,
1439
+ left: menu.width + gap,
1440
+ right: menu.width + gap
1441
+ };
1442
+ }
1443
+ /**
1444
+ * Which side the menu should open on.
1445
+ *
1446
+ * An explicit side is honoured when it fits and flipped to its opposite when it doesn't — a
1447
+ * requested side that runs off-screen is a worse answer than the one that doesn't. `auto` takes
1448
+ * the first side that fits, and if none do, the roomiest: something has to be chosen, and the
1449
+ * least-bad option beats an arbitrary default.
1450
+ */
1451
+ function resolveSide(preferred, trigger, menu, viewport, gap = 8) {
1452
+ const space = spaceAround(trigger, viewport);
1453
+ const need = required(menu, gap);
1454
+ const fits = (side) => space[side] >= need[side];
1455
+ if (preferred !== "auto") {
1456
+ if (fits(preferred)) return preferred;
1457
+ const opposite = OPPOSITE[preferred];
1458
+ return fits(opposite) ? opposite : preferred;
1459
+ }
1460
+ const firstFitting = AUTO_ORDER.find(fits);
1461
+ if (firstFitting) return firstFitting;
1462
+ return AUTO_ORDER.reduce((best, side) => space[side] > space[best] ? side : best, AUTO_ORDER[0]);
1463
+ }
1464
+ //#endregion
1412
1465
  //#region src/components/user-avatar.tsx
1413
1466
  /**
1414
1467
  * Up to two initials from a display name. Handles the shapes the platform actually
@@ -1462,9 +1515,13 @@ function isBuiltIn(label) {
1462
1515
  }
1463
1516
  /**
1464
1517
  * Declarative markers. `<UserButton/>` reads their props off the element tree and renders the
1465
- * menu itself, so these never render anything which is also what keeps the menu's markup
1466
- * ours and a consumer's styles out of it (FR-BRAND-1). Their public prop types come from the
1467
- * signatures declared on the exported object below.
1518
+ * menu itself, so these never render anything. Their public prop types come from the signatures
1519
+ * declared on the exported object below.
1520
+ *
1521
+ * `Custom` and `Header` take arbitrary nodes on purpose: an app adopting this component in place
1522
+ * of its own user menu has to be able to bring its own UI, or it won't adopt it. That is
1523
+ * composition, not the style injection FR-BRAND-1 guards against — a consumer renders their own
1524
+ * subtree in a slot; they still cannot restyle the SDK's own chrome.
1468
1525
  */
1469
1526
  function MenuItems() {
1470
1527
  return null;
@@ -1475,6 +1532,12 @@ function Action() {
1475
1532
  function Link() {
1476
1533
  return null;
1477
1534
  }
1535
+ function Custom() {
1536
+ return null;
1537
+ }
1538
+ function Header() {
1539
+ return null;
1540
+ }
1478
1541
  /**
1479
1542
  * Read the declarative children into a flat item list. A custom entry naming a built-in
1480
1543
  * *moves* it; unnamed built-ins keep their default order at the end. This is the whole reason
@@ -1482,7 +1545,7 @@ function Link() {
1482
1545
  * inject arbitrary markup or styles into the menu (FR-BRAND-1).
1483
1546
  */
1484
1547
  function resolveItems(children, builtIns) {
1485
- const custom = [];
1548
+ const supplied = [];
1486
1549
  const placed = /* @__PURE__ */ new Set();
1487
1550
  react.Children.forEach(children, (child) => {
1488
1551
  if (!(0, react.isValidElement)(child) || child.type !== MenuItems) return;
@@ -1493,12 +1556,12 @@ function resolveItems(children, builtIns) {
1493
1556
  if (isBuiltIn(props.label)) {
1494
1557
  const builtIn = builtIns.get(props.label);
1495
1558
  if (builtIn) {
1496
- custom.push(builtIn);
1559
+ supplied.push(builtIn);
1497
1560
  placed.add(props.label);
1498
1561
  }
1499
1562
  return;
1500
1563
  }
1501
- custom.push({
1564
+ supplied.push({
1502
1565
  key: `action-${index}`,
1503
1566
  label: props.label,
1504
1567
  labelIcon: props.labelIcon,
@@ -1508,17 +1571,30 @@ function resolveItems(children, builtIns) {
1508
1571
  }
1509
1572
  if (item.type === Link) {
1510
1573
  const props = item.props;
1511
- custom.push({
1574
+ supplied.push({
1512
1575
  key: `link-${index}`,
1513
1576
  label: props.label,
1514
1577
  labelIcon: props.labelIcon,
1515
1578
  href: props.href
1516
1579
  });
1580
+ return;
1517
1581
  }
1582
+ if (item.type === Custom) supplied.push({
1583
+ key: `custom-${index}`,
1584
+ custom: item.props.children
1585
+ });
1518
1586
  });
1519
1587
  });
1520
1588
  const remaining = [...builtIns.entries()].filter(([name]) => !placed.has(name)).map(([, item]) => item);
1521
- return [...custom, ...remaining];
1589
+ return [...supplied, ...remaining];
1590
+ }
1591
+ /** The consumer's replacement for the identity block, if they supplied one. */
1592
+ function findHeader(children) {
1593
+ let header;
1594
+ react.Children.forEach(children, (child) => {
1595
+ if ((0, react.isValidElement)(child) && child.type === Header) header = child.props.children;
1596
+ });
1597
+ return header;
1522
1598
  }
1523
1599
  function UserButtonRoot(props) {
1524
1600
  const labels = {
@@ -1529,13 +1605,25 @@ function UserButtonRoot(props) {
1529
1605
  const user = useUser();
1530
1606
  const [open, setOpen] = (0, react.useState)(props.defaultOpen ?? false);
1531
1607
  const [focused, setFocused] = (0, react.useState)(0);
1608
+ const [side, setSide] = (0, react.useState)(props.side && props.side !== "auto" ? props.side : "bottom");
1532
1609
  const triggerRef = (0, react.useRef)(null);
1610
+ const menuRef = (0, react.useRef)(null);
1533
1611
  const itemRefs = (0, react.useRef)([]);
1534
1612
  const menuId = (0, react.useId)();
1613
+ const align = props.align ?? "end";
1535
1614
  const close = (0, react.useCallback)((returnFocus) => {
1536
1615
  setOpen(false);
1537
1616
  if (returnFocus) triggerRef.current?.focus();
1538
1617
  }, []);
1618
+ const onOpenChange = props.onOpenChange;
1619
+ const mounted = (0, react.useRef)(false);
1620
+ (0, react.useEffect)(() => {
1621
+ if (!mounted.current) {
1622
+ mounted.current = true;
1623
+ return;
1624
+ }
1625
+ onOpenChange?.(open);
1626
+ }, [open, onOpenChange]);
1539
1627
  (0, react.useEffect)(() => {
1540
1628
  if (!open) return;
1541
1629
  const onKeyDown = (event) => {
@@ -1555,6 +1643,33 @@ function UserButtonRoot(props) {
1555
1643
  (0, react.useEffect)(() => {
1556
1644
  if (open) itemRefs.current[focused]?.focus();
1557
1645
  }, [open, focused]);
1646
+ /**
1647
+ * Choose the side once the menu exists and can be measured. `useLayoutEffect` runs before
1648
+ * paint, so the correction never shows as a jump — the menu is only ever painted where it
1649
+ * belongs. Re-measured on resize and on scroll, since either can invalidate the choice.
1650
+ */
1651
+ (0, react.useLayoutEffect)(() => {
1652
+ if (!open) return;
1653
+ const place = () => {
1654
+ const trigger = triggerRef.current?.getBoundingClientRect();
1655
+ const menu = menuRef.current?.getBoundingClientRect();
1656
+ if (!trigger || !menu) return;
1657
+ setSide(resolveSide(props.side ?? "auto", trigger, {
1658
+ width: menu.width,
1659
+ height: menu.height
1660
+ }, {
1661
+ width: window.innerWidth,
1662
+ height: window.innerHeight
1663
+ }));
1664
+ };
1665
+ place();
1666
+ window.addEventListener("resize", place);
1667
+ window.addEventListener("scroll", place, true);
1668
+ return () => {
1669
+ window.removeEventListener("resize", place);
1670
+ window.removeEventListener("scroll", place, true);
1671
+ };
1672
+ }, [open, props.side]);
1558
1673
  if (!isLoaded) return props.fallback ?? null;
1559
1674
  if (!isSignedIn || !user) return null;
1560
1675
  const name = user.displayName ?? user.email ?? "";
@@ -1581,14 +1696,17 @@ function UserButtonRoot(props) {
1581
1696
  })
1582
1697
  });
1583
1698
  const items = resolveItems(props.children, builtIns);
1699
+ const customHeader = findHeader(props.children);
1700
+ const navigable = items.map((item, index) => ({
1701
+ item,
1702
+ index
1703
+ })).filter(({ item }) => !item.custom);
1584
1704
  const onMenuKeyDown = (event) => {
1585
- if (event.key === "ArrowDown") {
1586
- event.preventDefault();
1587
- setFocused((current) => (current + 1) % items.length);
1588
- } else if (event.key === "ArrowUp") {
1589
- event.preventDefault();
1590
- setFocused((current) => (current - 1 + items.length) % items.length);
1591
- }
1705
+ if (navigable.length === 0) return;
1706
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1707
+ event.preventDefault();
1708
+ const next = (navigable.findIndex(({ index }) => index === focused) + (event.key === "ArrowDown" ? 1 : -1) + navigable.length) % navigable.length;
1709
+ setFocused(navigable[next].index);
1592
1710
  };
1593
1711
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1594
1712
  className: [
@@ -1597,10 +1715,10 @@ function UserButtonRoot(props) {
1597
1715
  props.className
1598
1716
  ].filter(Boolean).join(" "),
1599
1717
  "data-polyx-userbutton": open ? "open" : "closed",
1600
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1718
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1601
1719
  ref: triggerRef,
1602
1720
  type: "button",
1603
- className: "polyx-userbutton__trigger",
1721
+ className: ["polyx-userbutton__trigger", props.triggerClassName].filter(Boolean).join(" "),
1604
1722
  "aria-haspopup": "menu",
1605
1723
  "aria-expanded": open,
1606
1724
  "aria-controls": open ? menuId : void 0,
@@ -1609,20 +1727,23 @@ function UserButtonRoot(props) {
1609
1727
  setFocused(0);
1610
1728
  setOpen((current) => !current);
1611
1729
  },
1612
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1730
+ children: props.trigger ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1613
1731
  src: user.avatarUrl,
1614
1732
  name,
1615
1733
  size: props.avatarSize
1616
1734
  }), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1617
1735
  className: "polyx-userbutton__trigger-name",
1618
1736
  children: name
1619
- }) : null]
1737
+ }) : null] })
1620
1738
  }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1739
+ ref: menuRef,
1621
1740
  id: menuId,
1622
1741
  role: "menu",
1623
1742
  className: "polyx-userbutton__menu",
1743
+ "data-polyx-side": side,
1744
+ "data-polyx-align": align,
1624
1745
  onKeyDown: onMenuKeyDown,
1625
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1746
+ children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1626
1747
  className: "polyx-userbutton__identity",
1627
1748
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1628
1749
  src: user.avatarUrl,
@@ -1640,7 +1761,10 @@ function UserButtonRoot(props) {
1640
1761
  })]
1641
1762
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1642
1763
  className: "polyx-userbutton__items",
1643
- children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
1764
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.custom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1765
+ className: "polyx-userbutton__custom",
1766
+ children: item.custom
1767
+ }) : item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
1644
1768
  ref: (node) => {
1645
1769
  itemRefs.current[index] = node;
1646
1770
  },
@@ -1725,7 +1849,9 @@ function ExitIcon() {
1725
1849
  const UserButton = Object.assign(UserButtonRoot, {
1726
1850
  MenuItems,
1727
1851
  Action,
1728
- Link
1852
+ Link,
1853
+ Custom,
1854
+ Header
1729
1855
  });
1730
1856
  //#endregion
1731
1857
  //#region src/index.ts
@@ -1753,6 +1879,8 @@ exports.UserButton = UserButton;
1753
1879
  exports.WebLocksLock = WebLocksLock;
1754
1880
  exports.WindowBrowserBridge = WindowBrowserBridge;
1755
1881
  exports.createLoginController = createLoginController;
1882
+ exports.initialsFrom = initialsFrom;
1883
+ exports.resolveSide = resolveSide;
1756
1884
  exports.useAuth = useAuth;
1757
1885
  exports.useAuthCallback = useAuthCallback;
1758
1886
  exports.useSession = useSession;
package/dist/index.d.cts CHANGED
@@ -386,6 +386,46 @@ declare function AuthCallback({
386
386
  children
387
387
  }: AuthCallbackProps): ReactNode;
388
388
  //#endregion
389
+ //#region src/components/placement.d.ts
390
+ /**
391
+ * Collision-aware placement for the SDK's popovers.
392
+ *
393
+ * A menu pinned to one side is wrong somewhere: an avatar in a page header wants to open
394
+ * down-and-left, the same component in a sidebar footer has to open up-and-right. Rather than
395
+ * make every consumer work that out, measure the trigger against the viewport and pick.
396
+ *
397
+ * Deliberately hand-rolled: `@poly-x/react` has no runtime dependency but `@poly-x/core`, and a
398
+ * full positioning engine is a lot of surface to inherit for one menu. The component only needs
399
+ * to know *which side* — CSS does the actual placing — so this stays a pure function over
400
+ * rectangles, and is unit-testable without a layout engine.
401
+ */
402
+ type Side = "top" | "bottom" | "left" | "right";
403
+ type SideOption = Side | "auto";
404
+ type Align = "start" | "center" | "end";
405
+ interface Rect {
406
+ top: number;
407
+ bottom: number;
408
+ left: number;
409
+ right: number;
410
+ }
411
+ interface Size {
412
+ width: number;
413
+ height: number;
414
+ }
415
+ interface Viewport {
416
+ width: number;
417
+ height: number;
418
+ }
419
+ /**
420
+ * Which side the menu should open on.
421
+ *
422
+ * An explicit side is honoured when it fits and flipped to its opposite when it doesn't — a
423
+ * requested side that runs off-screen is a worse answer than the one that doesn't. `auto` takes
424
+ * the first side that fits, and if none do, the roomiest: something has to be chosen, and the
425
+ * least-bad option beats an arbitrary default.
426
+ */
427
+ declare function resolveSide(preferred: SideOption, trigger: Rect, menu: Size, viewport: Viewport, gap?: number): Side;
428
+ //#endregion
389
429
  //#region src/components/user-button.d.ts
390
430
  /** Every user-facing string, overridable for i18n / white-labelling. */
391
431
  interface UserButtonLabels {
@@ -409,6 +449,9 @@ interface UserButtonLinkProps {
409
449
  labelIcon?: ReactNode;
410
450
  href: string;
411
451
  }
452
+ interface UserButtonSlotProps {
453
+ children?: ReactNode;
454
+ }
412
455
  interface UserButtonProps {
413
456
  /** Show the user's name next to the avatar. Default `false`. */
414
457
  showName?: boolean;
@@ -427,11 +470,34 @@ interface UserButtonProps {
427
470
  fallback?: ReactNode;
428
471
  /** Appended to the root element's class list, for escape-hatch styling. */
429
472
  className?: string;
473
+ /**
474
+ * Appended to the trigger button's class list. The SDK owns that button, so without this a
475
+ * consumer could style everything except the one element they place in their own layout.
476
+ */
477
+ triggerClassName?: string;
478
+ /** Notified when the menu opens or closes — hosts often coordinate their own chrome around it. */
479
+ onOpenChange?: (open: boolean) => void;
430
480
  labels?: Partial<UserButtonLabels>;
431
- /** `<UserButton.MenuItems>` with custom `<UserButton.Action>` / `<UserButton.Link>` entries. */
481
+ /**
482
+ * `<UserButton.MenuItems>` with `<UserButton.Action>` / `<UserButton.Link>` / `<UserButton.Custom>`
483
+ * entries, and/or a `<UserButton.Header>` replacing the identity block.
484
+ */
432
485
  children?: ReactNode;
433
486
  /** Avatar size in pixels. Default 32. */
434
487
  avatarSize?: number;
488
+ /**
489
+ * What the trigger shows. Replaces the avatar (and `showName`); the SDK still owns the button
490
+ * and its menu semantics, so a custom trigger costs no accessibility.
491
+ */
492
+ trigger?: ReactNode;
493
+ /**
494
+ * Which side the menu opens on. Default `auto` — measured against the viewport, because the
495
+ * right answer differs by where the trigger sits (a header avatar opens down, a sidebar-footer
496
+ * one has to open up). An explicit side is flipped only if it would run off-screen.
497
+ */
498
+ side?: SideOption;
499
+ /** Alignment along the chosen side. Default `end`. */
500
+ align?: Align;
435
501
  }
436
502
  /**
437
503
  * The signed-in user surface: avatar trigger + a menu with their identity and sign-out
@@ -451,11 +517,11 @@ interface UserButtonProps {
451
517
  * ```
452
518
  */
453
519
  declare const UserButton: ((props: UserButtonProps) => ReactNode) & {
454
- MenuItems: (props: {
455
- children?: ReactNode;
456
- }) => ReactNode;
520
+ MenuItems: (props: UserButtonSlotProps) => ReactNode;
457
521
  Action: (props: UserButtonActionProps) => ReactNode;
458
- Link: (props: UserButtonLinkProps) => ReactNode;
522
+ Link: (props: UserButtonLinkProps) => ReactNode; /** Arbitrary JSX as a menu row — bring your own controls. */
523
+ Custom: (props: UserButtonSlotProps) => ReactNode; /** Replaces the built-in identity block. */
524
+ Header: (props: UserButtonSlotProps) => ReactNode;
459
525
  };
460
526
  //#endregion
461
527
  //#region src/components/user-avatar.d.ts
@@ -469,6 +535,11 @@ interface UserAvatarProps {
469
535
  /** Appended to the root element's class list, for escape-hatch styling. */
470
536
  className?: string;
471
537
  }
538
+ /**
539
+ * Up to two initials from a display name. Handles the shapes the platform actually
540
+ * produces — "Ali Ikram", a bare username, an email address.
541
+ */
542
+ declare function initialsFrom(name: string | undefined): string;
472
543
  /**
473
544
  * The user's picture, or their initials. A missing avatar is the ordinary case rather than
474
545
  * an error, so initials are a first-class state, not a broken image.
@@ -488,4 +559,4 @@ declare function UserAvatar({
488
559
  declare const PACKAGE_NAME = "@poly-x/react";
489
560
  declare const version = "0.0.0";
490
561
  //#endregion
491
- export { AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
562
+ export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, WebLocksLock, WindowBrowserBridge, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };
package/dist/index.d.mts CHANGED
@@ -386,6 +386,46 @@ declare function AuthCallback({
386
386
  children
387
387
  }: AuthCallbackProps): ReactNode;
388
388
  //#endregion
389
+ //#region src/components/placement.d.ts
390
+ /**
391
+ * Collision-aware placement for the SDK's popovers.
392
+ *
393
+ * A menu pinned to one side is wrong somewhere: an avatar in a page header wants to open
394
+ * down-and-left, the same component in a sidebar footer has to open up-and-right. Rather than
395
+ * make every consumer work that out, measure the trigger against the viewport and pick.
396
+ *
397
+ * Deliberately hand-rolled: `@poly-x/react` has no runtime dependency but `@poly-x/core`, and a
398
+ * full positioning engine is a lot of surface to inherit for one menu. The component only needs
399
+ * to know *which side* — CSS does the actual placing — so this stays a pure function over
400
+ * rectangles, and is unit-testable without a layout engine.
401
+ */
402
+ type Side = "top" | "bottom" | "left" | "right";
403
+ type SideOption = Side | "auto";
404
+ type Align = "start" | "center" | "end";
405
+ interface Rect {
406
+ top: number;
407
+ bottom: number;
408
+ left: number;
409
+ right: number;
410
+ }
411
+ interface Size {
412
+ width: number;
413
+ height: number;
414
+ }
415
+ interface Viewport {
416
+ width: number;
417
+ height: number;
418
+ }
419
+ /**
420
+ * Which side the menu should open on.
421
+ *
422
+ * An explicit side is honoured when it fits and flipped to its opposite when it doesn't — a
423
+ * requested side that runs off-screen is a worse answer than the one that doesn't. `auto` takes
424
+ * the first side that fits, and if none do, the roomiest: something has to be chosen, and the
425
+ * least-bad option beats an arbitrary default.
426
+ */
427
+ declare function resolveSide(preferred: SideOption, trigger: Rect, menu: Size, viewport: Viewport, gap?: number): Side;
428
+ //#endregion
389
429
  //#region src/components/user-button.d.ts
390
430
  /** Every user-facing string, overridable for i18n / white-labelling. */
391
431
  interface UserButtonLabels {
@@ -409,6 +449,9 @@ interface UserButtonLinkProps {
409
449
  labelIcon?: ReactNode;
410
450
  href: string;
411
451
  }
452
+ interface UserButtonSlotProps {
453
+ children?: ReactNode;
454
+ }
412
455
  interface UserButtonProps {
413
456
  /** Show the user's name next to the avatar. Default `false`. */
414
457
  showName?: boolean;
@@ -427,11 +470,34 @@ interface UserButtonProps {
427
470
  fallback?: ReactNode;
428
471
  /** Appended to the root element's class list, for escape-hatch styling. */
429
472
  className?: string;
473
+ /**
474
+ * Appended to the trigger button's class list. The SDK owns that button, so without this a
475
+ * consumer could style everything except the one element they place in their own layout.
476
+ */
477
+ triggerClassName?: string;
478
+ /** Notified when the menu opens or closes — hosts often coordinate their own chrome around it. */
479
+ onOpenChange?: (open: boolean) => void;
430
480
  labels?: Partial<UserButtonLabels>;
431
- /** `<UserButton.MenuItems>` with custom `<UserButton.Action>` / `<UserButton.Link>` entries. */
481
+ /**
482
+ * `<UserButton.MenuItems>` with `<UserButton.Action>` / `<UserButton.Link>` / `<UserButton.Custom>`
483
+ * entries, and/or a `<UserButton.Header>` replacing the identity block.
484
+ */
432
485
  children?: ReactNode;
433
486
  /** Avatar size in pixels. Default 32. */
434
487
  avatarSize?: number;
488
+ /**
489
+ * What the trigger shows. Replaces the avatar (and `showName`); the SDK still owns the button
490
+ * and its menu semantics, so a custom trigger costs no accessibility.
491
+ */
492
+ trigger?: ReactNode;
493
+ /**
494
+ * Which side the menu opens on. Default `auto` — measured against the viewport, because the
495
+ * right answer differs by where the trigger sits (a header avatar opens down, a sidebar-footer
496
+ * one has to open up). An explicit side is flipped only if it would run off-screen.
497
+ */
498
+ side?: SideOption;
499
+ /** Alignment along the chosen side. Default `end`. */
500
+ align?: Align;
435
501
  }
436
502
  /**
437
503
  * The signed-in user surface: avatar trigger + a menu with their identity and sign-out
@@ -451,11 +517,11 @@ interface UserButtonProps {
451
517
  * ```
452
518
  */
453
519
  declare const UserButton: ((props: UserButtonProps) => ReactNode) & {
454
- MenuItems: (props: {
455
- children?: ReactNode;
456
- }) => ReactNode;
520
+ MenuItems: (props: UserButtonSlotProps) => ReactNode;
457
521
  Action: (props: UserButtonActionProps) => ReactNode;
458
- Link: (props: UserButtonLinkProps) => ReactNode;
522
+ Link: (props: UserButtonLinkProps) => ReactNode; /** Arbitrary JSX as a menu row — bring your own controls. */
523
+ Custom: (props: UserButtonSlotProps) => ReactNode; /** Replaces the built-in identity block. */
524
+ Header: (props: UserButtonSlotProps) => ReactNode;
459
525
  };
460
526
  //#endregion
461
527
  //#region src/components/user-avatar.d.ts
@@ -469,6 +535,11 @@ interface UserAvatarProps {
469
535
  /** Appended to the root element's class list, for escape-hatch styling. */
470
536
  className?: string;
471
537
  }
538
+ /**
539
+ * Up to two initials from a display name. Handles the shapes the platform actually
540
+ * produces — "Ali Ikram", a bare username, an email address.
541
+ */
542
+ declare function initialsFrom(name: string | undefined): string;
472
543
  /**
473
544
  * The user's picture, or their initials. A missing avatar is the ordinary case rather than
474
545
  * an error, so initials are a first-class state, not a broken image.
@@ -488,4 +559,4 @@ declare function UserAvatar({
488
559
  declare const PACKAGE_NAME = "@poly-x/react";
489
560
  declare const version = "0.0.0";
490
561
  //#endregion
491
- export { AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
562
+ export { type Align, AuthCallback, type AuthCallbackMessage, type AuthCallbackProps, type AuthChannel, BroadcastAuthChannel, BroadcastChannelSessionChannel, type BrowserBridge, type CallbackParams, ForgotPassword, type ForgotPasswordLabels, type ForgotPasswordProps, type LoginController, type LoginControllerDeps, PACKAGE_NAME, PolyXContext, type PolyXContextValue, PolyXProvider, type PolyXProviderProps, PopupCancelledError, type PopupHandle, Protected, type ProtectedProps, ResetPassword, type ResetPasswordLabels, type ResetPasswordProps, SessionStoragePkceStore, type Side, type SideOption, SignIn, type SignInLabels, type SignInOptions, type SignInProps, type SignInVariant, type UseAuth, UserAvatar, type UserAvatarProps, UserButton, type UserButtonActionProps, type UserButtonLabels, type UserButtonLinkProps, type UserButtonProps, type UserButtonSlotProps, WebLocksLock, WindowBrowserBridge, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, createSessionEngine, generatePkce, randomState, resolveConfig } from "@poly-x/core";
3
- import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
+ import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/auth-channel.ts
6
6
  var BroadcastAuthChannel = class {
@@ -1408,6 +1408,59 @@ function AuthCallback({ children = null }) {
1408
1408
  return children;
1409
1409
  }
1410
1410
  //#endregion
1411
+ //#region src/components/placement.ts
1412
+ const OPPOSITE = {
1413
+ top: "bottom",
1414
+ bottom: "top",
1415
+ left: "right",
1416
+ right: "left"
1417
+ };
1418
+ /** Preference order when nothing is requested: vertical reads better for a menu than sideways. */
1419
+ const AUTO_ORDER = [
1420
+ "bottom",
1421
+ "top",
1422
+ "right",
1423
+ "left"
1424
+ ];
1425
+ /** Room between the trigger and each viewport edge. */
1426
+ function spaceAround(trigger, viewport) {
1427
+ return {
1428
+ top: trigger.top,
1429
+ bottom: viewport.height - trigger.bottom,
1430
+ left: trigger.left,
1431
+ right: viewport.width - trigger.right
1432
+ };
1433
+ }
1434
+ function required(menu, gap) {
1435
+ return {
1436
+ top: menu.height + gap,
1437
+ bottom: menu.height + gap,
1438
+ left: menu.width + gap,
1439
+ right: menu.width + gap
1440
+ };
1441
+ }
1442
+ /**
1443
+ * Which side the menu should open on.
1444
+ *
1445
+ * An explicit side is honoured when it fits and flipped to its opposite when it doesn't — a
1446
+ * requested side that runs off-screen is a worse answer than the one that doesn't. `auto` takes
1447
+ * the first side that fits, and if none do, the roomiest: something has to be chosen, and the
1448
+ * least-bad option beats an arbitrary default.
1449
+ */
1450
+ function resolveSide(preferred, trigger, menu, viewport, gap = 8) {
1451
+ const space = spaceAround(trigger, viewport);
1452
+ const need = required(menu, gap);
1453
+ const fits = (side) => space[side] >= need[side];
1454
+ if (preferred !== "auto") {
1455
+ if (fits(preferred)) return preferred;
1456
+ const opposite = OPPOSITE[preferred];
1457
+ return fits(opposite) ? opposite : preferred;
1458
+ }
1459
+ const firstFitting = AUTO_ORDER.find(fits);
1460
+ if (firstFitting) return firstFitting;
1461
+ return AUTO_ORDER.reduce((best, side) => space[side] > space[best] ? side : best, AUTO_ORDER[0]);
1462
+ }
1463
+ //#endregion
1411
1464
  //#region src/components/user-avatar.tsx
1412
1465
  /**
1413
1466
  * Up to two initials from a display name. Handles the shapes the platform actually
@@ -1461,9 +1514,13 @@ function isBuiltIn(label) {
1461
1514
  }
1462
1515
  /**
1463
1516
  * Declarative markers. `<UserButton/>` reads their props off the element tree and renders the
1464
- * menu itself, so these never render anything which is also what keeps the menu's markup
1465
- * ours and a consumer's styles out of it (FR-BRAND-1). Their public prop types come from the
1466
- * signatures declared on the exported object below.
1517
+ * menu itself, so these never render anything. Their public prop types come from the signatures
1518
+ * declared on the exported object below.
1519
+ *
1520
+ * `Custom` and `Header` take arbitrary nodes on purpose: an app adopting this component in place
1521
+ * of its own user menu has to be able to bring its own UI, or it won't adopt it. That is
1522
+ * composition, not the style injection FR-BRAND-1 guards against — a consumer renders their own
1523
+ * subtree in a slot; they still cannot restyle the SDK's own chrome.
1467
1524
  */
1468
1525
  function MenuItems() {
1469
1526
  return null;
@@ -1474,6 +1531,12 @@ function Action() {
1474
1531
  function Link() {
1475
1532
  return null;
1476
1533
  }
1534
+ function Custom() {
1535
+ return null;
1536
+ }
1537
+ function Header() {
1538
+ return null;
1539
+ }
1477
1540
  /**
1478
1541
  * Read the declarative children into a flat item list. A custom entry naming a built-in
1479
1542
  * *moves* it; unnamed built-ins keep their default order at the end. This is the whole reason
@@ -1481,7 +1544,7 @@ function Link() {
1481
1544
  * inject arbitrary markup or styles into the menu (FR-BRAND-1).
1482
1545
  */
1483
1546
  function resolveItems(children, builtIns) {
1484
- const custom = [];
1547
+ const supplied = [];
1485
1548
  const placed = /* @__PURE__ */ new Set();
1486
1549
  Children.forEach(children, (child) => {
1487
1550
  if (!isValidElement(child) || child.type !== MenuItems) return;
@@ -1492,12 +1555,12 @@ function resolveItems(children, builtIns) {
1492
1555
  if (isBuiltIn(props.label)) {
1493
1556
  const builtIn = builtIns.get(props.label);
1494
1557
  if (builtIn) {
1495
- custom.push(builtIn);
1558
+ supplied.push(builtIn);
1496
1559
  placed.add(props.label);
1497
1560
  }
1498
1561
  return;
1499
1562
  }
1500
- custom.push({
1563
+ supplied.push({
1501
1564
  key: `action-${index}`,
1502
1565
  label: props.label,
1503
1566
  labelIcon: props.labelIcon,
@@ -1507,17 +1570,30 @@ function resolveItems(children, builtIns) {
1507
1570
  }
1508
1571
  if (item.type === Link) {
1509
1572
  const props = item.props;
1510
- custom.push({
1573
+ supplied.push({
1511
1574
  key: `link-${index}`,
1512
1575
  label: props.label,
1513
1576
  labelIcon: props.labelIcon,
1514
1577
  href: props.href
1515
1578
  });
1579
+ return;
1516
1580
  }
1581
+ if (item.type === Custom) supplied.push({
1582
+ key: `custom-${index}`,
1583
+ custom: item.props.children
1584
+ });
1517
1585
  });
1518
1586
  });
1519
1587
  const remaining = [...builtIns.entries()].filter(([name]) => !placed.has(name)).map(([, item]) => item);
1520
- return [...custom, ...remaining];
1588
+ return [...supplied, ...remaining];
1589
+ }
1590
+ /** The consumer's replacement for the identity block, if they supplied one. */
1591
+ function findHeader(children) {
1592
+ let header;
1593
+ Children.forEach(children, (child) => {
1594
+ if (isValidElement(child) && child.type === Header) header = child.props.children;
1595
+ });
1596
+ return header;
1521
1597
  }
1522
1598
  function UserButtonRoot(props) {
1523
1599
  const labels = {
@@ -1528,13 +1604,25 @@ function UserButtonRoot(props) {
1528
1604
  const user = useUser();
1529
1605
  const [open, setOpen] = useState(props.defaultOpen ?? false);
1530
1606
  const [focused, setFocused] = useState(0);
1607
+ const [side, setSide] = useState(props.side && props.side !== "auto" ? props.side : "bottom");
1531
1608
  const triggerRef = useRef(null);
1609
+ const menuRef = useRef(null);
1532
1610
  const itemRefs = useRef([]);
1533
1611
  const menuId = useId();
1612
+ const align = props.align ?? "end";
1534
1613
  const close = useCallback((returnFocus) => {
1535
1614
  setOpen(false);
1536
1615
  if (returnFocus) triggerRef.current?.focus();
1537
1616
  }, []);
1617
+ const onOpenChange = props.onOpenChange;
1618
+ const mounted = useRef(false);
1619
+ useEffect(() => {
1620
+ if (!mounted.current) {
1621
+ mounted.current = true;
1622
+ return;
1623
+ }
1624
+ onOpenChange?.(open);
1625
+ }, [open, onOpenChange]);
1538
1626
  useEffect(() => {
1539
1627
  if (!open) return;
1540
1628
  const onKeyDown = (event) => {
@@ -1554,6 +1642,33 @@ function UserButtonRoot(props) {
1554
1642
  useEffect(() => {
1555
1643
  if (open) itemRefs.current[focused]?.focus();
1556
1644
  }, [open, focused]);
1645
+ /**
1646
+ * Choose the side once the menu exists and can be measured. `useLayoutEffect` runs before
1647
+ * paint, so the correction never shows as a jump — the menu is only ever painted where it
1648
+ * belongs. Re-measured on resize and on scroll, since either can invalidate the choice.
1649
+ */
1650
+ useLayoutEffect(() => {
1651
+ if (!open) return;
1652
+ const place = () => {
1653
+ const trigger = triggerRef.current?.getBoundingClientRect();
1654
+ const menu = menuRef.current?.getBoundingClientRect();
1655
+ if (!trigger || !menu) return;
1656
+ setSide(resolveSide(props.side ?? "auto", trigger, {
1657
+ width: menu.width,
1658
+ height: menu.height
1659
+ }, {
1660
+ width: window.innerWidth,
1661
+ height: window.innerHeight
1662
+ }));
1663
+ };
1664
+ place();
1665
+ window.addEventListener("resize", place);
1666
+ window.addEventListener("scroll", place, true);
1667
+ return () => {
1668
+ window.removeEventListener("resize", place);
1669
+ window.removeEventListener("scroll", place, true);
1670
+ };
1671
+ }, [open, props.side]);
1557
1672
  if (!isLoaded) return props.fallback ?? null;
1558
1673
  if (!isSignedIn || !user) return null;
1559
1674
  const name = user.displayName ?? user.email ?? "";
@@ -1580,14 +1695,17 @@ function UserButtonRoot(props) {
1580
1695
  })
1581
1696
  });
1582
1697
  const items = resolveItems(props.children, builtIns);
1698
+ const customHeader = findHeader(props.children);
1699
+ const navigable = items.map((item, index) => ({
1700
+ item,
1701
+ index
1702
+ })).filter(({ item }) => !item.custom);
1583
1703
  const onMenuKeyDown = (event) => {
1584
- if (event.key === "ArrowDown") {
1585
- event.preventDefault();
1586
- setFocused((current) => (current + 1) % items.length);
1587
- } else if (event.key === "ArrowUp") {
1588
- event.preventDefault();
1589
- setFocused((current) => (current - 1 + items.length) % items.length);
1590
- }
1704
+ if (navigable.length === 0) return;
1705
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1706
+ event.preventDefault();
1707
+ const next = (navigable.findIndex(({ index }) => index === focused) + (event.key === "ArrowDown" ? 1 : -1) + navigable.length) % navigable.length;
1708
+ setFocused(navigable[next].index);
1591
1709
  };
1592
1710
  return /* @__PURE__ */ jsxs("div", {
1593
1711
  className: [
@@ -1596,10 +1714,10 @@ function UserButtonRoot(props) {
1596
1714
  props.className
1597
1715
  ].filter(Boolean).join(" "),
1598
1716
  "data-polyx-userbutton": open ? "open" : "closed",
1599
- children: [/* @__PURE__ */ jsxs("button", {
1717
+ children: [/* @__PURE__ */ jsx("button", {
1600
1718
  ref: triggerRef,
1601
1719
  type: "button",
1602
- className: "polyx-userbutton__trigger",
1720
+ className: ["polyx-userbutton__trigger", props.triggerClassName].filter(Boolean).join(" "),
1603
1721
  "aria-haspopup": "menu",
1604
1722
  "aria-expanded": open,
1605
1723
  "aria-controls": open ? menuId : void 0,
@@ -1608,20 +1726,23 @@ function UserButtonRoot(props) {
1608
1726
  setFocused(0);
1609
1727
  setOpen((current) => !current);
1610
1728
  },
1611
- children: [/* @__PURE__ */ jsx(UserAvatar, {
1729
+ children: props.trigger ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UserAvatar, {
1612
1730
  src: user.avatarUrl,
1613
1731
  name,
1614
1732
  size: props.avatarSize
1615
1733
  }), props.showName ? /* @__PURE__ */ jsx("span", {
1616
1734
  className: "polyx-userbutton__trigger-name",
1617
1735
  children: name
1618
- }) : null]
1736
+ }) : null] })
1619
1737
  }), open ? /* @__PURE__ */ jsxs("div", {
1738
+ ref: menuRef,
1620
1739
  id: menuId,
1621
1740
  role: "menu",
1622
1741
  className: "polyx-userbutton__menu",
1742
+ "data-polyx-side": side,
1743
+ "data-polyx-align": align,
1623
1744
  onKeyDown: onMenuKeyDown,
1624
- children: [/* @__PURE__ */ jsxs("div", {
1745
+ children: [customHeader ?? /* @__PURE__ */ jsxs("div", {
1625
1746
  className: "polyx-userbutton__identity",
1626
1747
  children: [/* @__PURE__ */ jsx(UserAvatar, {
1627
1748
  src: user.avatarUrl,
@@ -1639,7 +1760,10 @@ function UserButtonRoot(props) {
1639
1760
  })]
1640
1761
  }), /* @__PURE__ */ jsx("ul", {
1641
1762
  className: "polyx-userbutton__items",
1642
- children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: item.href ? /* @__PURE__ */ jsxs("a", {
1763
+ children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: item.custom ? /* @__PURE__ */ jsx("div", {
1764
+ className: "polyx-userbutton__custom",
1765
+ children: item.custom
1766
+ }) : item.href ? /* @__PURE__ */ jsxs("a", {
1643
1767
  ref: (node) => {
1644
1768
  itemRefs.current[index] = node;
1645
1769
  },
@@ -1724,7 +1848,9 @@ function ExitIcon() {
1724
1848
  const UserButton = Object.assign(UserButtonRoot, {
1725
1849
  MenuItems,
1726
1850
  Action,
1727
- Link
1851
+ Link,
1852
+ Custom,
1853
+ Header
1728
1854
  });
1729
1855
  //#endregion
1730
1856
  //#region src/index.ts
@@ -1735,4 +1861,4 @@ const UserButton = Object.assign(UserButtonRoot, {
1735
1861
  const PACKAGE_NAME = "@poly-x/react";
1736
1862
  const version = "0.0.0";
1737
1863
  //#endregion
1738
- export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
1864
+ export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, WebLocksLock, WindowBrowserBridge, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };
package/dist/styles.css CHANGED
@@ -548,10 +548,12 @@
548
548
  user-select: none;
549
549
  }
550
550
 
551
+ /* Placement is expressed as data attributes and resolved in CSS: the component measures which
552
+ side has room and says so; where that lands is a styling concern. `--polyx-gap` is the space
553
+ between trigger and menu, and the JS assumes the same 8px when it measures. */
551
554
  .polyx-userbutton__menu {
555
+ --polyx-gap: 0.5rem;
552
556
  position: absolute;
553
- top: calc(100% + 0.5rem);
554
- right: 0;
555
557
  z-index: 50;
556
558
  min-width: 15rem;
557
559
  padding: 0.375rem;
@@ -561,6 +563,54 @@
561
563
  box-shadow: var(--polyx-shadow);
562
564
  }
563
565
 
566
+ /* Vertical sides: offset above/below, then align along the horizontal axis. */
567
+ .polyx-userbutton__menu[data-polyx-side="bottom"] {
568
+ top: calc(100% + var(--polyx-gap));
569
+ }
570
+ .polyx-userbutton__menu[data-polyx-side="top"] {
571
+ bottom: calc(100% + var(--polyx-gap));
572
+ }
573
+ .polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="end"],
574
+ .polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="end"] {
575
+ right: 0;
576
+ }
577
+ .polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="start"],
578
+ .polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="start"] {
579
+ left: 0;
580
+ }
581
+ .polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="center"],
582
+ .polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="center"] {
583
+ left: 50%;
584
+ transform: translateX(-50%);
585
+ }
586
+
587
+ /* Horizontal sides: offset left/right, then align along the vertical axis. */
588
+ .polyx-userbutton__menu[data-polyx-side="right"] {
589
+ left: calc(100% + var(--polyx-gap));
590
+ }
591
+ .polyx-userbutton__menu[data-polyx-side="left"] {
592
+ right: calc(100% + var(--polyx-gap));
593
+ }
594
+ .polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="end"],
595
+ .polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="end"] {
596
+ bottom: 0;
597
+ }
598
+ .polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="start"],
599
+ .polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="start"] {
600
+ top: 0;
601
+ }
602
+ .polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="center"],
603
+ .polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="center"] {
604
+ top: 50%;
605
+ transform: translateY(-50%);
606
+ }
607
+
608
+ /* Consumer-supplied rows: give them room and get out of the way — no colours, no type, no
609
+ cursor. Whatever they render is theirs. */
610
+ .polyx-userbutton__custom {
611
+ padding: 0.375rem 0.625rem;
612
+ }
613
+
564
614
  .polyx-userbutton__identity {
565
615
  display: flex;
566
616
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/react",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "description": "PolyX SDK for React SPAs - provider, hooks, drop-in auth UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "module": "./dist/index.mjs",
28
28
  "types": "./dist/index.d.cts",
29
29
  "dependencies": {
30
- "@poly-x/core": "0.1.0-alpha.10"
30
+ "@poly-x/core": "0.1.0-alpha.12"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18",