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/action-menu/action-menu.test.tsx b/packages/mosaic/src/components/action-menu/action-menu.test.tsx new file mode 100644 index 00000000000..56bf6f568f9 --- /dev/null +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { ActionMenu } from './action-menu'; + +function renderMenu(actions: Parameters[0]['actions']) { + return render( + + + , + ); +} + +describe('Mosaic ActionMenu', () => { + it('renders nothing when there are no actions', () => { + renderMenu([]); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('names its trigger and runs the action picked from it', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + renderMenu([ + { label: 'Rename', icon: 'pen', onClick }, + { label: 'Remove', color: 'negative', onClick: vi.fn() }, + ]); + + await user.click(screen.getByRole('button', { name: 'Manage passkey' })); + await user.click(await screen.findByRole('menuitem', { name: 'Rename' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx similarity index 73% rename from packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx rename to packages/mosaic/src/components/action-menu/action-menu.tsx index 19750de3c17..480c4655625 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -1,26 +1,23 @@ import type { Ref } from 'react'; -import { Icon } from '../../components/icon'; -import { Menu } from '../../components/menu'; import type { IconName } from '../../icons/registry'; +import { Icon } from '../icon'; +import { Menu } from '../menu'; -export interface UserProfileMenuAction { +export interface ActionMenuAction { label: string; color?: 'neutral' | 'negative'; icon?: IconName; onClick: () => void; } -export function UserProfileActionMenu({ - label, - actions, - triggerRef, -}: { +export interface ActionMenuProps { label: string; - actions: UserProfileMenuAction[]; - /** The trigger element, for a caller that has to hand focus back to this row. */ + actions: ActionMenuAction[]; triggerRef?: Ref; -}) { +} + +export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { if (actions.length === 0) { return null; } diff --git a/packages/mosaic/src/components/action-menu/index.ts b/packages/mosaic/src/components/action-menu/index.ts new file mode 100644 index 00000000000..677d8fd8576 --- /dev/null +++ b/packages/mosaic/src/components/action-menu/index.ts @@ -0,0 +1,2 @@ +export { ActionMenu } from './action-menu'; +export type { ActionMenuAction, ActionMenuProps } from './action-menu'; diff --git a/packages/mosaic/src/components/copy-button/copy-button.styles.ts b/packages/mosaic/src/components/copy-button/copy-button.styles.ts new file mode 100644 index 00000000000..18c8307d6fa --- /dev/null +++ b/packages/mosaic/src/components/copy-button/copy-button.styles.ts @@ -0,0 +1,49 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + toast: { + borderRadius: radiusVars['--cl-radius-md'], + gap: space['1'], + paddingBlock: space['1'], + paddingInline: space['1.5'], + alignItems: 'center', + backgroundColor: colorVars['--cl-color-foreground'], + color: colorVars['--cl-color-background'], + // A copy while one is still showing queues behind the `limit`; only the newest is wanted. + display: { default: 'flex', ':where([data-limited])': 'none' }, + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + }, + transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.94)', + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, + }, + transformOrigin: 'var(--cl-anchor-origin, center)', + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: { + default: 'opacity, transform', + '@media (prefers-reduced-motion: reduce)': 'opacity', + }, + transitionTimingFunction: { + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, + whiteSpace: 'nowrap', + }, + title: { + font: 'inherit', + margin: 0, + }, +}); 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..4c91fb33603 --- /dev/null +++ b/packages/mosaic/src/components/copy-button/copy-button.test.tsx @@ -0,0 +1,119 @@ +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('confirms the copy in a toast and keeps the button named', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + const toast = await screen.findByRole('dialog', { name: 'Copied' }); + expect(toast.querySelector('.cl-icon')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + }); + + it('announces the copy from the region the button names', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Copy slug' })); + + expect(await screen.findByRole('region', { name: 'Copy slug' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Copied')); + }); + + it('takes the confirmation text from copiedLabel', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + expect(await screen.findByRole('dialog', { name: 'Slug copied' })).toBeInTheDocument(); + }); + + it('settles back after the confirmation expires', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + await screen.findByRole('dialog'); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('shows one confirmation when the value is copied twice', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + await screen.findByRole('dialog'); + await user.click(screen.getByRole('button', { name: 'Copy' })); + + await waitFor(() => expect(screen.getAllByRole('dialog')).toHaveLength(1)); + }); + + 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('confirms nothing when the copy fails, and hands the caller the reason', async () => { + const onCopyError = vi.fn(); + const denied = new Error('denied'); + const user = userEvent.setup(); + render( + Promise.reject(denied)} + onCopyError={onCopyError} + />, + ); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + await waitFor(() => expect(onCopyError).toHaveBeenCalledWith(denied)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); 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..ff29996ff4b --- /dev/null +++ b/packages/mosaic/src/components/copy-button/copy-button.tsx @@ -0,0 +1,127 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { ToastSide } from '../../primitives/toast'; +import { Toast } from '../../primitives/toast'; +import { mergeStyleProps, themeProps } from '../../props'; +import type { ButtonProps } from '../button'; +import { Button } from '../button'; +import { Icon } from '../icon'; +import { styles } from './copy-button.styles'; + +const SIDE_OFFSET = 4; + +export interface CopyButtonProps extends Omit { + value: string; + label?: string; + copiedLabel?: string; + side?: ToastSide; + resetAfter?: number; + onCopy?: (value: string) => void | Promise; + onCopyError?: (error: unknown) => void; +} + +export const CopyButton = React.forwardRef(function MosaicCopyButton( + { resetAfter = 2000, ...rest }, + ref, +) { + return ( + + + + ); +}); + +const CopyButtonTrigger = React.forwardRef>( + function CopyButtonTrigger( + { + value, + label = 'Copy', + copiedLabel = 'Copied', + side = 'top', + onCopy, + onCopyError, + color = 'neutral', + variant = 'ghost', + size = 'xs', + shape = 'square', + onClick, + ...rest + }, + ref, + ) { + const manager = Toast.useToastManager(); + + const copy = async (anchor: HTMLElement) => { + try { + await (onCopy ? onCopy(value) : navigator.clipboard.writeText(value)); + } catch (error) { + // A blocked clipboard confirms nothing; the caller decides whether to say so. + onCopyError?.(error); + return; + } + manager.add({ + title: copiedLabel, + positionerProps: { anchor, side, sideOffset: SIDE_OFFSET }, + }); + }; + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (!event.defaultPrevented) { + void copy(event.currentTarget); + } + }; + + return ( + <> + + + + {manager.toasts.map(toast => ( + + + + + + ))} + + + + ); + }, +); 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: ( - <> -