From aeb92cd08f42911f334bbda1bf23673dec14b6f0 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 21 Sep 2026 11:12:47 -0600 Subject: [PATCH 1/4] feat(mosaic): add the organization profile General page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/organization-profile-general.md | 2 + .../copy-button/copy-button.test.tsx | 99 ++++++++++++ .../components/copy-button/copy-button.tsx | 107 +++++++++++++ .../src/components/copy-button/index.ts | 2 + .../src/components/field/field.styles.ts | 52 +------ .../src/components/field/field.test.tsx | 2 +- .../mosaic/src/components/field/field.tsx | 66 ++------ .../src/components/section/section.styles.ts | 10 -- .../src/components/section/section.test.tsx | 44 +++++- .../mosaic/src/components/section/section.tsx | 46 ++++-- ...zation-profile-general-panel.view.test.tsx | 128 ++++++++++++++++ .../organization-profile-action-menu.tsx | 52 +++++++ ...zation-profile-danger-action.controller.ts | 71 +++++++++ ...ization-profile-danger-section.messages.ts | 25 ++++ ...ganization-profile-danger-section.view.tsx | 114 ++++++++++++++ ...ganization-profile-general-panel.styles.ts | 17 +++ ...rganization-profile-general-panel.view.tsx | 56 +++++++ ...ization-profile-placeholder-panel.view.tsx | 22 +++ ...anization-profile-edit-field.controller.ts | 113 ++++++++++++++ ...organization-profile-edit-field.dialog.tsx | 130 ++++++++++++++++ .../organization-profile-logo-row.view.tsx | 141 ++++++++++++++++++ .../organization-profile-name-row.view.tsx | 57 +++++++ .../organization-profile-slug-row.view.tsx | 76 ++++++++++ ...tion-profile-workspace-section.messages.ts | 38 +++++ ...zation-profile-workspace-section.styles.ts | 14 ++ ...ization-profile-workspace-section.view.tsx | 56 +++++++ .../organization-profile.layout.ts | 48 ++++++ .../organization-profile.messages.ts | 11 ++ .../organization-profile.types.ts | 36 +++++ .../organization-profile.view.tsx | 100 +++++++++++++ .../user-profile-picture-row.view.tsx | 2 +- ...ser-profile-connected-account-row.view.tsx | 8 +- ...er-profile-enterprise-account-row.view.tsx | 2 +- .../user-profile-web3-wallet-row.view.tsx | 4 +- packages/mosaic/src/localization/registry.ts | 6 + packages/mosaic/src/utils/feedback.styles.ts | 56 +++++++ packages/mosaic/src/utils/feedback.tsx | 55 +++++++ .../swingset/src/components/Composition.tsx | 10 +- .../swingset/src/components/DocsViewer.tsx | 9 ++ packages/swingset/src/lib/registry.ts | 67 +++++++++ packages/swingset/src/stories/copy-button.mdx | 43 ++++++ .../src/stories/copy-button.stories.tsx | 65 ++++++++ .../stories/fixtures/organization-profile.ts | 53 +++++++ .../organization-profile-danger-section.mdx | 34 +++++ ...ization-profile-danger-section.stories.tsx | 66 ++++++++ .../organization-profile-general-panel.mdx | 24 +++ ...nization-profile-general-panel.stories.tsx | 35 +++++ ...organization-profile-workspace-section.mdx | 24 +++ ...tion-profile-workspace-section.stories.tsx | 50 +++++++ .../src/stories/organization-profile.mdx | 25 ++++ .../stories/organization-profile.stories.tsx | 47 ++++++ 51 files changed, 2279 insertions(+), 141 deletions(-) create mode 100644 .changeset/organization-profile-general.md create mode 100644 packages/mosaic/src/components/copy-button/copy-button.test.tsx create mode 100644 packages/mosaic/src/components/copy-button/copy-button.tsx create mode 100644 packages/mosaic/src/components/copy-button/index.ts create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-action-menu.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-action.controller.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-general-panel.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-placeholder-panel.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-edit-field.controller.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-edit-field.dialog.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-logo-row.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-name-row.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-slug-row.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.messages.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.layout.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.messages.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.types.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.view.tsx create mode 100644 packages/mosaic/src/utils/feedback.styles.ts create mode 100644 packages/mosaic/src/utils/feedback.tsx create mode 100644 packages/swingset/src/stories/copy-button.mdx create mode 100644 packages/swingset/src/stories/copy-button.stories.tsx create mode 100644 packages/swingset/src/stories/fixtures/organization-profile.ts create mode 100644 packages/swingset/src/stories/organization-profile-danger-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-danger-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-general-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-general-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-workspace-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-workspace-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile.mdx create mode 100644 packages/swingset/src/stories/organization-profile.stories.tsx diff --git a/.changeset/organization-profile-general.md b/.changeset/organization-profile-general.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/organization-profile-general.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/copy-button/copy-button.test.tsx b/packages/mosaic/src/components/copy-button/copy-button.test.tsx new file mode 100644 index 00000000000..d74c514e650 --- /dev/null +++ b/packages/mosaic/src/components/copy-button/copy-button.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { CopyButton } from './copy-button'; + +describe('Mosaic CopyButton', () => { + it('writes the value to the clipboard', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + await expect(navigator.clipboard.readText()).resolves.toBe('acme-inc'); + }); + + it('announces the copy and keeps the button named', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Copied')); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + }); + + it('confirms the copy in a tooltip', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Copied'); + expect(tooltip.querySelector('.cl-icon')).toBeInTheDocument(); + }); + + it('takes the tooltip label from copiedLabel', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('Slug copied')); + }); + + it('settles back after the copied state expires', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Copied')); + + await waitFor(() => expect(screen.getByRole('status')).toBeEmptyDOMElement()); + }); + + it('copies through onCopy when one is given', async () => { + const onCopy = vi.fn(); + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, 'writeText'); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + expect(onCopy).toHaveBeenCalledWith('acme-inc'); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('stays quiet when the copy fails', async () => { + const user = userEvent.setup(); + render( + Promise.reject(new Error('denied'))} + />, + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/mosaic/src/components/copy-button/copy-button.tsx b/packages/mosaic/src/components/copy-button/copy-button.tsx new file mode 100644 index 00000000000..0e87584a01a --- /dev/null +++ b/packages/mosaic/src/components/copy-button/copy-button.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +import type { ButtonProps } from '../button'; +import { Button } from '../button'; +import { Icon } from '../icon'; +import { Tooltip } from '../tooltip'; +import { VisuallyHidden } from '../visually-hidden'; + +export interface CopyButtonProps extends Omit { + value: string; + label?: string; + copiedLabel?: string; + placement?: 'top' | 'bottom' | 'left' | 'right'; + resetAfter?: number; + onCopy?: (value: string) => void | Promise; +} + +export const CopyButton = React.forwardRef(function MosaicCopyButton( + { + value, + label = 'Copy', + copiedLabel = 'Copied', + placement = 'top', + resetAfter = 2000, + onCopy, + color = 'neutral', + variant = 'ghost', + size = 'xs', + shape = 'square', + onClick, + ...rest + }, + ref, +) { + const [copied, setCopied] = React.useState(false); + const timeout = React.useRef>(undefined); + + React.useEffect(() => () => clearTimeout(timeout.current), []); + + const settle = () => { + clearTimeout(timeout.current); + setCopied(false); + }; + + const copy = async () => { + try { + await (onCopy ? onCopy(value) : navigator.clipboard.writeText(value)); + } catch { + // A blocked clipboard leaves the button as it was; there is nothing to report. + return; + } + + setCopied(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setCopied(false), resetAfter); + }; + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (!event.defaultPrevented) { + void copy(); + } + }; + + return ( + { + if (!next) { + settle(); + } + }} + > + + } + onClick={handleClick} + > + + }>{copied ? copiedLabel : ''} + + + {copiedLabel} + + + ); +}); diff --git a/packages/mosaic/src/components/copy-button/index.ts b/packages/mosaic/src/components/copy-button/index.ts new file mode 100644 index 00000000000..067cd97f96b --- /dev/null +++ b/packages/mosaic/src/components/copy-button/index.ts @@ -0,0 +1,2 @@ +export { CopyButton } from './copy-button'; +export type { CopyButtonProps } from './copy-button'; diff --git a/packages/mosaic/src/components/field/field.styles.ts b/packages/mosaic/src/components/field/field.styles.ts index b7f5406c529..725113a9037 100644 --- a/packages/mosaic/src/components/field/field.styles.ts +++ b/packages/mosaic/src/components/field/field.styles.ts @@ -1,12 +1,10 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, durationVars, easingVars, fontWeightVars, space } from '../../tokens.stylex'; - -const ROOT_GAP = space['2']; +import { colorVars, fontWeightVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ root: { - gap: ROOT_GAP, + gap: space['2'], display: 'flex', flexDirection: 'column', }, @@ -20,50 +18,4 @@ export const styles = stylex.create({ description: { color: colorVars['--cl-color-foreground-secondary'], }, - messageRoot: { - overflow: 'clip', - alignContent: 'start', - display: 'grid', - position: 'relative', - transitionDuration: durationVars['--cl-duration-slow'], - transitionProperty: { - default: 'height, margin-top', - '@media (prefers-reduced-motion: reduce)': 'none', - }, - transitionTimingFunction: easingVars['--cl-ease-enter'], - height: { - default: 'var(--_cl-field-message-height)', - ':where(:not([data-open]), [data-starting-style])': 0, - }, - marginTop: { - default: 0, - ':where(:not([data-open]), [data-starting-style])': `calc(-1 * ${ROOT_GAP})`, - }, - }, - feedback: { - inset: { default: null, ':where([data-ending-style])': '0 0 auto' }, - gap: space['1'], - alignItems: 'flex-start', - display: 'flex', - opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 }, - position: { default: null, ':where([data-ending-style])': 'absolute' }, - textWrap: 'pretty', - transitionDuration: durationVars['--cl-duration-base'], - transitionProperty: 'opacity', - transitionTimingFunction: 'linear', - }, - error: { - color: colorVars['--cl-color-negative'], - }, - success: { - color: colorVars['--cl-color-positive'], - }, - feedbackIcon: { - flexShrink: 0, - height: '1lh', - }, -}); - -export const dynamic = stylex.create({ - messageHeight: (height: number) => ({ '--_cl-field-message-height': `${height}px` }), }); diff --git a/packages/mosaic/src/components/field/field.test.tsx b/packages/mosaic/src/components/field/field.test.tsx index 1eea5b36df5..8d5a1e61c10 100644 --- a/packages/mosaic/src/components/field/field.test.tsx +++ b/packages/mosaic/src/components/field/field.test.tsx @@ -391,7 +391,7 @@ describe('Mosaic Field', () => { '@media (prefers-reduced-motion: reduce)': 'none', }, height: { - default: 'var(--_cl-field-message-height)', + default: 'var(--_cl-feedback-height)', ':where(:not([data-open]), [data-starting-style])': 0, }, }, diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index e2cf715b14c..28b5c1470b0 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -1,4 +1,3 @@ -import { useSafeLayoutEffect } from '@clerk/shared/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -7,10 +6,11 @@ import { useTransition } from '../../primitives/hooks'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { FeedbackBody, hasMessage, useHeldMessage, useMessageHeight } from '../../utils/feedback'; +import { feedbackHeight, feedbackStyles } from '../../utils/feedback.styles'; import { reset } from '../../utils/reset.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import { visuallyHidden } from '../../utils/visually-hidden.styles'; -import { Icon } from '../icon'; import { FieldMessageProvider, FieldProvider, @@ -18,7 +18,7 @@ import { useRegisterFieldMessage, useRegisterFieldPartId, } from './field.context'; -import { dynamic, styles } from './field.styles'; +import { styles } from './field.styles'; function useNativeLabelWarning(label: HTMLElement | null) { React.useEffect(() => { @@ -139,29 +139,6 @@ const Description = React.forwardRef; -function useMessageHeight(active: HTMLElement | null) { - const [height, setHeight] = React.useState(0); - - useSafeLayoutEffect(() => { - if (!active) { - return undefined; - } - - const measure = () => setHeight(active.offsetHeight); - measure(); - - if (typeof ResizeObserver === 'undefined') { - return undefined; - } - - const observer = new ResizeObserver(measure); - observer.observe(active); - return () => observer.disconnect(); - }, [active]); - - return height; -} - const Message = React.forwardRef(function MosaicFieldMessage( { render, xstyle, children, ...rest }, ref, @@ -186,7 +163,7 @@ const Message = React.forwardRef(function Mos props: { ...mergeStyleProps( themeProps('field-message'), - stylex.props(reset.base, styles.messageRoot, dynamic.messageHeight(height), xstyle), + stylex.props(reset.base, feedbackStyles.collapse, feedbackHeight.measured(height), xstyle), { role: 'status', ...transitionProps }, rest, ), @@ -200,18 +177,14 @@ const Message = React.forwardRef(function Mos type FieldFeedbackKind = 'error' | 'success'; const FEEDBACK: Record = { - error: { slot: 'field-error', icon: 'exclamation-circle', color: styles.error }, - success: { slot: 'field-success', icon: 'checkmark', color: styles.success }, + error: { slot: 'field-error', icon: 'exclamation-circle', color: feedbackStyles.error }, + success: { slot: 'field-success', icon: 'checkmark', color: feedbackStyles.success }, }; interface FieldFeedbackProps extends MosaicComponentProps<'p'> { kind: FieldFeedbackKind; } -function hasMessage(children: React.ReactNode) { - return React.Children.toArray(children).some(child => child !== ''); -} - const FieldFeedback = React.forwardRef(function MosaicFieldFeedback( { render, xstyle, id: idProp, children, kind, ...rest }, ref, @@ -223,10 +196,7 @@ const FieldFeedback = React.forwardRef const open = hasMessage(children); const element = React.useRef(null); const { mounted, transitionProps } = useTransition({ open, ref: element }); - const lastMessage = React.useRef(children); - if (open) { - lastMessage.current = children; - } + const message = useHeldMessage(children, open); useRegisterFieldPartId(open ? id : undefined, context?.setMessageIds); const registerMessage = useRegisterFieldMessage(open); return useRender({ @@ -237,30 +207,12 @@ const FieldFeedback = React.forwardRef props: { ...mergeStyleProps( themeProps(slot), - stylex.props( - reset.base, - typographyStyles.base, - typographySizes.xs, - styles.message, - styles.feedback, - color, - xstyle, - ), + stylex.props(reset.base, typographyStyles.base, typographySizes.xs, feedbackStyles.message, color, xstyle), { 'aria-hidden': open ? undefined : true, ...transitionProps }, rest, ), id, - children: ( - <> -