Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/profile-tab-responsiveness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/mosaic/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/index.js", "maxSize": "125KB" },
{ "path": "./dist/styles.css", "maxSize": "14KB" }
{ "path": "./dist/styles.css", "maxSize": "15KB" }
]
}
15 changes: 3 additions & 12 deletions packages/mosaic/src/components/panel/panel.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ export const styles = stylex.create({
display: 'block',
},

/**
* The headline as a button: the heading's own type, inline so the caret can align to its
* x-height, with a little room around it for the focus ring.
*/
// Inline so the caret can align to the title's x-height.
navTrigger: {
font: 'inherit',
borderRadius: radiusVars['--cl-radius-md'],
Expand All @@ -28,16 +25,10 @@ export const styles = stylex.create({
display: 'inline',
textAlign: 'start',
},
/**
* Beside the title, `vertical-align: middle`: the caret's midpoint on the baseline plus half the
* x-height, which centers it on the lowercase letters rather than the line box. Sized in `em`
* through the icon's `inherit` size, so it scales with the heading; coloured through the icon's
* own variable rather than `color`, which the icon sets itself.
*/
// `vertical-align: middle` centers the caret on the title's x-height rather than the line box.
caret: {
'--_cl-icon-color': colorVars['--cl-color-foreground-secondary'],
fontSize: '0.6em',
marginInlineStart: '0.25em',
marginInlineStart: space['1'],
verticalAlign: 'middle',
},
sections: {
Expand Down
22 changes: 7 additions & 15 deletions packages/mosaic/src/components/panel/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { useRender } from '../../primitives/utils';
import { isKeyboardEvent } from '../../primitives/utils/interaction-modality';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { focusOutline } from '../../utils/focus-outline.styles';
Expand All @@ -24,15 +25,7 @@ const Root = React.forwardRef<HTMLDivElement, PanelRootProps>(function PanelRoot
});
});

/**
* A page's headline. Inside a profile that has gone compact the headline IS the way to the other
* pages: the heading holds a button — the title, and a caret beside it — that opens the navigation
* sheet. Anywhere else — the wide layout, or a page rendered on its own — it is the heading alone.
*
* The caret sits `vertical-align: middle`, which CSS defines as the box's midpoint on the parent's
* baseline plus half its x-height: optically centered on the lowercase letters rather than on the
* line box. That needs an inline formatting context, so the button is `display: inline`.
*/
// In a compact profile the heading holds a button that opens the navigation.
const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTitle(
{ children, render, xstyle, ...rest },
ref,
Expand All @@ -42,7 +35,6 @@ const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTi
const registerPageTitle = profile?.registerPageTitle;
const page = panel?.value;
const level = useHeadingLevel();
// The sheet's return-focus target. A no-op outside a profile's page, where there is no sheet.
const registerTrigger = React.useCallback(
(element: HTMLButtonElement | null) => {
if (page !== undefined) {
Expand All @@ -69,7 +61,9 @@ const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTi
type='button'
aria-haspopup='dialog'
aria-expanded={profile.navOpen}
onClick={profile.openNav}
onClick={event =>
profile.navOpen ? profile.closeNav() : profile.openNav(isKeyboardEvent(event.nativeEvent))
}
{...mergeStyleProps(
themeProps('profile-nav-trigger'),
stylex.props(reset.base, styles.navTrigger, focusOutline.visible),
Expand All @@ -78,7 +72,7 @@ const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTi
{children}
<Icon
name='chevron-down'
size='inherit'
size='sm'
{...mergeStyleProps(themeProps('profile-nav-trigger-caret'), stylex.props(styles.caret))}
/>
</button>
Expand All @@ -105,9 +99,7 @@ const Sections = React.forwardRef<HTMLDivElement, PanelSectionsProps>(function P
});

/**
* A page of content, composed through `Panel.Root`, `Panel.Title`, and `Panel.Sections`. The
* sections sit one heading level below the title. Every part accepts the Mosaic `render` prop and
* forwards its ref.
* A page of content. `Panel.Sections` sit one heading level below `Panel.Title`.
*
* ```tsx
* <Panel.Root>
Expand Down
66 changes: 10 additions & 56 deletions packages/mosaic/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { useAccessibleNameWarning } from '../../hooks/useAccessibleNameWarning';
import type { PopoverProps as HeadlessPopoverProps } from '../../primitives/popover';
import type { PopoverFocusTarget, PopoverProps as HeadlessPopoverProps } from '../../primitives/popover';
import { Popover as Primitive } from '../../primitives/popover';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
Expand All @@ -13,19 +13,12 @@ export type PopoverSize = 'sm' | 'md' | 'lg' | 'anchor';

export type PopoverRootProps = HeadlessPopoverProps;

/**
* The headless parts type their props (and the `render` callback's argument) against
* the raw tag props, which carry the non-standard HTML `color` attribute typed
* `string`. Re-typing them through `MosaicComponentProps` drops it, so a `render`
* callback can spread straight into a Mosaic component whose own `color` is a narrow
* variant union.
*/
// Drops the raw `color: string` attr so a `render` callback can spread into a Mosaic component.
export type PopoverTriggerProps = MosaicComponentProps<'button'>;
export type PopoverCloseProps = MosaicComponentProps<'button'>;
export type PopoverTitleProps = MosaicComponentProps<'h2'>;
export type PopoverDescriptionProps = MosaicComponentProps<'p'>;

/** The anchor. Renders a `<button>`; `render` swaps in another element. */
const Trigger = React.forwardRef<HTMLButtonElement, PopoverTriggerProps>(function PopoverTrigger(
{ xstyle, ...rest },
ref,
Expand All @@ -38,7 +31,6 @@ const Trigger = React.forwardRef<HTMLButtonElement, PopoverTriggerProps>(functio
);
});

/** Dismisses the popover. Renders a `<button>`; `render` swaps in another element. */
const Close = React.forwardRef<HTMLButtonElement, PopoverCloseProps>(function PopoverClose({ xstyle, ...rest }, ref) {
return (
<Primitive.Close
Expand All @@ -48,7 +40,6 @@ const Close = React.forwardRef<HTMLButtonElement, PopoverCloseProps>(function Po
);
});

/** Names the dialog. Renders an `<h2>` wired to the popup's `aria-labelledby`. */
const Title = React.forwardRef<HTMLHeadingElement, PopoverTitleProps>(function PopoverTitle({ xstyle, ...rest }, ref) {
return (
<Primitive.Title
Expand All @@ -58,7 +49,6 @@ const Title = React.forwardRef<HTMLHeadingElement, PopoverTitleProps>(function P
);
});

/** Describes the dialog. Renders a `<p>` wired to the popup's `aria-describedby`. */
const Description = React.forwardRef<HTMLParagraphElement, PopoverDescriptionProps>(function PopoverDescription(
{ xstyle, ...rest },
ref,
Expand All @@ -71,26 +61,6 @@ const Description = React.forwardRef<HTMLParagraphElement, PopoverDescriptionPro
);
});

/**
* Mosaic Popover: a floating box anchored to a trigger, built on the
* `primitives/popover` primitive. Composed via dot syntax:
* `Popover.Root`, `Popover.Trigger`, `Popover.Popup`, plus `Popover.Title`,
* `Popover.Description` and `Popover.Close` for the popup's contents.
*
* The popover owns only what it means to float — trigger wiring, ARIA, focus
* management, positioning, stacking, viewport clamps, and the enter/exit
* transition. It paints no surface of its own: background, border, radius,
* shadow and padding come from whatever is rendered inside it (typically a
* `Card`), so the two never both draw a border.
*
* `Popover.Popup` renders the portal and the floating positioner itself —
* neither is a part a consumer composes, so they stay out of the public API.
* Each styled part spreads `themeProps` + `stylex.props` through
* `mergeStyleProps`, so it carries the public `.cl-<slot>` class and StyleX
* atoms while the headless part keeps its floating behavior, refs, and ARIA
* wiring.
*/

function Positioner({ children, ...rest }: React.ComponentPropsWithoutRef<typeof Primitive.Positioner>) {
const [node, setNode] = React.useState<HTMLDivElement | null>(null);
useAccessibleNameWarning(node, 'Popover');
Expand All @@ -107,38 +77,26 @@ function Positioner({ children, ...rest }: React.ComponentPropsWithoutRef<typeof
}

export interface PopoverPopupProps extends MosaicComponentProps<'div'> {
/** Positions against this element instead of the trigger. */
/** Positions against this element instead of the trigger; with no trigger, it acts as one. */
anchor?: HTMLElement | null;
/** Width of the floating box. */
finalFocus?: PopoverFocusTarget;
size?: PopoverSize;
/**
* Names the dialog for assistive technology. Required unless the contents render a
* `Popover.Title`, which wires `aria-labelledby` instead.
*/
/** Required unless the contents render a `Popover.Title`. */
'aria-label'?: string;
/** Names the dialog from an existing element. Alternative to `aria-label`. */
'aria-labelledby'?: string;
}

/**
* The floating box. Portals itself out of the tree and positions against
* `Popover.Trigger`; supply the surface inside it, usually a `Card`.
*/
// Paints no surface of its own; supply one inside it, usually a `Card`.
const Popup = React.forwardRef<HTMLDivElement, PopoverPopupProps>(function PopoverPopup(
{ anchor, xstyle, size = 'md', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest },
{ anchor, finalFocus, xstyle, size = 'md', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest },
ref,
) {
return (
<Primitive.Portal>
{/*
The positioner is the `role="dialog"` element, so the accessible name belongs on
it rather than on the popup. Spread conditionally: the headless positioner merges
these over its own `aria-labelledby` (set once a `Popover.Title` mounts), so
passing an explicit `undefined` would delete the Title's label rather than leave
it alone.
*/}
{/* Spread conditionally: an explicit `undefined` would drop the `Popover.Title`'s label. */}
<Positioner
anchor={anchor}
finalFocus={finalFocus}
{...(ariaLabel == null ? {} : { 'aria-label': ariaLabel })}
{...(ariaLabelledby == null ? {} : { 'aria-labelledby': ariaLabelledby })}
>
Expand All @@ -155,11 +113,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopoverPopupProps>(function Popov
);
});

/**
* Mosaic `Popover` — a floating box anchored to a trigger. Composed via dot
* syntax: `Popover.Root`, `Popover.Trigger`, `Popover.Popup`, `Popover.Title`,
* `Popover.Description`, `Popover.Close`.
*/
/** A floating box anchored to a trigger. */
export const Popover = {
Root: Primitive.Root,
Trigger,
Expand Down
13 changes: 3 additions & 10 deletions packages/mosaic/src/components/profile/profile.context.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
import React from 'react';

export interface ProfileContextValue {
/** The id `Profile.Title` renders under; the navigation and the sheet point their names at it. */
titleId: string;
renderBranding: boolean;
/** Below `COMPACT_WIDTH`: the navigation lives in a sheet, opened from a page's title. */
compact: boolean;
navLayout: 'column' | 'popover' | 'sheet';
navOpen: boolean;
openNav: () => void;
navOpenedByKeyboard: boolean;
openNav: (byKeyboard: boolean) => void;
closeNav: () => void;
/** The selected page, by `value`. */
value: string;
/**
* `Panel.Title` registers the control it renders under its page's `value`, the way a tab
* registers with the tabs root; the sheet returns focus through `pageTitleFor`.
*/
registerPageTitle: (value: string, element: HTMLElement | null) => void;
pageTitleFor: (value: string) => HTMLElement | null;
/** Flush: the page's own content, selected by `elevation='flush'`. */
inline: boolean;
}

export const ProfileContext = React.createContext<ProfileContextValue | null>(null);

/** The page a `Panel.Title` is in: the id it renders under, so the panel can be named by it, and the page's `value`. */
export const ContentPanelContext = React.createContext<{ titleId: string; value: string } | null>(null);
Loading
Loading