diff --git a/.changeset/spicy-avatars-change.md b/.changeset/spicy-avatars-change.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/spicy-avatars-change.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/swingset-toast-todo-status.md b/.changeset/swingset-toast-todo-status.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-toast-todo-status.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d7e76660da5..1cea17b2234 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -60,6 +60,7 @@ const docModules: Record> = { profile: dynamic(() => import('../stories/profile.component.mdx')), section: dynamic(() => import('../stories/section.mdx')), table: dynamic(() => import('../stories/table.mdx')), + toast: dynamic(() => import('../stories/toast.mdx')), text: dynamic(() => import('../stories/text.mdx')), field: dynamic(() => import('../stories/field.component.mdx')), flow: dynamic(() => import('../stories/flow.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9dd721943ac..b3455222a56 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -159,6 +159,7 @@ import { meta as textMeta, Sizes as TextSizes, } from '../stories/text.stories'; +import { meta as toastMeta } from '../stories/toast.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; import { @@ -419,6 +420,7 @@ const useDataTableModule: StoryModule = { meta: useDataTableMeta }; // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const tableModule: StoryModule = { meta: tableMeta }; +const toastModule: StoryModule = { meta: toastMeta }; const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, @@ -568,6 +570,7 @@ export const registry: StoryModule[] = [ sectionModule, tableModule, textModule, + toastModule, fieldModule, visuallyHiddenModule, // Primitives — alphabetical within the group. diff --git a/packages/swingset/src/stories/fixtures/use-preview-image.ts b/packages/swingset/src/stories/fixtures/use-preview-image.ts new file mode 100644 index 00000000000..10b34995775 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/use-preview-image.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Holds the avatar a story is showing and swaps in an object URL for a picked file, revoking the + * one it replaces so repeated picks don't retain every earlier file for the life of the page. + * Only URLs this hook created are revoked, so the remote avatar it starts on is left alone. + */ +export function usePreviewImage(initialUrl?: string) { + const [imageUrl, setImageUrl] = useState(initialUrl); + const objectUrlRef = useRef(undefined); + + const release = useCallback(() => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = undefined; + } + }, []); + + useEffect(() => release, [release]); + + const showFile = useCallback( + (file: File) => { + release(); + const next = URL.createObjectURL(file); + objectUrlRef.current = next; + setImageUrl(next); + }, + [release], + ); + + const clearImage = useCallback(() => { + release(); + setImageUrl(undefined); + }, [release]); + + return { imageUrl, showFile, clearImage }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 47bff61cd50..f265e38f1d3 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -12,6 +12,8 @@ import type { } from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; import { useMemo, useState } from 'react'; +import { usePreviewImage } from './use-preview-image'; + export interface UserProfileFixtureOptions { /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ onAddEmail?: () => void; @@ -96,6 +98,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const [apiKeysPageSize, setAPIKeysPageSize] = useState(10); const [searchValue, setSearchValue] = useState(''); const [selectedIds, setSelectedIds] = useState([]); + const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); const visibleAPIKeys = useMemo( () => apiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(searchValue.toLowerCase())), [apiKeys, searchValue], @@ -107,7 +110,8 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const pages: UserProfileViewProps['pages'] = { account: { allowMultipleAccounts: true, - imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', + hasImage: Boolean(imageUrl), + imageUrl, name: 'Preston Booth', username: 'prestonxyz', emails, @@ -123,11 +127,12 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions }, ]), onDeleteAccount: () => Promise.resolve(), - onEditProfilePicture: () => undefined, onManageEmail: () => undefined, onManagePhone: () => undefined, onNameChange: () => undefined, + onProfilePictureChange: showFile, onRemoveEmail: id => setEmails(current => current.filter(email => email.id !== id)), + onRemoveProfilePicture: clearImage, onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), diff --git a/packages/swingset/src/stories/toast.mdx b/packages/swingset/src/stories/toast.mdx new file mode 100644 index 00000000000..3c56edca5ac --- /dev/null +++ b/packages/swingset/src/stories/toast.mdx @@ -0,0 +1,5 @@ +# Toast + +Transient, self-dismissing message raised over the surface it belongs to, for feedback that has nowhere of its own to live — a picture the picker turned away, an action that failed after the row it started from is gone. Not yet implemented. + +Until it lands, a row that must report a failure renders it inline: `Section.Error` under the row, or `Field.Error` inside a `Field.Root`. The profile picture row is the case this component is meant to take over. diff --git a/packages/swingset/src/stories/toast.stories.tsx b/packages/swingset/src/stories/toast.stories.tsx new file mode 100644 index 00000000000..3bb7fa8d679 --- /dev/null +++ b/packages/swingset/src/stories/toast.stories.tsx @@ -0,0 +1,7 @@ +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Toast', + status: 'todo', +}; diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 23da0290b27..88229333df9 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -7,6 +7,8 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { usePreviewImage } from './fixtures/use-preview-image'; + export { default as __source } from './user-profile-account-section.stories?raw'; export const meta: StoryMeta = { @@ -30,12 +32,14 @@ function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: bool const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); + const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); return ( undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} + onProfilePictureChange={showFile} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onRemoveProfilePicture={clearImage} onNameChange={() => undefined} onUsernameChange={() => undefined} /> diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 1912b2c907d..334506009b2 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -4,6 +4,8 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { usePreviewImage } from './fixtures/use-preview-image'; + const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; @@ -26,6 +28,7 @@ export function Default(_args: Record) { const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); + const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); return ( ) { connected: false, }, ]} - imageUrl={profileImageUrl} + hasImage={Boolean(imageUrl)} + imageUrl={imageUrl} name='Preston Booth' phones={phones} username='prestonxyz' @@ -79,10 +83,11 @@ export function Default(_args: Record) { } onConnectAccount={() => undefined} onDeleteAccount={() => Promise.resolve()} - onEditProfilePicture={() => undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} + onProfilePictureChange={showFile} onRemoveConnectedAccount={() => undefined} + onRemoveProfilePicture={clearImage} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onConnectWeb3Wallet={() => undefined} diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index 57991bbc1c5..17fc126453d 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -128,5 +128,20 @@ export const styles = stylex.create({ flexShrink: 0, justifyContent: 'flex-end', }, + // Sits under the row's item rather than inside its content, so a message never shifts the + // media and actions off the centre line they share. + error: { + margin: 0, + gap: space['1'], + alignItems: 'flex-start', + color: colorVars['--cl-color-negative'], + display: 'flex', + textWrap: 'pretty', + width: '100%', + }, + errorIcon: { + flexShrink: 0, + height: '1lh', + }, }); /* eslint-enable @stylexjs/no-lookahead-selectors */ diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx index e4d2b349761..0de00309e56 100644 --- a/packages/ui/src/mosaic/components/section/section.test.tsx +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -153,4 +153,36 @@ describe('Section', () => { expect(screen.getByText('Name')).toHaveStyle({ color: 'rgb(255, 0, 0)' }); expect(actionsRef.current).toHaveClass('cl-section-actions'); }); + + it('renders a row-level error as a sibling of the item, with the alert glyph', () => { + render( + + + + + + Profile picture + + + File type not supported. + + + , + ); + + const error = screen.getByTestId('error'); + expect(error).toHaveClass('cl-section-error'); + expect(error.tagName).toBe('P'); + // A row-level message announces itself; there is no field to describe it. + expect(error).toHaveAttribute('role', 'alert'); + expect(error).toHaveTextContent('File type not supported.'); + // Outside the item, so the item's media and actions keep their centre line. + expect(screen.getByTestId('item')).not.toContainElement(error); + expect(screen.getByTestId('row')).toContainElement(error); + + const icon = error.querySelector('.cl-icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + expect(icon).toHaveAttribute('data-size', 'sm'); + }); }); diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 90eac350ad0..e9c895e33a7 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -6,8 +6,10 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import type { HeadingProps } from '../heading'; import { Heading } from '../heading'; +import { Icon } from '../icon'; import { sectionItemsMarker } from './section.markers.stylex'; import { styles } from './section.styles'; @@ -23,6 +25,7 @@ export type SectionContentProps = MosaicComponentProps<'div'>; export type SectionLabelProps = MosaicComponentProps<'div'>; export type SectionDescriptionProps = MosaicComponentProps<'div'>; export type SectionActionsProps = MosaicComponentProps<'div'>; +export type SectionErrorProps = MosaicComponentProps<'p'>; const mediaSizes = { sm: styles.mediaSm, @@ -250,8 +253,59 @@ const Actions = React.forwardRef(function S }); }); +/** + * A row-level message, mirroring `Field.Error` for a row that holds no form control. Place it as a + * sibling of `Section.Item` inside `Section.Row`, not inside `Section.Content`: the item stays a + * single centred line, so the media and actions hold their position whether or not it is showing. + * Carries `role='alert'` for the announcement a `Field.Root` would otherwise wire up. + */ +const SectionError = React.forwardRef(function SectionError( + { render, className, style, children, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('section-error'), + stylex.props(reset.base, typographyStyles.base, typographySizes.xs, styles.error), + className, + style, + ), + role: 'alert', + ...rest, + children: ( + <> +