Skip to content

Commit aeb92cd

Browse files
maxyingerclaude
andcommitted
feat(mosaic): add the organization profile General page
Adds `OrganizationProfileView`: the organization profile as a `Profile`, with General, Members, Security, Billing, and API Keys in its navigation. Only General is built — the rest hold their place with a placeholder. General carries the workspace details (logo, name, and a slug that truncates to one line beside a copy control) and a danger zone whose Leave and Delete actions each ask for the workspace's name to be typed out, as the legacy organization profile does. Copy says "Workspace" throughout, per the design. Along with it: - `CopyButton`, a `Button` that puts a value on the clipboard and confirms it in a tooltip. - `Section.Error` now animates in and out. The collapse, crossfade, and message body it needs already existed inside `Field.Message`, so those move to `utils/feedback` and both compose them. It reads its open state from its children, so its five callers now render it with the message it has rather than behind a conditional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 75e42d5 commit aeb92cd

51 files changed

Lines changed: 2279 additions & 141 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { CopyButton } from './copy-button';
6+
7+
describe('Mosaic CopyButton', () => {
8+
it('writes the value to the clipboard', async () => {
9+
const user = userEvent.setup();
10+
render(<CopyButton value='acme-inc' />);
11+
12+
await user.click(screen.getByRole('button', { name: 'Copy' }));
13+
14+
await expect(navigator.clipboard.readText()).resolves.toBe('acme-inc');
15+
});
16+
17+
it('announces the copy and keeps the button named', async () => {
18+
const user = userEvent.setup();
19+
render(<CopyButton value='acme-inc' />);
20+
21+
await user.click(screen.getByRole('button', { name: 'Copy' }));
22+
23+
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Copied'));
24+
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();
25+
});
26+
27+
it('confirms the copy in a tooltip', async () => {
28+
const user = userEvent.setup();
29+
render(<CopyButton value='acme-inc' />);
30+
31+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
32+
33+
await user.click(screen.getByRole('button', { name: 'Copy' }));
34+
35+
const tooltip = await screen.findByRole('tooltip');
36+
expect(tooltip).toHaveTextContent('Copied');
37+
expect(tooltip.querySelector('.cl-icon')).toBeInTheDocument();
38+
});
39+
40+
it('takes the tooltip label from copiedLabel', async () => {
41+
const user = userEvent.setup();
42+
render(
43+
<CopyButton
44+
value='acme-inc'
45+
copiedLabel='Slug copied'
46+
/>,
47+
);
48+
49+
await user.click(screen.getByRole('button', { name: 'Copy' }));
50+
51+
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('Slug copied'));
52+
});
53+
54+
it('settles back after the copied state expires', async () => {
55+
const user = userEvent.setup();
56+
render(
57+
<CopyButton
58+
value='acme-inc'
59+
resetAfter={200}
60+
/>,
61+
);
62+
63+
await user.click(screen.getByRole('button', { name: 'Copy' }));
64+
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Copied'));
65+
66+
await waitFor(() => expect(screen.getByRole('status')).toBeEmptyDOMElement());
67+
});
68+
69+
it('copies through onCopy when one is given', async () => {
70+
const onCopy = vi.fn();
71+
const user = userEvent.setup();
72+
const writeText = vi.spyOn(navigator.clipboard, 'writeText');
73+
render(
74+
<CopyButton
75+
value='acme-inc'
76+
onCopy={onCopy}
77+
/>,
78+
);
79+
80+
await user.click(screen.getByRole('button', { name: 'Copy' }));
81+
82+
expect(onCopy).toHaveBeenCalledWith('acme-inc');
83+
expect(writeText).not.toHaveBeenCalled();
84+
});
85+
86+
it('stays quiet when the copy fails', async () => {
87+
const user = userEvent.setup();
88+
render(
89+
<CopyButton
90+
value='acme-inc'
91+
onCopy={() => Promise.reject(new Error('denied'))}
92+
/>,
93+
);
94+
95+
await user.click(screen.getByRole('button', { name: 'Copy' }));
96+
97+
expect(screen.getByRole('status')).toBeEmptyDOMElement();
98+
});
99+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React from 'react';
2+
3+
import type { ButtonProps } from '../button';
4+
import { Button } from '../button';
5+
import { Icon } from '../icon';
6+
import { Tooltip } from '../tooltip';
7+
import { VisuallyHidden } from '../visually-hidden';
8+
9+
export interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onCopy'> {
10+
value: string;
11+
label?: string;
12+
copiedLabel?: string;
13+
placement?: 'top' | 'bottom' | 'left' | 'right';
14+
resetAfter?: number;
15+
onCopy?: (value: string) => void | Promise<void>;
16+
}
17+
18+
export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(function MosaicCopyButton(
19+
{
20+
value,
21+
label = 'Copy',
22+
copiedLabel = 'Copied',
23+
placement = 'top',
24+
resetAfter = 2000,
25+
onCopy,
26+
color = 'neutral',
27+
variant = 'ghost',
28+
size = 'xs',
29+
shape = 'square',
30+
onClick,
31+
...rest
32+
},
33+
ref,
34+
) {
35+
const [copied, setCopied] = React.useState(false);
36+
const timeout = React.useRef<ReturnType<typeof setTimeout>>(undefined);
37+
38+
React.useEffect(() => () => clearTimeout(timeout.current), []);
39+
40+
const settle = () => {
41+
clearTimeout(timeout.current);
42+
setCopied(false);
43+
};
44+
45+
const copy = async () => {
46+
try {
47+
await (onCopy ? onCopy(value) : navigator.clipboard.writeText(value));
48+
} catch {
49+
// A blocked clipboard leaves the button as it was; there is nothing to report.
50+
return;
51+
}
52+
53+
setCopied(true);
54+
clearTimeout(timeout.current);
55+
timeout.current = setTimeout(() => setCopied(false), resetAfter);
56+
};
57+
58+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
59+
onClick?.(event);
60+
if (!event.defaultPrevented) {
61+
void copy();
62+
}
63+
};
64+
65+
return (
66+
<Tooltip.Root
67+
open={copied}
68+
placement={placement}
69+
// Only the close is honoured, so hover cannot open a tooltip this button does not have.
70+
onOpenChange={next => {
71+
if (!next) {
72+
settle();
73+
}
74+
}}
75+
>
76+
<Tooltip.Trigger
77+
ref={ref}
78+
render={
79+
<Button
80+
type='button'
81+
aria-label={label}
82+
color={color}
83+
variant={variant}
84+
size={size}
85+
shape={shape}
86+
{...rest}
87+
/>
88+
}
89+
onClick={handleClick}
90+
>
91+
<Icon
92+
name='clipboard'
93+
size='sm'
94+
/>
95+
<VisuallyHidden render={<span role='status' />}>{copied ? copiedLabel : ''}</VisuallyHidden>
96+
</Tooltip.Trigger>
97+
<Tooltip.Popup>
98+
{copiedLabel}
99+
<Icon
100+
name='checkmark'
101+
size='sm'
102+
aria-hidden='true'
103+
/>
104+
</Tooltip.Popup>
105+
</Tooltip.Root>
106+
);
107+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { CopyButton } from './copy-button';
2+
export type { CopyButtonProps } from './copy-button';
Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import * as stylex from '@stylexjs/stylex';
22

3-
import { colorVars, durationVars, easingVars, fontWeightVars, space } from '../../tokens.stylex';
4-
5-
const ROOT_GAP = space['2'];
3+
import { colorVars, fontWeightVars, space } from '../../tokens.stylex';
64

75
export const styles = stylex.create({
86
root: {
9-
gap: ROOT_GAP,
7+
gap: space['2'],
108
display: 'flex',
119
flexDirection: 'column',
1210
},
@@ -20,50 +18,4 @@ export const styles = stylex.create({
2018
description: {
2119
color: colorVars['--cl-color-foreground-secondary'],
2220
},
23-
messageRoot: {
24-
overflow: 'clip',
25-
alignContent: 'start',
26-
display: 'grid',
27-
position: 'relative',
28-
transitionDuration: durationVars['--cl-duration-slow'],
29-
transitionProperty: {
30-
default: 'height, margin-top',
31-
'@media (prefers-reduced-motion: reduce)': 'none',
32-
},
33-
transitionTimingFunction: easingVars['--cl-ease-enter'],
34-
height: {
35-
default: 'var(--_cl-field-message-height)',
36-
':where(:not([data-open]), [data-starting-style])': 0,
37-
},
38-
marginTop: {
39-
default: 0,
40-
':where(:not([data-open]), [data-starting-style])': `calc(-1 * ${ROOT_GAP})`,
41-
},
42-
},
43-
feedback: {
44-
inset: { default: null, ':where([data-ending-style])': '0 0 auto' },
45-
gap: space['1'],
46-
alignItems: 'flex-start',
47-
display: 'flex',
48-
opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
49-
position: { default: null, ':where([data-ending-style])': 'absolute' },
50-
textWrap: 'pretty',
51-
transitionDuration: durationVars['--cl-duration-base'],
52-
transitionProperty: 'opacity',
53-
transitionTimingFunction: 'linear',
54-
},
55-
error: {
56-
color: colorVars['--cl-color-negative'],
57-
},
58-
success: {
59-
color: colorVars['--cl-color-positive'],
60-
},
61-
feedbackIcon: {
62-
flexShrink: 0,
63-
height: '1lh',
64-
},
65-
});
66-
67-
export const dynamic = stylex.create({
68-
messageHeight: (height: number) => ({ '--_cl-field-message-height': `${height}px` }),
6921
});

‎packages/mosaic/src/components/field/field.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ describe('Mosaic Field', () => {
391391
'@media (prefers-reduced-motion: reduce)': 'none',
392392
},
393393
height: {
394-
default: 'var(--_cl-field-message-height)',
394+
default: 'var(--_cl-feedback-height)',
395395
':where(:not([data-open]), [data-starting-style])': 0,
396396
},
397397
},

0 commit comments

Comments
 (0)