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

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,9 +1605,12 @@ 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();
@@ -1555,6 +1634,33 @@ function UserButtonRoot(props) {
1555
1634
  (0, react.useEffect)(() => {
1556
1635
  if (open) itemRefs.current[focused]?.focus();
1557
1636
  }, [open, focused]);
1637
+ /**
1638
+ * Choose the side once the menu exists and can be measured. `useLayoutEffect` runs before
1639
+ * paint, so the correction never shows as a jump — the menu is only ever painted where it
1640
+ * belongs. Re-measured on resize and on scroll, since either can invalidate the choice.
1641
+ */
1642
+ (0, react.useLayoutEffect)(() => {
1643
+ if (!open) return;
1644
+ const place = () => {
1645
+ const trigger = triggerRef.current?.getBoundingClientRect();
1646
+ const menu = menuRef.current?.getBoundingClientRect();
1647
+ if (!trigger || !menu) return;
1648
+ setSide(resolveSide(props.side ?? "auto", trigger, {
1649
+ width: menu.width,
1650
+ height: menu.height
1651
+ }, {
1652
+ width: window.innerWidth,
1653
+ height: window.innerHeight
1654
+ }));
1655
+ };
1656
+ place();
1657
+ window.addEventListener("resize", place);
1658
+ window.addEventListener("scroll", place, true);
1659
+ return () => {
1660
+ window.removeEventListener("resize", place);
1661
+ window.removeEventListener("scroll", place, true);
1662
+ };
1663
+ }, [open, props.side]);
1558
1664
  if (!isLoaded) return props.fallback ?? null;
1559
1665
  if (!isSignedIn || !user) return null;
1560
1666
  const name = user.displayName ?? user.email ?? "";
@@ -1581,14 +1687,17 @@ function UserButtonRoot(props) {
1581
1687
  })
1582
1688
  });
1583
1689
  const items = resolveItems(props.children, builtIns);
1690
+ const customHeader = findHeader(props.children);
1691
+ const navigable = items.map((item, index) => ({
1692
+ item,
1693
+ index
1694
+ })).filter(({ item }) => !item.custom);
1584
1695
  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
- }
1696
+ if (navigable.length === 0) return;
1697
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1698
+ event.preventDefault();
1699
+ const next = (navigable.findIndex(({ index }) => index === focused) + (event.key === "ArrowDown" ? 1 : -1) + navigable.length) % navigable.length;
1700
+ setFocused(navigable[next].index);
1592
1701
  };
1593
1702
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1594
1703
  className: [
@@ -1597,7 +1706,7 @@ function UserButtonRoot(props) {
1597
1706
  props.className
1598
1707
  ].filter(Boolean).join(" "),
1599
1708
  "data-polyx-userbutton": open ? "open" : "closed",
1600
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1709
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1601
1710
  ref: triggerRef,
1602
1711
  type: "button",
1603
1712
  className: "polyx-userbutton__trigger",
@@ -1609,20 +1718,23 @@ function UserButtonRoot(props) {
1609
1718
  setFocused(0);
1610
1719
  setOpen((current) => !current);
1611
1720
  },
1612
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1721
+ children: props.trigger ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1613
1722
  src: user.avatarUrl,
1614
1723
  name,
1615
1724
  size: props.avatarSize
1616
1725
  }), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1617
1726
  className: "polyx-userbutton__trigger-name",
1618
1727
  children: name
1619
- }) : null]
1728
+ }) : null] })
1620
1729
  }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1730
+ ref: menuRef,
1621
1731
  id: menuId,
1622
1732
  role: "menu",
1623
1733
  className: "polyx-userbutton__menu",
1734
+ "data-polyx-side": side,
1735
+ "data-polyx-align": align,
1624
1736
  onKeyDown: onMenuKeyDown,
1625
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1737
+ children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1626
1738
  className: "polyx-userbutton__identity",
1627
1739
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1628
1740
  src: user.avatarUrl,
@@ -1640,7 +1752,10 @@ function UserButtonRoot(props) {
1640
1752
  })]
1641
1753
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1642
1754
  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", {
1755
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.custom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1756
+ className: "polyx-userbutton__custom",
1757
+ children: item.custom
1758
+ }) : item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
1644
1759
  ref: (node) => {
1645
1760
  itemRefs.current[index] = node;
1646
1761
  },
