Skip to content
Open
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/mosaic-user-button-trigger-hover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
alexcarpenter marked this conversation as resolved.
7 changes: 7 additions & 0 deletions packages/swingset/src/stories/user-button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ trigger, no selection when personal is hidden and none is active, the account ot
storyModule={UserButtonStories}
/>

A user's avatar on its own rounds the trigger fully to match it.

<Story
name='UserAvatarOnly'
storyModule={UserButtonStories}
/>

`renderTriggerBadge={false}` keeps the name. The badge is part of the label, so it needs both.

<Story
Expand Down
12 changes: 12 additions & 0 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,18 @@ export function AvatarOnly(_args: Record<string, unknown>) {
);
}

export function UserAvatarOnly(_args: Record<string, unknown>) {
const prototype = usePrototype();

return (
<UserButtonView
{...prototype}
mode='user'
renderTriggerLabel={false}
/>
);
}

export function WithoutTriggerBadge(_args: Record<string, unknown>) {
const prototype = usePrototype();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -865,15 +865,40 @@ describe('UserButtonTrigger', () => {
expect(screen.queryByText('Pro')).toBeNull();
});

it('takes its corner from the workspace it names, labelled or not', () => {
it('ends the label with a caret, and carries none for the avatar alone', () => {
const caret = (props: Partial<UserButtonProps>) => {
const { unmount } = renderTrigger(props);
const found = screen.getByRole('button', { name: /Open account menu/ }).querySelector('.cl-icon');
unmount();
return found;
};

expect(caret({ mode: 'organization' })).not.toBeNull();
expect(caret({ mode: 'organization', renderTriggerLabel: false })).toBeNull();
});

it('shrinks the avatar beside a label, and fills the trigger with it alone', () => {
const avatarSize = (props: Partial<UserButtonProps>) => {
const { unmount } = renderTrigger(props);
const avatar = screen.getByRole('button', { name: /Open account menu/ }).querySelector('.cl-avatar');
const size = avatar?.getAttribute('data-size');
unmount();
return size;
};

expect(avatarSize({ mode: 'organization' })).toBe('xs');
expect(avatarSize({ mode: 'organization', renderTriggerLabel: false })).toBe('sm');
});

it('rounds fully only around a user avatar on its own', () => {
const corner = (props: Partial<UserButtonProps>) => {
const { unmount } = renderTrigger(props);
const className = screen.getByRole('button', { name: /Open account menu/ }).className;
unmount();
return className;
};

expect(corner({ mode: 'organization' })).not.toEqual(corner({ mode: 'user' }));
expect(corner({ mode: 'organization' })).toEqual(corner({ mode: 'user' }));
expect(corner({ mode: 'organization', renderTriggerLabel: false })).not.toEqual(
corner({ mode: 'user', renderTriggerLabel: false }),
);
Expand Down
37 changes: 23 additions & 14 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex';
import { colorVars, durationVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex';

export const styles = stylex.create({
// The avatar is the trigger, so the button paints nothing of its own.
trigger: {
padding: 0,
borderRadius: radiusVars['--cl-radius-md'],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
borderStyle: 'none',
backgroundColor: 'transparent',
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-open])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
cursor: 'pointer',
display: 'inline-flex',
transitionDuration: durationVars['--cl-duration-base'],
transitionProperty: 'background-color',
},

// A labelled trigger sits in a host app's chrome, so it stays flush like the avatar-only form
// and only spaces the avatar from its text.
triggerLabelled: {
gap: space['2'],
alignItems: 'center',
padding: space['1'],
gap: space['1.5'],
},

triggerRound: {
borderRadius: radiusVars['--cl-radius-full'],
},

// Matches `Item.Label`, so the trigger names a workspace the same way its row does. Capped,
Expand All @@ -29,6 +40,11 @@ export const styles = stylex.create({
maxWidth: '12rem',
},

triggerCaret: {
'--_cl-icon-color': colorVars['--cl-color-neutral-faded'],
marginInlineEnd: space['1'],
},

// The workspace list scrolls; the header and footer stay put. The scroll area carries the
// overflow, the edge fades, the scrollbar and the scroll padding they need, so only the cap
// is ours.
Expand All @@ -44,10 +60,3 @@ export const styles = stylex.create({
width: space['7'],
},
});

// The trigger takes the corner of the workspace mark it carries: round for a person, squared for
// an organization. Rounding it fully would draw a circle around a square mark, labelled or not.
export const triggerShapes = stylex.create({
circle: { borderRadius: radiusVars['--cl-radius-full'] },
square: { borderRadius: radiusVars['--cl-radius-md'] },
});
12 changes: 9 additions & 3 deletions packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { truncationStyles } from '../utils/typography.styles';
import type { UserButtonLayout } from './user-button.layout';
import { resolveUserButtonLayout } from './user-button.layout';
import { fill, plural, userButtonBase as m } from './user-button.messages';
import { styles, triggerShapes } from './user-button.styles';
import { styles } from './user-button.styles';
import type {
UserButtonBrandingProps,
UserButtonBusyState,
Expand Down Expand Up @@ -1086,19 +1086,25 @@ export function UserButtonTrigger({
focusOutline.visible,
styles.trigger,
renderTriggerLabel ? styles.triggerLabelled : null,
triggerShapes[shape],
!renderTriggerLabel && shape === 'circle' ? styles.triggerRound : null,
)}
>
<RowAvatar
name={name}
imageUrl={imageUrl}
shape={shape}
size='sm'
size={renderTriggerLabel ? 'xs' : 'sm'}
/>
{renderTriggerLabel ? (
<>
<span {...stylex.props(styles.triggerName, truncationStyles.singleLine)}>{name}</span>
{planLabel ? <Badge color='neutral'>{planLabel}</Badge> : null}
<Icon
aria-hidden
name='chevron-down'
size='sm'
{...stylex.props(styles.triggerCaret)}
/>
</>
) : null}
</Popover.Trigger>
Expand Down
Loading