@astryxdesign/core 0.4.1 → 0.4.2-canary.356d2f9
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/CHANGELOG.md +55 -0
- package/dist/Avatar/Avatar.d.ts +4 -1
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +19 -23
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +7 -2
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +9 -7
- package/dist/Chat/ChatMessage.d.ts +7 -0
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.d.ts +13 -1
- package/dist/Chat/ChatMessageBubble.d.ts.map +1 -1
- package/dist/Chat/ChatMessageBubble.js +19 -1
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +5 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteFooter.js +5 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +2 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +19 -15
- package/dist/HoverCard/HoverCard.d.ts +1 -1
- package/dist/HoverCard/HoverCard.d.ts.map +1 -1
- package/dist/HoverCard/HoverCard.js +5 -13
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +6 -5
- package/dist/InputGroup/groupStyles.d.ts.map +1 -1
- package/dist/InputGroup/groupStyles.js +7 -2
- package/dist/Layer/layerHost.d.ts +24 -0
- package/dist/Layer/layerHost.d.ts.map +1 -0
- package/dist/Layer/layerHost.js +79 -0
- package/dist/Layer/useLayer.d.ts +14 -6
- package/dist/Layer/useLayer.d.ts.map +1 -1
- package/dist/Layer/useLayer.js +158 -30
- package/dist/NavItem/navItemStyles.stylex.d.ts +17 -5
- package/dist/NavItem/navItemStyles.stylex.d.ts.map +1 -1
- package/dist/NavItem/navItemStyles.stylex.js +11 -5
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +5 -0
- package/dist/SideNav/SideNav.d.ts +7 -9
- package/dist/SideNav/SideNav.d.ts.map +1 -1
- package/dist/SideNav/SideNav.js +32 -5
- package/dist/SideNav/SideNavCollapseButton.d.ts +25 -9
- package/dist/SideNav/SideNavCollapseButton.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseButton.js +39 -15
- package/dist/SideNav/SideNavCollapseContext.d.ts +21 -0
- package/dist/SideNav/SideNavCollapseContext.d.ts.map +1 -1
- package/dist/SideNav/SideNavCollapseContext.js +16 -2
- package/dist/SideNav/SideNavHeading.d.ts.map +1 -1
- package/dist/SideNav/SideNavHeading.js +89 -32
- package/dist/SideNav/SideNavItem.d.ts +7 -2
- package/dist/SideNav/SideNavItem.d.ts.map +1 -1
- package/dist/SideNav/SideNavItem.js +119 -75
- package/dist/SideNav/SideNavSection.d.ts.map +1 -1
- package/dist/SideNav/SideNavSection.js +7 -16
- package/dist/SideNav/index.d.ts +1 -1
- package/dist/SideNav/index.d.ts.map +1 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +56 -15
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -0
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +5 -0
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +14 -6
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +25 -93
- package/dist/TopNav/TopNavMegaMenuItem.js +1 -1
- package/dist/TopNav/TopNavMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMenu.js +10 -5
- package/dist/astryx.css +40 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.stylex.d.ts +71 -4
- package/dist/hooks/containerReveal.stylex.d.ts.map +1 -1
- package/dist/hooks/containerReveal.stylex.js +57 -5
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useContainerReveal.d.ts +60 -3
- package/dist/hooks/useContainerReveal.d.ts.map +1 -1
- package/dist/hooks/useContainerReveal.js +27 -5
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +16 -2
- package/dist/hooks/useMenuHover.d.ts +50 -5
- package/dist/hooks/useMenuHover.d.ts.map +1 -1
- package/dist/hooks/useMenuHover.js +171 -51
- package/dist/theme/defineTheme.d.ts +16 -5
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +44 -48
- package/dist/theme/derivedVarRegistry.d.ts.map +1 -1
- package/dist/theme/derivedVarRegistry.js +7 -0
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +1 -0
- package/dist/theme/expandMotionScale.d.ts +1 -0
- package/dist/theme/expandMotionScale.d.ts.map +1 -1
- package/dist/theme/expandMotionScale.js +1 -0
- package/dist/theme/expandRadiusScale.d.ts +1 -0
- package/dist/theme/expandRadiusScale.d.ts.map +1 -1
- package/dist/theme/expandRadiusScale.js +1 -0
- package/dist/theme/expandTypeScale.d.ts +1 -0
- package/dist/theme/expandTypeScale.d.ts.map +1 -1
- package/dist/theme/expandTypeScale.js +1 -0
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/theme/tokens.stylex.d.ts +3 -1
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +3 -1
- package/locales/en.json +84 -0
- package/locales/pseudo.json +63 -0
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +5 -2
- package/src/Avatar/Avatar.test.tsx +51 -0
- package/src/Avatar/Avatar.tsx +30 -20
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +14 -0
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +3 -2
- package/src/Button/Button.tsx +5 -3
- package/src/ButtonGroup/ButtonGroup.test.tsx +7 -4
- package/src/Card/Card.doc.mjs +2 -0
- package/src/Chat/Chat.doc.mjs +4 -1
- package/src/Chat/ChatMessage.doc.mjs +3 -3
- package/src/Chat/ChatMessage.tsx +7 -0
- package/src/Chat/ChatMessageBubble.doc.mjs +7 -0
- package/src/Chat/ChatMessageBubble.test.tsx +95 -6
- package/src/Chat/ChatMessageBubble.tsx +25 -0
- package/src/CheckboxInput/CheckboxInput.tsx +20 -0
- package/src/CodeBlock/CodeBlock.doc.mjs +3 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +6 -3
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +72 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +24 -20
- package/src/HoverCard/HoverCard.doc.mjs +3 -3
- package/src/HoverCard/HoverCard.test.tsx +283 -55
- package/src/HoverCard/HoverCard.tsx +5 -13
- package/src/HoverCard/useHoverCard.tsx +8 -11
- package/src/InputGroup/groupStyles.ts +7 -8
- package/src/Item/Item.doc.mjs +4 -0
- package/src/Layer/layerHost.test.ts +99 -0
- package/src/Layer/layerHost.ts +141 -0
- package/src/Layer/useLayer.doc.mjs +14 -4
- package/src/Layer/useLayer.test.tsx +332 -7
- package/src/Layer/useLayer.tsx +235 -36
- package/src/MobileNav/MobileNav.doc.mjs +7 -0
- package/src/NavItem/navItemStyles.stylex.ts +31 -5
- package/src/RadioList/RadioListItem.tsx +20 -0
- package/src/SideNav/SideNav.doc.mjs +13 -4
- package/src/SideNav/SideNav.test.tsx +858 -2
- package/src/SideNav/SideNav.tsx +37 -16
- package/src/SideNav/SideNavCollapseButton.doc.mjs +28 -6
- package/src/SideNav/SideNavCollapseButton.tsx +67 -15
- package/src/SideNav/SideNavCollapseContext.ts +29 -9
- package/src/SideNav/SideNavHeading.tsx +53 -25
- package/src/SideNav/SideNavItem.doc.mjs +13 -0
- package/src/SideNav/SideNavItem.tsx +97 -84
- package/src/SideNav/SideNavSection.tsx +6 -20
- package/src/SideNav/index.ts +2 -0
- package/src/Slider/Slider.test.tsx +69 -33
- package/src/Slider/Slider.tsx +65 -13
- package/src/Switch/Switch.tsx +20 -0
- package/src/TabList/TabList.doc.mjs +3 -0
- package/src/Text/Text.doc.mjs +1 -1
- package/src/Thumbnail/Thumbnail.doc.mjs +3 -0
- package/src/Thumbnail/Thumbnail.tsx +10 -0
- package/src/Timestamp/Timestamp.test.tsx +40 -22
- package/src/Toolbar/Toolbar.test.tsx +14 -14
- package/src/TopNav/TopNav.test.tsx +86 -1
- package/src/TopNav/TopNavHeading.tsx +21 -14
- package/src/TopNav/TopNavMegaMenu.test.tsx +43 -0
- package/src/TopNav/TopNavMegaMenu.tsx +29 -105
- package/src/TopNav/TopNavMegaMenuItem.tsx +1 -1
- package/src/TopNav/TopNavMenu.test.tsx +41 -0
- package/src/TopNav/TopNavMenu.tsx +14 -4
- package/src/TreeList/TreeList.doc.mjs +1 -0
- package/src/hooks/containerReveal.stylex.ts +140 -9
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useContainerReveal.doc.mjs +11 -5
- package/src/hooks/useContainerReveal.test.tsx +70 -3
- package/src/hooks/useContainerReveal.ts +86 -7
- package/src/hooks/useFocusTrap.test.tsx +16 -0
- package/src/hooks/useFocusTrap.ts +20 -2
- package/src/hooks/useMenuHover.test.tsx +364 -0
- package/src/hooks/useMenuHover.ts +243 -47
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +57 -51
- package/src/theme/derivedVarRegistry.test.ts +78 -12
- package/src/theme/derivedVarRegistry.ts +4 -0
- package/src/theme/expandColorScale.ts +1 -0
- package/src/theme/expandMotionScale.ts +1 -0
- package/src/theme/expandRadiusScale.ts +1 -0
- package/src/theme/expandTypeScale.ts +1 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/theme/themingTargets.test.ts +152 -26
- package/src/theme/tokens.stylex.ts +3 -1
- package/src/theme/tokens.test.ts +12 -0
- package/src/theme/useTheme.test.tsx +18 -0
package/src/Layer/useLayer.tsx
CHANGED
|
@@ -25,8 +25,10 @@ import React, {
|
|
|
25
25
|
} from 'react';
|
|
26
26
|
import * as stylex from '@stylexjs/stylex';
|
|
27
27
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
28
|
+
import {createPortal} from 'react-dom';
|
|
28
29
|
import {addAnchorName, removeAnchorName} from './anchorName';
|
|
29
|
-
import {
|
|
30
|
+
import {resolveLayerPortalTarget} from './layerHost';
|
|
31
|
+
import {typeScaleVars, typographyVars} from '../theme/tokens.stylex';
|
|
30
32
|
|
|
31
33
|
const styles = stylex.create({
|
|
32
34
|
// Base reset for all layers
|
|
@@ -42,7 +44,12 @@ const styles = stylex.create({
|
|
|
42
44
|
borderWidth: 0,
|
|
43
45
|
borderStyle: 'none',
|
|
44
46
|
overflow: 'visible',
|
|
47
|
+
// A layer is hosted wherever its trigger happens to sit, so type that is
|
|
48
|
+
// inherited rather than declared makes the same component render at a
|
|
49
|
+
// different size in different callers.
|
|
45
50
|
fontFamily: typographyVars['--font-family-body'],
|
|
51
|
+
fontSize: typeScaleVars['--text-body-size'],
|
|
52
|
+
lineHeight: typeScaleVars['--text-body-leading'],
|
|
46
53
|
// Override browser default [popover] background (canvas color)
|
|
47
54
|
backgroundColor: 'transparent',
|
|
48
55
|
},
|
|
@@ -149,12 +156,11 @@ export interface ContextRenderProps {
|
|
|
149
156
|
/**
|
|
150
157
|
* HTML tag to render the popover container as.
|
|
151
158
|
*
|
|
152
|
-
* Defaults to `'div'`.
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* the same on either tag.
|
|
159
|
+
* Defaults to `'div'`. Context layers render an inert `<template>` marker at
|
|
160
|
+
* the JSX position. The marker's parent is checked before the requested
|
|
161
|
+
* container mounts there or portals outside ancestors that cannot safely
|
|
162
|
+
* contain it. The marker remains available to detect a new parent if the
|
|
163
|
+
* render call moves. With `lazyMount`, the first check waits until `show()`.
|
|
158
164
|
*
|
|
159
165
|
* @default 'div'
|
|
160
166
|
*/
|
|
@@ -222,6 +228,15 @@ interface BaseLayerOptions {
|
|
|
222
228
|
*/
|
|
223
229
|
export interface ContextLayerOptions extends BaseLayerOptions {
|
|
224
230
|
mode: 'context';
|
|
231
|
+
/**
|
|
232
|
+
* Defer mounting the final layer and resolving its inline/portal position
|
|
233
|
+
* until `show()` is requested. Hiding unmounts it while the inert marker
|
|
234
|
+
* remains at the JSX position. Use this when rich content must never enter
|
|
235
|
+
* an unsafe ancestor, even briefly, and does not need to exist while closed.
|
|
236
|
+
*
|
|
237
|
+
* @default false
|
|
238
|
+
*/
|
|
239
|
+
lazyMount?: boolean;
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
/**
|
|
@@ -314,16 +329,51 @@ function toCssLength(value: number | string): string {
|
|
|
314
329
|
return typeof value === 'number' ? `${value}px` : value;
|
|
315
330
|
}
|
|
316
331
|
|
|
332
|
+
interface ContextLayerMount {
|
|
333
|
+
/** Null means the marker's parent is safe and the layer stays inline. */
|
|
334
|
+
portalTarget: HTMLElement | null;
|
|
335
|
+
/** Logical writing context lost when moving outside an unsafe ancestor. */
|
|
336
|
+
portalStyle: React.CSSProperties;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readPortalWritingContext(
|
|
340
|
+
element: HTMLElement,
|
|
341
|
+
portalTarget: HTMLElement,
|
|
342
|
+
): React.CSSProperties {
|
|
343
|
+
const view = element.ownerDocument.defaultView;
|
|
344
|
+
if (!view) {
|
|
345
|
+
return {};
|
|
346
|
+
}
|
|
347
|
+
const sourceStyle = view.getComputedStyle(element);
|
|
348
|
+
const targetStyle = view.getComputedStyle(portalTarget);
|
|
349
|
+
|
|
350
|
+
// Do not snapshot custom properties here. The portal target is the closest
|
|
351
|
+
// safe ancestor, so theme variables continue to inherit and update there.
|
|
352
|
+
// These two properties can be set on the unsafe chain itself and directly
|
|
353
|
+
// affect the logical anchor-positioning keywords used by the layer. Only
|
|
354
|
+
// override values the portal would actually lose; matching values should
|
|
355
|
+
// keep inheriting from the target so later direction changes remain live.
|
|
356
|
+
return {
|
|
357
|
+
...(sourceStyle.direction !== targetStyle.direction && {
|
|
358
|
+
direction: sourceStyle.direction as React.CSSProperties['direction'],
|
|
359
|
+
}),
|
|
360
|
+
...(sourceStyle.writingMode !== targetStyle.writingMode && {
|
|
361
|
+
writingMode:
|
|
362
|
+
sourceStyle.writingMode as React.CSSProperties['writingMode'],
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
317
367
|
/**
|
|
318
368
|
* Map logical placement/alignment to a CSS position-area value.
|
|
319
369
|
*
|
|
320
370
|
* Uses the self-* logical keyword family: the inline axis resolves against
|
|
321
|
-
* the popover's own
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
371
|
+
* the popover's own direction (inherited inline or preserved when portaled),
|
|
372
|
+
* so it mirrors in RTL without placement-specific JS. The block axis is
|
|
373
|
+
* direction-neutral but must come from the same keyword family — mixing
|
|
374
|
+
* physical `top` with `self-inline-*` produces an invalid position-area
|
|
375
|
+
* (computes to `none`, which pins the popover to the viewport corner because
|
|
376
|
+
* styles.base zeroes the UA margins).
|
|
327
377
|
*
|
|
328
378
|
* Note the plain logical family (`inline-start`, no `self-`) is NOT a
|
|
329
379
|
* substitute: it resolves against the containing block — the page root for
|
|
@@ -406,39 +456,122 @@ export function useLayer(
|
|
|
406
456
|
options: ContextLayerOptions | FixedLayerOptions,
|
|
407
457
|
): ContextLayerReturn | FixedLayerReturn {
|
|
408
458
|
const {mode, onShow, onHide, lightDismiss = false} = options;
|
|
459
|
+
const lazyMount = mode === 'context' ? (options.lazyMount ?? false) : false;
|
|
409
460
|
const id = useId();
|
|
410
461
|
const anchorId = `--astryx-layer-${id.replace(/:/g, '')}`;
|
|
411
462
|
|
|
412
463
|
const [isOpen, setIsOpen] = useState(false);
|
|
413
464
|
const popoverRef = useRef<HTMLElement | null>(null);
|
|
465
|
+
// The DOM element on which the current logical open state was applied.
|
|
466
|
+
// A portal target change replaces the popover element; retaining the old
|
|
467
|
+
// reference lets the ref callback recognize and reopen its replacement.
|
|
468
|
+
const openedPopoverRef = useRef<HTMLElement | null>(null);
|
|
414
469
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
470
|
+
// Context layers place a persistent inert marker at their real JSX position.
|
|
471
|
+
// Its parent tells us whether the final layer can stay inline or needs a
|
|
472
|
+
// corrective portal, including after the render call moves.
|
|
473
|
+
const sentinelRef = useRef<HTMLTemplateElement | null>(null);
|
|
474
|
+
const contextMountRef = useRef<ContextLayerMount | null>(null);
|
|
475
|
+
const [contextMount, setContextMount] = useState<ContextLayerMount | null>(
|
|
476
|
+
null,
|
|
477
|
+
);
|
|
478
|
+
// A show() that arrives before the final layer mounts is replayed when its
|
|
479
|
+
// popover ref attaches.
|
|
480
|
+
const pendingShowRef = useRef(false);
|
|
415
481
|
|
|
416
482
|
// Ref mirrors isOpen for synchronous reads inside show/hide.
|
|
417
483
|
// State drives re-renders; the ref lets the imperative calls avoid
|
|
418
484
|
// stale-closure reads of the previous isOpen value.
|
|
419
485
|
const isOpenRef = useRef(false);
|
|
420
486
|
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
487
|
+
const showPopoverElement = useCallback((popover: HTMLElement) => {
|
|
488
|
+
// Finding infra-4: the Popover API is unsupported on Safari <17 and
|
|
489
|
+
// Firefox <125. On those browsers `showPopover` does not exist, so fall
|
|
490
|
+
// back to plain visibility instead of throwing.
|
|
491
|
+
if (typeof popover.showPopover === 'function') {
|
|
492
|
+
// The trigger is passed as the popover's invoker `source`: a layer
|
|
493
|
+
// hosted away from its trigger then still takes its sequential focus
|
|
494
|
+
// order (and its popover nesting) from the trigger rather than from its
|
|
495
|
+
// own DOM position. Browsers without the option ignore it.
|
|
496
|
+
popover.showPopover({source: triggerRef.current ?? undefined});
|
|
497
|
+
} else {
|
|
498
|
+
popover.style.display = 'block';
|
|
499
|
+
}
|
|
500
|
+
openedPopoverRef.current = popover;
|
|
501
|
+
}, []);
|
|
502
|
+
|
|
503
|
+
const isCurrentContextPopover = useCallback(
|
|
504
|
+
(popover: HTMLElement): boolean => {
|
|
505
|
+
if (mode !== 'context') {
|
|
506
|
+
return true;
|
|
434
507
|
}
|
|
508
|
+
const mount = contextMountRef.current;
|
|
509
|
+
if (mount === null) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
const expectedParent =
|
|
513
|
+
mount.portalTarget ?? sentinelRef.current?.parentElement ?? null;
|
|
514
|
+
return popover.parentElement === expectedParent;
|
|
515
|
+
},
|
|
516
|
+
[mode],
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const requestContextMount = useCallback(() => {
|
|
520
|
+
if (mode !== 'context') {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const sentinel = sentinelRef.current;
|
|
525
|
+
const inlineParent = sentinel?.parentElement ?? null;
|
|
526
|
+
if (!sentinel || !inlineParent) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const portalTarget = resolveLayerPortalTarget(inlineParent);
|
|
531
|
+
const mount: ContextLayerMount = {
|
|
532
|
+
portalTarget,
|
|
533
|
+
portalStyle: portalTarget
|
|
534
|
+
? readPortalWritingContext(sentinel, portalTarget)
|
|
535
|
+
: {},
|
|
536
|
+
};
|
|
537
|
+
contextMountRef.current = mount;
|
|
538
|
+
setContextMount(mount);
|
|
539
|
+
}, [mode]);
|
|
540
|
+
|
|
541
|
+
const clearContextMount = useCallback(() => {
|
|
542
|
+
if (mode !== 'context' || !lazyMount) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
contextMountRef.current = null;
|
|
546
|
+
setContextMount(null);
|
|
547
|
+
}, [mode, lazyMount]);
|
|
548
|
+
|
|
549
|
+
const show = useCallback(() => {
|
|
550
|
+
// A context popover left over until React commits a previous hide must not
|
|
551
|
+
// be reopened. The synchronous mount ref is the source of truth.
|
|
552
|
+
const candidate = popoverRef.current;
|
|
553
|
+
const popover =
|
|
554
|
+
candidate && isCurrentContextPopover(candidate) ? candidate : null;
|
|
555
|
+
if (!popover) {
|
|
556
|
+
pendingShowRef.current = true;
|
|
557
|
+
requestContextMount();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (!isOpenRef.current) {
|
|
561
|
+
showPopoverElement(popover);
|
|
435
562
|
isOpenRef.current = true;
|
|
436
563
|
setIsOpen(true);
|
|
437
564
|
onShow?.();
|
|
438
565
|
}
|
|
439
|
-
}, [
|
|
566
|
+
}, [
|
|
567
|
+
onShow,
|
|
568
|
+
requestContextMount,
|
|
569
|
+
showPopoverElement,
|
|
570
|
+
isCurrentContextPopover,
|
|
571
|
+
]);
|
|
440
572
|
|
|
441
573
|
const hide = useCallback(() => {
|
|
574
|
+
pendingShowRef.current = false;
|
|
442
575
|
if (isOpenRef.current) {
|
|
443
576
|
const el = popoverRef.current;
|
|
444
577
|
// See finding infra-4 note in `show`: mirror the same guard on hide so
|
|
@@ -450,11 +583,13 @@ export function useLayer(
|
|
|
450
583
|
el.style.display = 'none';
|
|
451
584
|
}
|
|
452
585
|
}
|
|
586
|
+
openedPopoverRef.current = null;
|
|
453
587
|
isOpenRef.current = false;
|
|
454
588
|
setIsOpen(false);
|
|
455
589
|
onHide?.();
|
|
456
590
|
}
|
|
457
|
-
|
|
591
|
+
clearContextMount();
|
|
592
|
+
}, [onHide, clearContextMount]);
|
|
458
593
|
|
|
459
594
|
// Ref for trigger element (context mode only)
|
|
460
595
|
const ref: RefCallback<HTMLElement> | undefined =
|
|
@@ -488,12 +623,14 @@ export function useLayer(
|
|
|
488
623
|
(e: Event) => {
|
|
489
624
|
const toggleEvent = e as ToggleEvent;
|
|
490
625
|
if (toggleEvent.newState === 'closed' && isOpenRef.current) {
|
|
626
|
+
openedPopoverRef.current = null;
|
|
491
627
|
isOpenRef.current = false;
|
|
492
628
|
setIsOpen(false);
|
|
493
629
|
onHide?.();
|
|
630
|
+
clearContextMount();
|
|
494
631
|
}
|
|
495
632
|
},
|
|
496
|
-
[onHide],
|
|
633
|
+
[onHide, clearContextMount],
|
|
497
634
|
);
|
|
498
635
|
|
|
499
636
|
// Ref callback for popover element — sets up the `toggle` listener.
|
|
@@ -531,8 +668,40 @@ export function useLayer(
|
|
|
531
668
|
(el: HTMLElement | null) => {
|
|
532
669
|
popoverRef.current = el;
|
|
533
670
|
bindToggleListener(el, handleToggle);
|
|
671
|
+
if (el && pendingShowRef.current) {
|
|
672
|
+
pendingShowRef.current = false;
|
|
673
|
+
show();
|
|
674
|
+
} else if (
|
|
675
|
+
el &&
|
|
676
|
+
isOpenRef.current &&
|
|
677
|
+
openedPopoverRef.current !== el &&
|
|
678
|
+
isCurrentContextPopover(el)
|
|
679
|
+
) {
|
|
680
|
+
// Changing a portal target remounts the popover. Preserve the logical
|
|
681
|
+
// open state without firing onShow again for the replacement element.
|
|
682
|
+
showPopoverElement(el);
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
[
|
|
686
|
+
handleToggle,
|
|
687
|
+
bindToggleListener,
|
|
688
|
+
show,
|
|
689
|
+
showPopoverElement,
|
|
690
|
+
isCurrentContextPopover,
|
|
691
|
+
],
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const sentinelRefCallback = useCallback(
|
|
695
|
+
(el: HTMLTemplateElement | null) => {
|
|
696
|
+
sentinelRef.current = el;
|
|
697
|
+
if (el && (!lazyMount || pendingShowRef.current || isOpenRef.current)) {
|
|
698
|
+
// The render call may have moved while the hook stayed mounted.
|
|
699
|
+
// Resolve again from the newly attached marker rather than reusing a
|
|
700
|
+
// portal target from its previous JSX position.
|
|
701
|
+
requestContextMount();
|
|
702
|
+
}
|
|
534
703
|
},
|
|
535
|
-
[
|
|
704
|
+
[lazyMount, requestContextMount],
|
|
536
705
|
);
|
|
537
706
|
|
|
538
707
|
// Re-bind when the handler identity changes while the element stays mounted,
|
|
@@ -556,6 +725,15 @@ export function useLayer(
|
|
|
556
725
|
// Render function for context mode
|
|
557
726
|
const renderContext = useCallback(
|
|
558
727
|
(children: ReactNode, props?: ContextRenderProps) => {
|
|
728
|
+
// Keep the marker mounted after resolving the layer. Apart from giving
|
|
729
|
+
// us the real JSX parent on first show, this lets its ref report when a
|
|
730
|
+
// persistent hook's render call moves to a different host.
|
|
731
|
+
const sentinel = <template ref={sentinelRefCallback} />;
|
|
732
|
+
|
|
733
|
+
if (contextMount === null) {
|
|
734
|
+
return <>{sentinel}</>;
|
|
735
|
+
}
|
|
736
|
+
|
|
559
737
|
const {
|
|
560
738
|
placement = 'above',
|
|
561
739
|
alignment = 'center',
|
|
@@ -598,10 +776,10 @@ export function useLayer(
|
|
|
598
776
|
? `${extraClassName} ${stylexResult.className ?? ''}`
|
|
599
777
|
: stylexResult.className;
|
|
600
778
|
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
779
|
+
// The marker gives us the actual JSX parent without mounting arbitrary
|
|
780
|
+
// children there. Safe positions preserve the existing DOM order and
|
|
781
|
+
// cascade; unsafe positions use the nearest corrective portal target.
|
|
782
|
+
const layer = (
|
|
605
783
|
<Container
|
|
606
784
|
ref={popoverRefCallback}
|
|
607
785
|
id={id}
|
|
@@ -609,14 +787,35 @@ export function useLayer(
|
|
|
609
787
|
aria-label={ariaLabel}
|
|
610
788
|
popover={lightDismiss ? 'auto' : 'manual'}
|
|
611
789
|
className={combinedClassName}
|
|
612
|
-
style={{
|
|
790
|
+
style={{
|
|
791
|
+
...stylexResult.style,
|
|
792
|
+
...anchorStyle,
|
|
793
|
+
...contextMount.portalStyle,
|
|
794
|
+
...extraStyle,
|
|
795
|
+
}}
|
|
613
796
|
onMouseEnter={onMouseEnter}
|
|
614
797
|
onMouseLeave={onMouseLeave}>
|
|
615
798
|
{children}
|
|
616
799
|
</Container>
|
|
617
800
|
);
|
|
801
|
+
|
|
802
|
+
return (
|
|
803
|
+
<>
|
|
804
|
+
{sentinel}
|
|
805
|
+
{contextMount.portalTarget
|
|
806
|
+
? createPortal(layer, contextMount.portalTarget)
|
|
807
|
+
: layer}
|
|
808
|
+
</>
|
|
809
|
+
);
|
|
618
810
|
},
|
|
619
|
-
[
|
|
811
|
+
[
|
|
812
|
+
anchorId,
|
|
813
|
+
contextMount,
|
|
814
|
+
id,
|
|
815
|
+
lightDismiss,
|
|
816
|
+
popoverRefCallback,
|
|
817
|
+
sentinelRefCallback,
|
|
818
|
+
],
|
|
620
819
|
);
|
|
621
820
|
|
|
622
821
|
// Render function for fixed mode
|
|
@@ -78,6 +78,13 @@ export const docs = {
|
|
|
78
78
|
name: 'MobileNavToggle',
|
|
79
79
|
displayName: 'Mobile Nav Toggle',
|
|
80
80
|
description: 'Hamburger button that opens/closes the mobile nav drawer. Reads open state from AppShell context automatically: does NOT accept isOpen or onOpenChange props. Renders nothing above the mobile breakpoint.',
|
|
81
|
+
// The toggle renders null unless AppShell mobile context reports an
|
|
82
|
+
// enabled mobile viewport — the default context outside AppShell never
|
|
83
|
+
// does, so the Properties preview was an empty stage. appShellMobile
|
|
84
|
+
// has the preview simulate that context instead (#4983).
|
|
85
|
+
playground: {
|
|
86
|
+
appShellMobile: true,
|
|
87
|
+
},
|
|
81
88
|
props: [
|
|
82
89
|
{
|
|
83
90
|
name: 'children',
|
|
@@ -11,8 +11,13 @@
|
|
|
11
11
|
* disabled state) so all nav-like components stay in sync — especially important
|
|
12
12
|
* when TopNav items render inside MobileNav drawers alongside SideNav items.
|
|
13
13
|
*
|
|
14
|
-
* Individual components layer their own overrides (e.g. collapsed mode,
|
|
15
|
-
*
|
|
14
|
+
* Individual components layer their own overrides (e.g. collapsed mode,
|
|
15
|
+
* indentation) via stylex.props composition.
|
|
16
|
+
*
|
|
17
|
+
* The focus ring is not defined here. Compose
|
|
18
|
+
* `focusOutlineProps.focusVisible` (utils/focusOutline.stylex.ts) at the call
|
|
19
|
+
* site, on whichever element actually takes focus — in a split-action row that
|
|
20
|
+
* is the link and the toggle, not the row that contains them.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
export type NavItemSize = 'sm' | 'md' | 'lg';
|
|
@@ -79,15 +84,36 @@ export const navItemStyles = stylex.create({
|
|
|
79
84
|
|
|
80
85
|
/** Selected/active page indicator — deemphasized background, medium weight */
|
|
81
86
|
selected: {
|
|
82
|
-
|
|
87
|
+
// Forced colors flatten `--color-neutral` away, leaving the current page
|
|
88
|
+
// unmarked; Highlight/HighlightText is the platform convention, as in
|
|
89
|
+
// ToggleButton and SegmentedControlItem. No `forced-color-adjust: none`
|
|
90
|
+
// here — a nav row is not a native control, so the keywords land without
|
|
91
|
+
// it, and it would inherit into `endContent` and pin a Badge's own fill.
|
|
92
|
+
backgroundColor: {
|
|
93
|
+
default: colorVars['--color-neutral'],
|
|
94
|
+
'@media (forced-colors: active)': 'Highlight',
|
|
95
|
+
},
|
|
96
|
+
color: {
|
|
97
|
+
default: null,
|
|
98
|
+
'@media (forced-colors: active)': 'HighlightText',
|
|
99
|
+
},
|
|
83
100
|
fontWeight: fontWeightVars['--font-weight-medium'],
|
|
84
101
|
':hover': {
|
|
85
102
|
'@media (hover: hover)': {
|
|
86
|
-
backgroundColor:
|
|
103
|
+
backgroundColor: {
|
|
104
|
+
default: colorVars['--color-neutral'],
|
|
105
|
+
// Nested, not a sibling `(hover: hover) and (forced-colors:
|
|
106
|
+
// active)` block: as siblings `item`'s hover overlay ties on
|
|
107
|
+
// specificity and wins on source order, erasing the fill.
|
|
108
|
+
'@media (forced-colors: active)': 'Highlight',
|
|
109
|
+
},
|
|
87
110
|
},
|
|
88
111
|
},
|
|
89
112
|
':active': {
|
|
90
|
-
backgroundColor:
|
|
113
|
+
backgroundColor: {
|
|
114
|
+
default: colorVars['--color-neutral'],
|
|
115
|
+
'@media (forced-colors: active)': 'Highlight',
|
|
116
|
+
},
|
|
91
117
|
},
|
|
92
118
|
},
|
|
93
119
|
|
|
@@ -51,6 +51,26 @@ const styles = stylex.create({
|
|
|
51
51
|
opacity: 0,
|
|
52
52
|
cursor: 'pointer',
|
|
53
53
|
zIndex: 1,
|
|
54
|
+
minInlineSize: {
|
|
55
|
+
default: null,
|
|
56
|
+
'@media (pointer: coarse)': '24px',
|
|
57
|
+
},
|
|
58
|
+
minBlockSize: {
|
|
59
|
+
default: null,
|
|
60
|
+
'@media (pointer: coarse)': '24px',
|
|
61
|
+
},
|
|
62
|
+
insetBlockStart: {
|
|
63
|
+
default: null,
|
|
64
|
+
'@media (pointer: coarse)': '50%',
|
|
65
|
+
},
|
|
66
|
+
insetInlineStart: {
|
|
67
|
+
default: null,
|
|
68
|
+
'@media (pointer: coarse)': '50%',
|
|
69
|
+
},
|
|
70
|
+
transform: {
|
|
71
|
+
default: null,
|
|
72
|
+
'@media (pointer: coarse)': 'translate(-50%, -50%)',
|
|
73
|
+
},
|
|
54
74
|
},
|
|
55
75
|
inputDisabled: {
|
|
56
76
|
cursor: 'not-allowed',
|
|
@@ -21,7 +21,7 @@ export const docs = {
|
|
|
21
21
|
targets: [
|
|
22
22
|
{className: 'astryx-side-nav', visualProps: ['mode']},
|
|
23
23
|
{className: 'astryx-side-nav-heading'},
|
|
24
|
-
{className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected']},
|
|
24
|
+
{className: 'astryx-side-nav-item', visualProps: ['size'], states: ['selected', 'disabled']},
|
|
25
25
|
{className: 'astryx-side-nav-section'},
|
|
26
26
|
],
|
|
27
27
|
},
|
|
@@ -85,7 +85,7 @@ export const docs = {
|
|
|
85
85
|
{
|
|
86
86
|
name: 'footerIcons',
|
|
87
87
|
type: 'ReactNode',
|
|
88
|
-
description:
|
|
88
|
+
description: "Footer icon bar. The row cascades a 'sm' size to the interactive children it contains, so its icons and the built-in collapse button come out one height; pass an explicit size on a child to opt out.",
|
|
89
89
|
slotElements: [
|
|
90
90
|
{
|
|
91
91
|
__element: 'Icon',
|
|
@@ -99,7 +99,7 @@ export const docs = {
|
|
|
99
99
|
{
|
|
100
100
|
name: 'collapsible',
|
|
101
101
|
type: 'boolean | { defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapsedChange?: (isCollapsed: boolean) => void; hasButton?: boolean; buttonLabel?: string }',
|
|
102
|
-
description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel).',
|
|
102
|
+
description: 'Enables collapse behavior. true for uncontrolled with default toggle button, or an object for controlled mode and advanced config (defaultIsCollapsed, isCollapsed + onCollapsedChange, hasButton, buttonLabel). A controlled config can also be passed to a SideNavCollapseButton rendered outside this SideNav, so both share one state.',
|
|
103
103
|
default: 'false',
|
|
104
104
|
},
|
|
105
105
|
{
|
|
@@ -111,7 +111,7 @@ export const docs = {
|
|
|
111
111
|
{
|
|
112
112
|
name: 'handleRef',
|
|
113
113
|
type: 'Ref<SideNavImperativeCollapseHandle>',
|
|
114
|
-
description: 'Imperative collapse handle for SideNavCollapseButton instances rendered outside this SideNav. Separate from `ref`, which continues to expose the root HTMLElement.',
|
|
114
|
+
description: 'Deprecated. Imperative collapse handle for SideNavCollapseButton instances rendered outside this SideNav; hand both the same controlled collapsible config instead. Separate from `ref`, which continues to expose the root HTMLElement.',
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
name: 'xstyle',
|
|
@@ -131,6 +131,9 @@ export const docs = {
|
|
|
131
131
|
bestPractices: [
|
|
132
132
|
{guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
|
|
133
133
|
{guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
|
|
134
|
+
{guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
|
|
135
|
+
{guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
|
|
136
|
+
{guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
|
|
134
137
|
{guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
|
|
135
138
|
{guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
|
|
136
139
|
],
|
|
@@ -150,6 +153,9 @@ export const docsZh = {
|
|
|
150
153
|
bestPractices: [
|
|
151
154
|
{guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
|
|
152
155
|
{guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
|
|
156
|
+
{guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
|
|
157
|
+
{guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
|
|
158
|
+
{guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
|
|
153
159
|
{guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
|
|
154
160
|
{guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
|
|
155
161
|
],
|
|
@@ -169,6 +175,9 @@ export const docsDense = {
|
|
|
169
175
|
bestPractices: [
|
|
170
176
|
{guidance: true, description: 'Use sections to group related navigation items and help users scan for their destination.'},
|
|
171
177
|
{guidance: true, description: 'Pair outline and filled icon variants so the selected state is visually distinct.'},
|
|
178
|
+
{guidance: true, description: 'Mark the current page with isSelected — it sets aria-current="page", so the current destination is announced rather than carried by color alone.'},
|
|
179
|
+
{guidance: true, description: 'SideNav renders a navigation landmark, and a collapsible item follows the WAI-ARIA APG Disclosure pattern (https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/): the toggle carries aria-expanded and aria-controls, and the group it owns is inert while collapsed. Keep item labels short — they are the accessible name in both expanded and icon-only modes.'},
|
|
180
|
+
{guidance: true, description: 'While the nav is collapsed, an item with children shows them in a submenu flyout. On a device that can hover, pointing at the item opens it after a short delay and moving away closes it; a flyout opened by clicking stays open until it is dismissed. On touch, it opens on tap. Do not put an action in there that has no other route to it.'},
|
|
172
181
|
{guidance: false, description: 'Include a SideNavHeading when a TopNav is already providing app identity; this duplicates branding.'},
|
|
173
182
|
{guidance: false, description: 'Use for filtering content; use tabs or filter buttons instead.'},
|
|
174
183
|
],
|