Skip to content
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-combined.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/src/components/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { Profile } from './profile';
export type {
ProfileContentProps,
ProfileElevation,
ProfileNavGroupProps,
ProfileNavItemProps,
ProfileNavProps,
ProfileContentPanelProps,
Expand Down
16 changes: 16 additions & 0 deletions packages/mosaic/src/components/profile/profile.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ export const styles = stylex.create({
minWidth: 0,
},

navGroup: {
gap: NAV_GAP,
display: 'flex',
flexDirection: 'column',
marginBlockStart: { default: null, ':not(:first-child)': space['4'] },
},

navGroupLabel: {
paddingBlock: space['1'],
paddingInline: space['2.5'],
color: colorVars['--cl-color-foreground-secondary'],
fontSize: typeScaleVars['--cl-text-xs-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
},

navItem: {
borderColor: 'transparent',
borderRadius: radiusVars['--cl-radius-md'],
Expand Down
82 changes: 81 additions & 1 deletion packages/mosaic/src/components/profile/profile.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as stylex from '@stylexjs/stylex';
import { act, render, screen, waitFor } from '@testing-library/react';
import { act, isInaccessible, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { UserCircle } from '../../icons/glyphs/user-circle';
import { MosaicProvider } from '../../mosaic-provider';
import { axe } from '../../primitives/test-utils/axe';
import { Badge } from '../badge';
import { Card } from '../card';
import { Dialog } from '../dialog';
Expand Down Expand Up @@ -65,6 +66,30 @@ function renderSurface(props: Partial<ProfileRootProps> = {}) {
);
}

function GroupedSurface(rootProps: Partial<ProfileRootProps>) {
return (
<Profile.Root
value='account'
{...rootProps}
>
<Profile.Title>Settings</Profile.Title>
<Profile.Nav>
<Profile.NavGroup label='Personal'>
<Profile.NavItem value='account'>Account</Profile.NavItem>
<Profile.NavItem value='security'>Security</Profile.NavItem>
</Profile.NavGroup>
<Profile.NavGroup label='Acme Inc'>
<Profile.NavItem value='organization/general'>General</Profile.NavItem>
<Profile.NavItem value='organization/security'>Security</Profile.NavItem>
</Profile.NavGroup>
</Profile.Nav>
<Profile.Content pageTitle='Account'>
<Profile.ContentPanel value='account'>Account page</Profile.ContentPanel>
</Profile.Content>
</Profile.Root>
);
}

function atomsOf(style: stylex.StyleXStyles): string[] {
return stylex
.props(style)
Expand Down Expand Up @@ -289,6 +314,42 @@ describe('Profile', () => {
});

// The sentinel reports 1px column, 2px select, 3px sheet.
describe('navigation groups', () => {
it('labels each group and describes its tabs by that label', () => {
render(<GroupedSurface />);

const label = screen.getByText('Acme Inc');
expect(label).toHaveClass('cl-profile-nav-group-label');
expect(label.closest('.cl-profile-nav-group')).toContainElement(
screen.getByRole('tab', { name: 'General', description: 'Acme Inc' }),
);
expect(screen.getByRole('tab', { name: 'Security', description: 'Personal' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Security', description: 'Acme Inc' })).toBeInTheDocument();
});

it('keeps the group labels out of the tablist for assistive tech', () => {
render(<GroupedSurface />);

expect(isInaccessible(screen.getByText('Personal'))).toBe(true);
expect(isInaccessible(screen.getByText('Acme Inc'))).toBe(true);
});

it('moves between groups with the arrow keys', async () => {
const user = userEvent.setup();
render(<GroupedSurface />);

screen.getByRole('tab', { name: 'Security', description: 'Personal' }).focus();
await user.keyboard('{ArrowDown}');

expect(screen.getByRole('tab', { name: 'General' })).toHaveFocus();
});

it('has no accessibility violations', async () => {
const { container } = render(<GroupedSurface />);
expect((await axe(container)).violations).toEqual([]);
});
});

describe('compact', () => {
let observe: ((width: number) => void) | null = null;
const original = globalThis.ResizeObserver;
Expand Down Expand Up @@ -367,6 +428,25 @@ describe('Profile', () => {
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
});

it('groups the listbox options under each group label', async () => {
const user = userEvent.setup();
render(
<MosaicProvider>
<GroupedSurface />
</MosaicProvider>,
);
act(() => observe?.(2));

await user.click(screen.getByRole('combobox', { name: 'Account' }));

expect(screen.getByRole('group', { name: 'Acme Inc' })).toContainElement(
screen.getByRole('option', { name: 'General' }),
);
expect(screen.getByRole('group', { name: 'Personal' })).toContainElement(
screen.getByRole('option', { name: 'Account' }),
);
});

it('opens the listbox from the keyboard, picks with the arrows, and keeps focus on the title', async () => {
const user = userEvent.setup();
function Controlled() {
Expand Down
89 changes: 87 additions & 2 deletions packages/mosaic/src/components/profile/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ type NavItemMode = 'tab' | 'option';
// `Profile.Nav` children render again as the select's options.
const NavItemModeContext = React.createContext<NavItemMode>('tab');

const NavGroupLabelContext = React.createContext<string | undefined>(undefined);

function BackButton() {
const m = useMessages('profile');
return (
Expand Down Expand Up @@ -267,7 +269,7 @@ function NavBranding() {
}

/**
* Children are `Profile.NavItem`s only. Wide, they render as a tablist; compact, as the options of
* Children are `Profile.NavItem`s, or `Profile.NavGroup`s of them. Wide, they render as a tablist; compact, as the options of
* the page title's select, or a tablist in a sheet on a phone.
*/
const Nav = React.forwardRef<HTMLElement, ProfileNavProps>(function ProfileNav(
Expand Down Expand Up @@ -340,6 +342,87 @@ const Nav = React.forwardRef<HTMLElement, ProfileNavProps>(function ProfileNav(
);
});

export interface ProfileNavGroupProps extends MosaicComponentProps<'div'> {
/** Names the group above its items. */
label: React.ReactNode;
}

/**
* A labelled run of `Profile.NavItem`s. As tabs, the label describes each item; as options, it names
* a listbox group.
*/
const NavGroup = React.forwardRef<HTMLDivElement, ProfileNavGroupProps>(function ProfileNavGroup(
{ label, children, xstyle, ...rest },
ref,
) {
const mode = React.useContext(NavItemModeContext);
const groupProps = mergeStyleProps(
themeProps('profile-nav-group'),
stylex.props(reset.base, styles.navGroup, xstyle),
rest,
);
const labelProps = mergeStyleProps(
themeProps('profile-nav-group-label'),
stylex.props(reset.base, styles.navGroupLabel),
);
if (mode === 'option') {
return (
<SelectPrimitive.Group
ref={ref}
{...groupProps}
>
<SelectPrimitive.GroupLabel {...labelProps}>{label}</SelectPrimitive.GroupLabel>
{children}
</SelectPrimitive.Group>
);
}
return (
<TabsNavGroup
ref={ref}
label={label}
labelProps={labelProps}
{...groupProps}
>
{children}
</TabsNavGroup>
);
});

interface TabsNavGroupProps extends Omit<ProfileNavGroupProps, 'xstyle'> {
labelProps: React.ComponentProps<'div'>;
}

const TabsNavGroup = React.forwardRef<HTMLDivElement, TabsNavGroupProps>(function TabsNavGroup(
{ label, labelProps, children, render, ...rest },
ref,
) {
const labelId = React.useId();
return (
<NavGroupLabelContext.Provider value={labelId}>
{useRender({
defaultTagName: 'div',
render,
ref,
props: {
...rest,
children: (
<>
<div
id={labelId}
aria-hidden
{...labelProps}
>
{label}
</div>
{children}
</>
),
},
})}
</NavGroupLabelContext.Provider>
);
});

export interface ProfileNavItemProps extends MosaicComponentProps<'button'> {
value: string;
icon?: React.ReactNode;
Expand All @@ -355,6 +438,7 @@ const NavItem = React.forwardRef<HTMLButtonElement, ProfileNavItemProps>(functio
) {
const { compact, closeNav } = useProfileContext('Profile.NavItem');
const mode = React.useContext(NavItemModeContext);
const groupLabelId = React.useContext(NavGroupLabelContext);
const styleProps = mergeStyleProps(
themeProps('profile-nav-item'),
stylex.props(reset.base, styles.navItem, focusOutline.visible, mode === 'option' && styles.navItemOption, xstyle),
Expand Down Expand Up @@ -405,6 +489,7 @@ const NavItem = React.forwardRef<HTMLButtonElement, ProfileNavItemProps>(functio
ref={ref}
value={value}
disabled={disabled}
aria-describedby={groupLabelId}
render={render}
onClick={event => {
onClick?.(event);
Expand Down Expand Up @@ -596,4 +681,4 @@ const ContentPanel = React.forwardRef<HTMLDivElement, ProfileContentPanelProps>(
* </Profile.Root>
* ```
*/
export const Profile = { Root, Title, Nav, NavItem, Content, ContentPanel };
export const Profile = { Root, Title, Nav, NavGroup, NavItem, Content, ContentPanel };
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { describe, expect, it } from 'vitest';

import { getAvailableOrganizationProfilePages } from '../organization-profile.layout';
import type { OrganizationProfilePages } from '../organization-profile.types';
import { getAvailableOrganizationProfilePages, resolveOrganizationProfilePages } from '../organization-profile.layout';
import type { CustomOrganizationProfilePage, OrganizationProfilePages } from '../organization-profile.types';

const general: OrganizationProfilePages['general'] = <p>General</p>;

describe('resolveOrganizationProfilePages', () => {
it('keeps the first custom page for a repeated path', () => {
const audit: CustomOrganizationProfilePage = { label: 'Audit', path: 'audit', content: null };
const otherAudit: CustomOrganizationProfilePage = { label: 'Other audit', path: 'audit', content: null };

expect(resolveOrganizationProfilePages(['general'], [audit, otherAudit])).toEqual([
{ id: 'general' },
{ id: 'audit', custom: audit },
]);
});
});

describe('getAvailableOrganizationProfilePages', () => {
it('lists api keys given content', () => {
expect(getAvailableOrganizationProfilePages({ general, apiKeys: <p>API keys</p> })).toEqual(['general', 'apiKeys']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Key } from '../../icons/glyphs/key';
import { SecurityLockSquare } from '../../icons/glyphs/security-lock-square';
import { Users } from '../../icons/glyphs/users';
import { applyOrder } from '../../utils/apply-order';
import { withUniquePaths } from '../profile-pages/profile-pages.layout';
import type {
CustomOrganizationProfilePage,
OrganizationProfilePageId,
Expand Down Expand Up @@ -43,14 +44,15 @@ export function resolveOrganizationProfilePages(
customPages: readonly CustomOrganizationProfilePage[] = [],
order?: readonly string[],
): OrganizationProfileNavEntry[] {
const customById = new Map(customPages.map(page => [page.path, page]));
const uniqueCustomPages = withUniquePaths(customPages);
const customById = new Map(uniqueCustomPages.map(page => [page.path, page]));
const builtInIds = new Set<string>(builtIn);
const entries: OrganizationProfileNavEntry[] = [
...builtIn.map((id): OrganizationProfileNavEntry => {
const custom = customById.get(id);
return custom ? { id, custom } : { id };
}),
...customPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })),
...uniqueCustomPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })),
];
return applyOrder(order, entries, entry => entry.id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import { useOrganizationProfileModel } from './organization-profile.model';
import type { OrganizationProfileOptions } from './organization-profile.provider';
import { OrganizationProfileProvider } from './organization-profile.provider';
import type { OrganizationProfilePageId, OrganizationProfilePages } from './organization-profile.types';
import type { OrganizationProfileViewProps } from './organization-profile.view';
import { OrganizationProfileView } from './organization-profile.view';
import { OrganizationProfileApiKeysPanel } from './organization-profile-api-keys-panel';
Expand Down Expand Up @@ -83,7 +84,7 @@ export function OrganizationProfile({
defaultPage,
afterLeaveOrganizationUrl,
fallback,
apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {},
apiKeysProps,
}: OrganizationProfileProps) {
const model = useOrganizationProfileModel();
const [activePage, setActivePage] = useState<OrganizationProfileViewProps['activePage']>(
Expand All @@ -105,15 +106,24 @@ export function OrganizationProfile({
onPageChange={setActivePage}
customPages={customPages}
pageOrder={pageOrder}
pages={{
general: <OrganizationProfileGeneralPanel />,
members: model.pages.includes('members') ? <OrganizationProfileMembersPanel /> : undefined,
apiKeys:
model.pages.includes('apiKeys') && !hideAPIKeys ? (
<OrganizationProfileApiKeysPanel {...apiKeysProps} />
) : undefined,
}}
pages={organizationProfilePanels(model.pages, apiKeysProps)}
/>
</OrganizationProfileProvider>
);
}

/**
* The organization's built-in panels, for the pages the instance has turned on. `apiKeysProps.hide`
* leaves the API keys panel out; the rest of `apiKeysProps` goes to it.
*/
export function organizationProfilePanels(
available: readonly OrganizationProfilePageId[],
{ hide: hideAPIKeys = false, ...apiKeysProps }: APIKeysPageProps = {},
): OrganizationProfilePages {
return {
general: <OrganizationProfileGeneralPanel />,
members: available.includes('members') ? <OrganizationProfileMembersPanel /> : undefined,
apiKeys:
available.includes('apiKeys') && !hideAPIKeys ? <OrganizationProfileApiKeysPanel {...apiKeysProps} /> : undefined,
};
}
Loading
Loading