@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 +150 -22
- package/dist/index.d.cts +77 -6
- package/dist/index.d.mts +77 -6
- package/dist/index.mjs +150 -24
- package/dist/styles.css +52 -2
- package/package.json +2 -2
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
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
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
|
|
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
|
-
|
|
1559
|
+
supplied.push(builtIn);
|
|
1497
1560
|
placed.add(props.label);
|
|
1498
1561
|
}
|
|
1499
1562
|
return;
|
|
1500
1563
|
}
|
|
1501
|
-
|
|
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
|
-
|
|
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 [...
|
|
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 (
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
}
|
|
1589
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
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
|
|
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
|
-
|
|
1558
|
+
supplied.push(builtIn);
|
|
1496
1559
|
placed.add(props.label);
|
|
1497
1560
|
}
|
|
1498
1561
|
return;
|
|
1499
1562
|
}
|
|
1500
|
-
|
|
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
|
-
|
|
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 [...
|
|
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 (
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
}
|
|
1588
|
-
|
|
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__ */
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
"@poly-x/core": "0.1.0-alpha.12"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"react": ">=18",
|