diff --git a/.changeset/section-skeletons.md b/.changeset/section-skeletons.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/section-skeletons.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index ebf0fb0b109..ecefc1f1a64 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -14,6 +14,7 @@ Read the guide for the task you are doing. Paths below are relative to this pack | Author a headless primitive | [Headless primitives](docs/headless.md) | | Style a component or change the CSS build | [StyleX](docs/stylex.md) | | Add or debug motion | [Motion](docs/motion.md) | +| Build a loading skeleton | [Skeletons](docs/skeletons.md) | | Write a model | [Models](docs/models.md) | | Write a controller | [Controllers](docs/controllers.md) | | Author or debug a state machine | [Controllers](docs/controllers.md), then [Machine runtime](src/machine/README.md) | diff --git a/packages/mosaic/docs/motion.md b/packages/mosaic/docs/motion.md index c3456049e83..27e9f966960 100644 --- a/packages/mosaic/docs/motion.md +++ b/packages/mosaic/docs/motion.md @@ -1,4 +1,4 @@ -# Motion: entrances, exits, and pulses +# Motion: entrances, exits, and loading Token semantics live in `packages/mosaic/src/tokens.stylex.ts`, above `durationDefaults` / `easingDefaults` — read those comments first. This file is the @@ -36,13 +36,11 @@ For entrances, opacity takes `--cl-ease-enter`: there is nothing past `1` to overshoot into, so the pass is clamped away and only its cost — the slower approach to full opacity — is left. -## Repeating pulses +## Loading skeletons -Use `--cl-ease-pulse` for repeating opacity fades such as loading skeletons -(`user-profile-backup-codes.styles.ts`). Its -symmetric curve slows at both ends of each fade, keeping the reversal smooth. -Keep the pulse duration on the component and disable the animation under -`prefers-reduced-motion: reduce`. +Loading placeholders shimmer: a highlight sweeps across each one over 1.6s on the +plain `ease-in-out` keyword, not a token. How to build them, size them, and tune the +shimmer is in `skeletons.md`. ## A curve has a direction — don't run the entrance curve backwards diff --git a/packages/mosaic/docs/skeletons.md b/packages/mosaic/docs/skeletons.md new file mode 100644 index 00000000000..5bdce05452c --- /dev/null +++ b/packages/mosaic/docs/skeletons.md @@ -0,0 +1,121 @@ +# Skeletons: loading placeholders + +A skeleton stands in for content that is being fetched and is not yet on screen. It +has the exact size of that content, so the swap never shifts layout, and a highlight +sweeps across every bone. + +## What to render as bones + +When a surface loads as one unit (a panel, a table on first load), render the whole +thing as bones, including titles, card headings and column headers that are already +known: one loading state reads cleaner, shimmers as one surface, and swaps to content in one +moment. A unit is what the user sees appear at once, however many requests feed it. + +Content that isn't being fetched keeps rendering. A section loaded by a later request +shows bones beside sections that have already loaded, and a table moving to another +page turns its rows into bones while its headers and controls stay. Whether a table's +controls are bones on first load is still open. + +Actions (buttons, menus) are not drawn, but their height is held when it outgrows +the content beside it. + +## Building one + +Each component defines its own bones, but every bone uses the shared shimmer and the +same fill, `--cl-color-neutral-alpha-200`. + +- **Render the real view with mock data.** The view takes `skeleton` and passes it + to its container (``). Every part inside + inherits it and becomes a bone (`skeleton={false}` opts one out), and the + container turns `inert` and `aria-hidden`. Because the skeleton is the view's own + markup, it can't drift from it: change a row and the skeleton changes with it. + While `skeleton` is set, the view skips its dialogs and confirmations. +- **The `.skeleton.tsx` is one line** next to the view, rendering it with a + `PLACEHOLDER_*` constant: + `` + (see `user-profile-active-devices-section.skeleton.tsx`). A panel's skeleton + composes its sections' skeletons. +- **Mock data decides row count, shape, and text length.** Text bones are drawn + from the mock strings, so write them at a typical length: a placeholder that + wraps to two lines where real text takes one shifts the layout. Aim at the most + common loaded shape: a typical count (2–3), every line present, and the branch + real data usually takes (a current device, not the empty state). When the count + is known before the fetch, use it instead: a table moving between pages with a + known total shows exactly `min(pageSize, total − offset)` rows. A first load or a + new search can't know, so it uses the typical count and accepts a shift. The page + size is a maximum, not a count. +- **Announce it.** The skeleton is hidden from assistive technology, so the view + renders a `VisuallyHidden` `role='status'` message ("Loading active devices") + while `skeleton` is set. +- **Parts also take `skeleton` standalone**, as does `Panel.Title`, for a bone + outside a skeleton container. +- **What each part does:** text parts (Title, Label, Description, `Panel.Title`) + keep their mock text and wrap it in `SkeletonText`; Media renders empty as a + filled block; Actions render nothing and hold a small control's height. +- **A component with its own shape** composes the pieces in + `styles/skeleton.styles.ts` itself: + - `SkeletonText` (`utils/skeleton-text.tsx`) around mock text: one span, one + line, as wide as its text, drawing a bar with its `::before`. The span is + `visibility: hidden` and only the bar is visible, so anything nested in the + mock text (a badge, a link, an icon) keeps its width but never draws. Leave + badges out of mock rows entirely; a skeleton doesn't draw them. + - `skeletonStyles.bone`: fill and radius, for media and blocks. + - `skeletonStyles.shimmer`: the moving highlight for a block, as an `::after` + overlay. `Avatar.Fallback`, which has its own fill and circle, takes `shimmer` + alone. + +## Sizing + +- **Height must match exactly.** Text bones are the text's own line boxes, so text + rows match by construction. Watch anything taller than its text: + `Section.Actions skeleton` exists because a 28px `sm` menu trigger outgrew a 20px + line. +- **The bar is cap height on the baseline.** `SkeletonText` is one line of the + part's own type (`1lh` tall, so heights match), and its `::before` bar is `1cap` + tall (`0.7em` fallback), sitting on the baseline with a pill radius. It covers + the same band as the text's capitals in any font, with nothing measured, and + stacked lines keep a natural gap. +- **Width comes from the mock text.** The span is `width: fit-content` around its + transparent mock text, and the bar fills it (a `-100%` end margin keeps the bar + from taking space in the line, so it shares the text's baseline). So each bar is + exactly as long as the text it stands in for. Mock text is held to one line + (`nowrap`, clipped at the part's width), so a long placeholder can't wrap and + shift the layout; keep placeholders to one line of real content. With no mock + text, the bar falls back to `12ch`. + +## The shimmer + +Modeled on React Spectrum's `Skeleton`, with no JavaScript: a highlight one bone wide, +peaking in the middle, sweeps left to right across two bone widths, over 1.6s +`ease-in-out` (the CSS keyword, not a token), repeating. Off under +`prefers-reduced-motion: reduce`, leaving the plain fill. + +- **Blocks** (media, the avatar) move an `::after` overlay with `transform` + (`translateX(-100%)` → `translateX(100%)`), clipped by `overflow: hidden`. +- **Text bars** are themselves a pseudo-element, so they move their own + background instead: a gradient `300%` wide (transparent, highlight, transparent + at 33% / 50% / 66%) over the fill, from `background-position: 100%` to `0%`. + Same geometry and timing as the overlay. +- **The highlight** is a wash over the fill, one value in both techniques: + background-tinted in light mode, neutral-tinted in dark. +- **No sync.** Bones that mount together sweep together; a section mounting later + runs out of step. CSS animations keep running through hydration, so a + server-rendered skeleton doesn't jump. +- The sweep is relative to each bone's width, so a wide bar's highlight moves + faster than a narrow one's. + +## When to show it + +Not decided by the skeleton. The wiring around a panel owns it. The planned gate is +`useSpinDelay(loading, { delay: 150, minDuration: 500 })` with the skeleton mounted +but `visibility: hidden` until the delay passes, so a fast load never flashes it and +nothing shifts when it appears. + +## Checking one + +- Add a swingset **Loading** story with a **Reload** button that fakes a load (see + `section.stories.tsx` → `Loading`). +- Measure, don't eyeball: with `agent-browser`, compare each row's and card's + `getBoundingClientRect().height` in the skeleton and loaded states. They must be + equal, except where the number of rows differs. +- Clear swingset's `.next` after any `*.styles.ts` edit. diff --git a/packages/mosaic/src/components/avatar/avatar.styles.ts b/packages/mosaic/src/components/avatar/avatar.styles.ts index 96e6a2c5306..f6090d3bc97 100644 --- a/packages/mosaic/src/components/avatar/avatar.styles.ts +++ b/packages/mosaic/src/components/avatar/avatar.styles.ts @@ -2,11 +2,6 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space } from '../../tokens.stylex'; -// Timed to match `skeleton.tsx`'s pulse, so the two generations of placeholder read as one thing. -const pulse = stylex.keyframes({ - '50%': { opacity: 0.5 }, -}); - export const styles = stylex.create({ // root — sizes and positions its parts; fill comes from the image or fallback base: { @@ -71,16 +66,6 @@ export const styles = stylex.create({ visibility: 'hidden', }, - fallbackPending: { - animationDuration: '2s', - animationIterationCount: 'infinite', - animationName: { - default: pulse, - '@media (prefers-reduced-motion: reduce)': 'none', - }, - animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)', - }, - icon: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-full'], diff --git a/packages/mosaic/src/components/avatar/avatar.tsx b/packages/mosaic/src/components/avatar/avatar.tsx index ad8d8531e51..ae2f70278e8 100644 --- a/packages/mosaic/src/components/avatar/avatar.tsx +++ b/packages/mosaic/src/components/avatar/avatar.tsx @@ -7,6 +7,7 @@ import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; +import { skeletonStyles } from '../../styles/skeleton.styles'; import { shapes, sizes, styles } from './avatar.styles'; type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @@ -145,12 +146,12 @@ const AvatarFallback = React.forwardRef(fu return () => clearTimeout(timer); }, [delayMs]); + const pending = canRender && status === 'loading'; + if (!canRender || status === 'loaded') { return null; } - const pending = status === 'loading'; - return ( (fu reset.base, styles.fallback, bordered && styles.overlay, - pending && styles.fallbackPending, + pending && skeletonStyles.shimmer, xstyle, ), rest, @@ -193,7 +194,7 @@ const AvatarIcon = React.forwardRef(function M /** * Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders * once its source loads; `Avatar.Fallback` holds the space until then, as a blank - * placeholder that pulses only while an image is actually on its way; `Avatar.Icon` + * placeholder that shimmers only while an image is actually on its way; `Avatar.Icon` * adds an optional corner affordance. */ export const Avatar = { diff --git a/packages/mosaic/src/components/panel/panel.tsx b/packages/mosaic/src/components/panel/panel.tsx index 71de23b56e2..e0c0364c81c 100644 --- a/packages/mosaic/src/components/panel/panel.tsx +++ b/packages/mosaic/src/components/panel/panel.tsx @@ -1,3 +1,4 @@ +import { inertProps } from '@clerk/shared/inert'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -5,12 +6,13 @@ import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles'; +import { SkeletonText } from '../../utils/skeleton-text'; import { Heading, HeadingLevelProvider, useHeadingLevel } from '../heading'; import { ContentPanelContext, ProfileContext } from '../profile/profile.context'; import { styles } from './panel.styles'; export type PanelRootProps = MosaicComponentProps<'div'>; -export type PanelTitleProps = MosaicComponentProps<'div'>; +export type PanelTitleProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type PanelSectionsProps = MosaicComponentProps<'div'>; const Root = React.forwardRef(function PanelRoot({ render, xstyle, ...rest }, ref) { @@ -24,7 +26,7 @@ const Root = React.forwardRef(function PanelRoot // Inside a profile page, the profile renders the page title, and the ref reaches that instead. const Title = React.forwardRef(function PanelTitle( - { children, render, xstyle, ...rest }, + { skeleton = false, children, render, xstyle, ...rest }, ref, ) { const inProfilePage = React.useContext(ContentPanelContext); @@ -38,13 +40,18 @@ const Title = React.forwardRef(function PanelTi ref: pageTitleRef ? null : ref, enabled: !inProfilePage, props: { - ...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), rest), + ...mergeStyleProps( + themeProps('panel-title', { skeleton }), + stylex.props(reset.base, styles.title, xstyle), + skeleton ? { 'aria-hidden': true, ...inertProps(true) } : {}, + rest, + ), children: ( - {children} + {skeleton ? {children} : children} ), }, diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 12499be1226..575265a9ea4 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -125,7 +125,6 @@ export const styles = stylex.create({ flexDirection: 'column', flexGrow: 1, justifyContent: 'center', - rowGap: space['0.5'], minWidth: 0, }, label: { @@ -156,6 +155,9 @@ export const styles = stylex.create({ maxWidth: '50%', minWidth: 0, }, + actionsSkeleton: { + height: space['7'], + }, note: { alignItems: 'center', color: colorVars['--cl-color-foreground-secondary'], diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index c1c29f54768..5501f40505b 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -419,4 +419,52 @@ describe('Section', () => { expect(screen.getByTestId('item')).toHaveAttribute('data-wrap', ''); }); + + it('turns a skeleton card into inert placeholders', () => { + render( + + + + Account + + + + + media + + Name + Description + + + + + + + + + , + ); + + const group = screen.getByTestId('group'); + expect(group).toHaveAttribute('aria-hidden', 'true'); + expect(group).toHaveAttribute('inert'); + expect(group).not.toHaveAttribute('role'); + expect(group.querySelector('button')).toBeNull(); + expect(screen.getByText('Name')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('Description')).toHaveAttribute('aria-hidden', 'true'); + expect(group.querySelectorAll('[data-skeleton]')).toHaveLength(5); + }); + + it('lets a part opt out of its card skeleton', () => { + render( + + Name + , + ); + + expect(screen.getByText('Name')).not.toHaveAttribute('data-skeleton'); + }); }); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 1e7a264ec59..d92fe5b3896 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -1,3 +1,4 @@ +import { inertProps } from '@clerk/shared/inert'; import { useSafeLayoutEffect } from '@clerk/shared/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -8,8 +9,10 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { feedbackHeight, feedbackStyles } from '../../styles/feedback.styles'; import { reset } from '../../styles/reset.styles'; +import { skeletonStyles } from '../../styles/skeleton.styles'; import { sizes as typographySizes, styles as typographyStyles, truncationStyles } from '../../styles/typography.styles'; import { FeedbackBody, hasMessage, useHeldMessage, useMessageHeight } from '../../utils/feedback'; +import { SkeletonText } from '../../utils/skeleton-text'; import { withTruncatableLabel } from '../../utils/truncatable-label'; import type { HeadingProps } from '../heading'; import { Heading, useHeadingLevel } from '../heading'; @@ -17,19 +20,19 @@ import { sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; -export type SectionGroupProps = MosaicComponentProps<'div'>; +export type SectionGroupProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type SectionHeaderProps = MosaicComponentProps<'div'>; -export type SectionTitleProps = Omit; +export type SectionTitleProps = Omit & { skeleton?: boolean }; export type SectionBodyProps = MosaicComponentProps<'div'>; export type SectionRowProps = MosaicComponentProps<'div'>; export type SectionItemsProps = MosaicComponentProps<'ul'>; export type SectionItemProps = MosaicComponentProps<'div'> & { wrap?: boolean }; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; -export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMediaSize }; +export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMediaSize; skeleton?: boolean }; export type SectionContentProps = MosaicComponentProps<'div'>; -export type SectionLabelProps = MosaicComponentProps<'div'>; -export type SectionDescriptionProps = MosaicComponentProps<'div'>; -export type SectionActionsProps = MosaicComponentProps<'div'>; +export type SectionLabelProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; +export type SectionDescriptionProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; +export type SectionActionsProps = MosaicComponentProps<'div'> & { skeleton?: boolean }; export type SectionNoteProps = MosaicComponentProps<'div'> & { icon?: React.ReactNode }; export type SectionErrorProps = MosaicComponentProps<'p'>; @@ -44,6 +47,12 @@ const SectionGroupContext = React.createContext(function SectionRoot({ render, xstyle, ...rest }, ref) { return useRender({ @@ -55,7 +64,7 @@ const Root = React.forwardRef(function SectionRoo }); const Group = React.forwardRef(function SectionGroup( - { render, xstyle, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, + { skeleton = false, render, xstyle, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, ref, ) { const [titleId, setTitleId] = React.useState(); @@ -64,15 +73,29 @@ const Group = React.forwardRef(function Secti defaultTagName: 'div', render, ref, - props: { - role: 'group', - ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleId), - }, + props: skeleton + ? { + 'aria-hidden': true, + ...inertProps(true), + ...mergeStyleProps( + themeProps('section-group', { skeleton }), + stylex.props(reset.base, styles.group, xstyle), + rest, + ), + } + : { + role: 'group', + ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleId), + }, }); - return {element}; + return ( + + {element} + + ); }); const Header = React.forwardRef(function SectionHeader( @@ -90,13 +113,14 @@ const Header = React.forwardRef(function Sec }); const Title = React.forwardRef(function SectionTitle( - { id: idProp, xstyle, ...rest }, + { id: idProp, skeleton: skeletonProp, xstyle, children, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); const setTitleId = React.useContext(SectionGroupContext); const generatedId = React.useId(); const level = useHeadingLevel(); - const id = idProp ?? (setTitleId ? `cl-section-${generatedId}-title` : undefined); + const id = skeleton ? undefined : (idProp ?? (setTitleId ? `cl-section-${generatedId}-title` : undefined)); useSafeLayoutEffect(() => { if (!id || !setTitleId) { @@ -114,8 +138,10 @@ const Title = React.forwardRef(function S level={level} size='base' xstyle={[styles.title, truncationStyles.singleLine, xstyle]} - {...mergeStyleProps(themeProps('section-title'), rest)} - /> + {...mergeStyleProps(themeProps('section-title', { skeleton }), rest)} + > + {skeleton ? {children} : children} + ); }); @@ -179,17 +205,25 @@ const Item = React.forwardRef(function Section }); const Media = React.forwardRef(function SectionMedia( - { size = 'md', render, xstyle, ...rest }, + { size = 'md', skeleton: skeletonProp, render, xstyle, children, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); return useRender({ defaultTagName: 'div', render, ref, props: mergeStyleProps( - themeProps('section-media', { size }), - stylex.props(reset.base, styles.mediaBase, mediaSizes[size], xstyle), - rest, + themeProps('section-media', { size, skeleton }), + stylex.props( + reset.base, + styles.mediaBase, + mediaSizes[size], + skeleton && skeletonStyles.bone, + skeleton && skeletonStyles.shimmer, + xstyle, + ), + { ...rest, children: skeleton ? undefined : children }, ), }); }); @@ -214,40 +248,43 @@ const Content = React.forwardRef(function S }); const Label = React.forwardRef(function SectionLabel( - { render, xstyle, children, ...rest }, + { skeleton: skeletonProp, render, xstyle, children, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); return useRender({ defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps(themeProps('section-label'), stylex.props(reset.base, styles.label, xstyle), rest), - children: withTruncatableLabel(children), - }, + props: mergeStyleProps(themeProps('section-label', { skeleton }), stylex.props(reset.base, styles.label, xstyle), { + ...rest, + children: skeleton ? {children} : withTruncatableLabel(children), + }), }); }); const Description = React.forwardRef(function SectionDescription( - { render, xstyle, ...rest }, + { skeleton: skeletonProp, render, xstyle, children, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); return useRender({ defaultTagName: 'div', render, ref, props: mergeStyleProps( - themeProps('section-description'), + themeProps('section-description', { skeleton }), stylex.props(reset.base, styles.description, xstyle), - rest, + { ...rest, children: skeleton ? {children} : children }, ), }); }); const Actions = React.forwardRef(function SectionActions( - { render, xstyle, ...rest }, + { skeleton: skeletonProp, render, xstyle, children, ...rest }, ref, ) { + const skeleton = useInheritedSkeleton(skeletonProp); const wrap = React.useContext(SectionItemWrapContext); const inHeader = React.useContext(SectionHeaderContext); @@ -256,9 +293,16 @@ const Actions = React.forwardRef(function S render, ref, props: mergeStyleProps( - themeProps('section-actions'), - stylex.props(reset.base, styles.actions, wrap && styles.actionsWrap, inHeader && styles.headerActions, xstyle), - rest, + themeProps('section-actions', { skeleton }), + stylex.props( + reset.base, + styles.actions, + wrap && styles.actionsWrap, + inHeader && styles.headerActions, + skeleton && styles.actionsSkeleton, + xstyle, + ), + { ...rest, children: skeleton ? undefined : children }, ), }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx index 0b4b663c219..d8657bb4e8a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-backup-codes.view.test.tsx @@ -55,7 +55,7 @@ describe('UserProfileBackupCodesView', () => { const { props, rerender } = renderView({ codes: [], pendingAction: 'generate' }); expect(screen.getByRole('progressbar', { name: 'Generating backup codes' })).toBeInTheDocument(); const loading = screen.getByRole('status', { name: 'Generating backup codes' }); - expect(loading.textContent).toBe(''); + expect([...loading.children].every(cell => cell.getAttribute('aria-hidden') === 'true')).toBe(true); expect(screen.queryByRole('list')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Copy and close' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Download', exact: true })).not.toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.skeleton.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.skeleton.tsx new file mode 100644 index 00000000000..dd8ff093af2 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.skeleton.tsx @@ -0,0 +1,17 @@ +import type { UserProfileDevice } from './user-profile-active-devices.types'; +import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; + +const PLACEHOLDER_DEVICES: UserProfileDevice[] = [ + { id: 'current', name: 'Chrome on macOS', description: 'San Francisco, US', type: 'desktop', isCurrent: true }, + { id: 'phone', name: 'Safari on iOS', description: 'San Francisco, US', type: 'mobile' }, + { id: 'laptop', name: 'Firefox on Windows', description: 'Denver, US', type: 'desktop' }, +]; + +export function UserProfileActiveDevicesSectionSkeleton() { + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx index 7a48f1330ae..7caa32b6ec7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.view.tsx @@ -10,6 +10,7 @@ import { Badge } from '../../../components/badge'; import { Button } from '../../../components/button'; import { Dialog } from '../../../components/dialog'; import { Section } from '../../../components/section'; +import { VisuallyHidden } from '../../../components/visually-hidden'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; import type { MosaicMessages } from '../../../localization'; import { fill, plural, useLocale, useMessages } from '../../../localization'; @@ -24,12 +25,14 @@ export interface UserProfileActiveDevicesSectionViewProps { devices: UserProfileDevice[]; onSignOutDevice?: (id: string) => void | Promise; onSignOutAllOtherDevices?: () => void | Promise; + skeleton?: boolean; } export function UserProfileActiveDevicesSectionView({ devices, onSignOutDevice, onSignOutAllOtherDevices, + skeleton = false, }: UserProfileActiveDevicesSectionViewProps) { const m = useMessages('userProfileActiveDevices'); const locale = useLocale(); @@ -66,8 +69,9 @@ export function UserProfileActiveDevicesSectionView({ return (
+ {skeleton ? {m.loading} : null} - + {m.title} @@ -93,6 +97,7 @@ export function UserProfileActiveDevicesSectionView({ deviceDetails.open(device)} /> @@ -108,6 +113,7 @@ export function UserProfileActiveDevicesSectionView({ deviceDetails.open(device)} @@ -117,7 +123,7 @@ export function UserProfileActiveDevicesSectionView({ - {onSignOutAllOtherDevices ? ( + {onSignOutAllOtherDevices && !skeleton ? ( ) : null} - - {onSignOutDevice ? ( + {skeleton ? null : ( + + )} + {onSignOutDevice && !skeleton ? ( ; onViewDetails: (device: UserProfileDevice) => void; onSignOut?: (device: UserProfileDevice) => void; + skeleton?: boolean; }) { const m = useMessages('userProfileActiveDevices'); const actions: ActionMenuAction[] = [{ label: m.viewDetails, onClick: () => onViewDetails(device) }]; @@ -193,7 +203,7 @@ function DeviceItem({ {device.name} - {deviceBadges(device, m).map(label => ( + {(skeleton ? [] : deviceBadges(device, m)).map(label => ( void; onBack?: () => void; @@ -80,10 +83,7 @@ export function UserProfileBackupCodesView({ aria-hidden='true' {...stylex.props(reset.base, styles.cell)} > - } - xstyle={styles.skeleton} - /> +
))} @@ -152,3 +152,11 @@ export function UserProfileBackupCodesView({ ); } + +function BackupCodeSkeleton() { + return ( + }> + {PLACEHOLDER_CODE} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx index e7013d95d4c..c6938eb382d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-icon.tsx @@ -1,9 +1,5 @@ -import * as stylex from '@stylexjs/stylex'; - import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import { mergeStyleProps } from '../../props'; -import { space } from '../../tokens.stylex'; import { styles } from './user-profile-security-panel.styles'; export type UserProfileSecurityIconName = 'authenticator' | 'backup-codes' | 'desktop' | 'mobile' | 'passkey' | 'sms'; @@ -21,7 +17,7 @@ export function UserProfileSecurityIcon({ name }: { name: UserProfileSecurityIco return ( + {children} +
+ ); +} diff --git a/packages/swingset/src/components/StoryEmbed.tsx b/packages/swingset/src/components/StoryEmbed.tsx index c7f4401bc44..0724bb7b9a5 100644 --- a/packages/swingset/src/components/StoryEmbed.tsx +++ b/packages/swingset/src/components/StoryEmbed.tsx @@ -9,6 +9,7 @@ import { toUsageSnippet } from '@/lib/exampleSnippet'; import { extractStorySource } from '@/lib/extractStorySource'; import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; import type { StoryModule } from '@/lib/types'; +import { cn, fillsFrame } from '@/lib/utils'; import { StoryMosaicProvider } from './ChaosProvider'; import { CodeFooter } from './CodeFooter'; @@ -62,7 +63,12 @@ export function StoryEmbed({ name, storyModule, showCode = true, composition }: return (
-
+
*]:w-full', + )} + > diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx index 8ba03946298..6f6333ca48e 100644 --- a/packages/swingset/src/components/StoryPreview.tsx +++ b/packages/swingset/src/components/StoryPreview.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; import type { StoryModule } from '@/lib/types'; +import { cn, fillsFrame } from '@/lib/utils'; import { StoryMosaicProvider } from './ChaosProvider'; import { usePlayground } from './PlaygroundContext'; @@ -49,7 +50,7 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
-
+
*]:w-full')}> {mounted && ( diff --git a/packages/swingset/src/lib/utils.ts b/packages/swingset/src/lib/utils.ts index 9ad0df4269e..737be1812af 100644 --- a/packages/swingset/src/lib/utils.ts +++ b/packages/swingset/src/lib/utils.ts @@ -1,6 +1,12 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import type { StoryModule } from './types'; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function fillsFrame(storyModule: StoryModule): boolean { + return storyModule.meta.navigation?.category === 'Sections'; +} diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index 46d3d7af518..32346b9ceb9 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -49,7 +49,7 @@ import { Avatar } from '@clerk/mosaic/components/avatar'; ### Pending -While an image is still resolving, the fallback carries `data-pending` and pulses. This source holds its response for a minute, so the state is the component's own rather than a pinned attribute — inspect the `.cl-avatar-fallback` element to watch the attribute come and go. +While an image is still resolving, the fallback carries `data-pending` and shimmers. This source holds its response for a minute, so the state is the component's own rather than a pinned attribute — inspect the `.cl-avatar-fallback` element to watch the attribute come and go.