@@ -1725,7 +1840,9 @@ function ExitIcon() {
1725
1840
  const UserButton = Object.assign(UserButtonRoot, {
1726
1841
  MenuItems,
1727
1842
  Action,
1728
- Link
1843
+ Link,
1844
+ Custom,
1845
+ Header
1729
1846
  });
1730
1847
  //#endregion
1731
1848
  //#region src/index.ts
@@ -1753,6 +1870,8 @@ exports.UserButton = UserButton;
1753
1870
  exports.WebLocksLock = WebLocksLock;
1754
1871
  exports.WindowBrowserBridge = WindowBrowserBridge;
1755
1872
  exports.createLoginController = createLoginController;
1873
+ exports.initialsFrom = initialsFrom;
1874
+ exports.resolveSide = resolveSide;
1756
1875
  exports.useAuth = useAuth;
1757
1876
  exports.useAuthCallback = useAuthCallback;
1758
1877
  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;
@@ -428,10 +471,26 @@ interface UserButtonProps {
428
471
  /** Appended to the root element's class list, for escape-hatch styling. */
429
472
  className?: string;
430
473
  labels?: Partial<UserButtonLabels>;
431
- /** `<UserButton.MenuItems>` with custom `<UserButton.Action>` / `<UserButton.Link>` entries. */
474
+ /**
475
+ * `<UserButton.MenuItems>` with `<UserButton.Action>` / `<UserButton.Link>` / `<UserButton.Custom>`
476
+ * entries, and/or a `<UserButton.Header>` replacing the identity block.
477
+ */
432
478
  children?: ReactNode;
433
479
  /** Avatar size in pixels. Default 32. */
434
480
  avatarSize?: number;
481
+ /**
482
+ * What the trigger shows. Replaces the avatar (and `showName`); the SDK still owns the button
483
+ * and its menu semantics, so a custom trigger costs no accessibility.
484
+ */
485
+ trigger?: ReactNode;
486
+ /**
487
+ * Which side the menu opens on. Default `auto` — measured against the viewport, because the
488
+ * right answer differs by where the trigger sits (a header avatar opens down, a sidebar-footer
489
+ * one has to open up). An explicit side is flipped only if it would run off-screen.
490
+ */
491
+ side?: SideOption;
492
+ /** Alignment along the chosen side. Default `end`. */
493
+ align?: Align;
435
494
  }
436
495
  /**
437
496
  * The signed-in user surface: avatar trigger + a menu with their identity and sign-out
@@ -451,11 +510,11 @@ interface UserButtonProps {
451
510
  * ```
452
511
  */
453
512
  declare const UserButton: ((props: UserButtonProps) => ReactNode) & {
454
- MenuItems: (props: {
455
- children?: ReactNode;
456
- }) => ReactNode;
513
+ MenuItems: (props: UserButtonSlotProps) => ReactNode;
457
514
  Action: (props: UserButtonActionProps) => ReactNode;
458
- Link: (props: UserButtonLinkProps) => ReactNode;
515
+ Link: (props: UserButtonLinkProps) => ReactNode; /** Arbitrary JSX as a menu row — bring your own controls. */
516
+ Custom: (props: UserButtonSlotProps) => ReactNode; /** Replaces the built-in identity block. */
517
+ Header: (props: UserButtonSlotProps) => ReactNode;
459
518
  };
460
519
  //#endregion
461
520
  //#region src/components/user-avatar.d.ts
@@ -469,6 +528,11 @@ interface UserAvatarProps {
469
528
  /** Appended to the root element's class list, for escape-hatch styling. */
470
529
  className?: string;
471
530
  }
531
+ /**
532
+ * Up to two initials from a display name. Handles the shapes the platform actually
533
+ * produces — "Ali Ikram", a bare username, an email address.
534
+ */
535
+ declare function initialsFrom(name: string | undefined): string;
472
536
  /**
473
537
  * The user's picture, or their initials. A missing avatar is the ordinary case rather than
474
538
  * an error, so initials are a first-class state, not a broken image.
@@ -488,4 +552,4 @@ declare function UserAvatar({
488
552
  declare const PACKAGE_NAME = "@poly-x/react";
489
553
  declare const version = "0.0.0";
490
554
  //#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 };
555
+ 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;
@@ -428,10 +471,26 @@ interface UserButtonProps {
428
471
  /** Appended to the root element's class list, for escape-hatch styling. */
429
472
  className?: string;
430
473
  labels?: Partial<UserButtonLabels>;
431
- /** `<UserButton.MenuItems>` with custom `<UserButton.Action>` / `<UserButton.Link>` entries. */
474
+ /**
475
+ * `<UserButton.MenuItems>` with `<UserButton.Action>` / `<UserButton.Link>` / `<UserButton.Custom>`
476
+ * entries, and/or a `<UserButton.Header>` replacing the identity block.
477
+ */
432
478
  children?: ReactNode;
433
479
  /** Avatar size in pixels. Default 32. */
434
480
  avatarSize?: number;
481
+ /**
482
+ * What the trigger shows. Replaces the avatar (and `showName`); the SDK still owns the button
483
+ * and its menu semantics, so a custom trigger costs no accessibility.
484
+ */
485
+ trigger?: ReactNode;
486
+ /**
487
+ * Which side the menu opens on. Default `auto` — measured against the viewport, because the
488
+ * right answer differs by where the trigger sits (a header avatar opens down, a sidebar-footer
489
+ * one has to open up). An explicit side is flipped only if it would run off-screen.
490
+ */
491
+ side?: SideOption;
492
+ /** Alignment along the chosen side. Default `end`. */
493
+ align?: Align;
435
494
  }
436
495
  /**
437
496
  * The signed-in user surface: avatar trigger + a menu with their identity and sign-out
@@ -451,11 +510,11 @@ interface UserButtonProps {
451
510
  * ```
452
511
  */
453
512
  declare const UserButton: ((props: UserButtonProps) => ReactNode) & {
454
- MenuItems: (props: {
455
- children?: ReactNode;
456
- }) => ReactNode;
513
+ MenuItems: (props: UserButtonSlotProps) => ReactNode;
457
514
  Action: (props: UserButtonActionProps) => ReactNode;
458
- Link: (props: UserButtonLinkProps) => ReactNode;
515
+ Link: (props: UserButtonLinkProps) => ReactNode; /** Arbitrary JSX as a menu row — bring your own controls. */
516
+ Custom: (props: UserButtonSlotProps) => ReactNode; /** Replaces the built-in identity block. */
517
+ Header: (props: UserButtonSlotProps) => ReactNode;
459
518
  };
460
519
  //#endregion
461
520
  //#region src/components/user-avatar.d.ts
@@ -469,6 +528,11 @@ interface UserAvatarProps {
469
528
  /** Appended to the root element's class list, for escape-hatch styling. */
470
529
  className?: string;
471
530
  }
531
+ /**
532
+ * Up to two initials from a display name. Handles the shapes the platform actually
533
+ * produces — "Ali Ikram", a bare username, an email address.
534
+ */
535
+ declare function initialsFrom(name: string | undefined): string;
472
536
  /**
473
537
  * The user's picture, or their initials. A missing avatar is the ordinary case rather than
474
538
  * an error, so initials are a first-class state, not a broken image.
@@ -488,4 +552,4 @@ declare function UserAvatar({
488
552
  declare const PACKAGE_NAME = "@poly-x/react";
489
553
  declare const version = "0.0.0";
490
554
  //#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 };
555
+ 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,9 +1604,12 @@ 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();
@@ -1554,6 +1633,33 @@ function UserButtonRoot(props) {
1554
1633
  useEffect(() => {
1555
1634
  if (open) itemRefs.current[focused]?.focus();
1556
1635
  }, [open, focused]);
1636
+ /**
1637
+ * Choose the side once the menu exists and can be measured. `useLayoutEffect` runs before
1638
+ * paint, so the correction never shows as a jump — the menu is only ever painted where it
1639
+ * belongs. Re-measured on resize and on scroll, since either can invalidate the choice.
1640
+ */
1641
+ useLayoutEffect(() => {
1642
+ if (!open) return;
1643
+ const place = () => {
1644
+ const trigger = triggerRef.current?.getBoundingClientRect();
1645
+ const menu = menuRef.current?.getBoundingClientRect();
1646
+ if (!trigger || !menu) return;
1647
+ setSide(resolveSide(props.side ?? "auto", trigger, {
1648
+ width: menu.width,
1649
+ height: menu.height
1650
+ }, {
1651
+ width: window.innerWidth,
1652
+ height: window.innerHeight
1653
+ }));
1654
+ };
1655
+ place();
1656
+ window.addEventListener("resize", place);
1657
+ window.addEventListener("scroll", place, true);
1658
+ return () => {
1659
+ window.removeEventListener("resize", place);
1660
+ window.removeEventListener("scroll", place, true);
1661
+ };
1662
+ }, [open, props.side]);
1557
1663
  if (!isLoaded) return props.fallback ?? null;
1558
1664
  if (!isSignedIn || !user) return null;
1559
1665
  const name = user.displayName ?? user.email ?? "";
@@ -1580,14 +1686,17 @@ function UserButtonRoot(props) {
1580
1686
  })
1581
1687
  });
1582
1688
  const items = resolveItems(props.children, builtIns);
1689
+ const customHeader = findHeader(props.children);
1690
+ const navigable = items.map((item, index) => ({
1691
+ item,
1692
+ index
1693
+ })).filter(({ item }) => !item.custom);
1583
1694
  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
- }
1695
+ if (navigable.length === 0) return;
1696
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1697
+ event.preventDefault();
1698
+ const next = (navigable.findIndex(({ index }) => index === focused) + (event.key === "ArrowDown" ? 1 : -1) + navigable.length) % navigable.length;
1699
+ setFocused(navigable[next].index);
1591
1700
  };
1592
1701
  return /* @__PURE__ */ jsxs("div", {
1593
1702
  className: [
@@ -1596,7 +1705,7 @@ function UserButtonRoot(props) {
1596
1705
  props.className
1597
1706
  ].filter(Boolean).join(" "),
1598
1707
  "data-polyx-userbutton": open ? "open" : "closed",
1599
- children: [/* @__PURE__ */ jsxs("button", {
1708
+ children: [/* @__PURE__ */ jsx("button", {
1600
1709
  ref: triggerRef,
1601
1710
  type: "button",
1602
1711
  className: "polyx-userbutton__trigger",
@@ -1608,20 +1717,23 @@ function UserButtonRoot(props) {
1608
1717
  setFocused(0);
1609
1718
  setOpen((current) => !current);
1610
1719
  },
1611
- children: [/* @__PURE__ */ jsx(UserAvatar, {
1720
+ children: props.trigger ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UserAvatar, {
1612
1721
  src: user.avatarUrl,
1613
1722
  name,
1614
1723
  size: props.avatarSize
1615
1724
  }), props.showName ? /* @__PURE__ */ jsx("span", {
1616
1725
  className: "polyx-userbutton__trigger-name",
1617
1726
  children: name
1618
- }) : null]
1727
+ }) : null] })
1619
1728
  }), open ? /* @__PURE__ */ jsxs("div", {
1729
+ ref: menuRef,
1620
1730
  id: menuId,
1621
1731
  role: "menu",
1622
1732
  className: "polyx-userbutton__menu",
1733
+ "data-polyx-side": side,
1734
+ "data-polyx-align": align,
1623
1735
  onKeyDown: onMenuKeyDown,
1624
- children: [/* @__PURE__ */ jsxs("div", {
1736
+ children: [customHeader ?? /* @__PURE__ */ jsxs("div", {
1625
1737
  className: "polyx-userbutton__identity",
1626
1738
  children: [/* @__PURE__ */ jsx(UserAvatar, {
1627
1739
  src: user.avatarUrl,
@@ -1639,7 +1751,10 @@ function UserButtonRoot(props) {
1639
1751
  })]
1640
1752
  }), /* @__PURE__ */ jsx("ul", {
1641
1753
  className: "polyx-userbutton__items",
1642
- children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: item.href ? /* @__PURE__ */ jsxs("a", {
1754
+ children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: item.custom ? /* @__PURE__ */ jsx("div", {
1755
+ className: "polyx-userbutton__custom",
1756
+ children: item.custom
1757
+ }) : item.href ? /* @__PURE__ */ jsxs("a", {
1643
1758
  ref: (node) => {
1644
1759
  itemRefs.current[index] = node;
1645
1760
  },
@@ -1724,7 +1839,9 @@ function ExitIcon() {
1724
1839
  const UserButton = Object.assign(UserButtonRoot, {
1725
1840
  MenuItems,
1726
1841
  Action,
1727
- Link
1842
+ Link,
1843
+ Custom,
1844
+ Header
1728
1845
  });
1729
1846
  //#endregion
1730
1847
  //#region src/index.ts
@@ -1735,4 +1852,4 @@ const UserButton = Object.assign(UserButtonRoot, {
1735
1852
  const PACKAGE_NAME = "@poly-x/react";
1736
1853
  const version = "0.0.0";
1737
1854
  //#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 };
1855
+ 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.11",
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.11"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": ">=18",