From da73504fed17c9aab95e5a400df3d88027d422a3 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Mon, 21 Sep 2026 11:39:28 -0600 Subject: [PATCH 1/6] feat(mosaic): Add OrganizationProfile scaffold with Tabs and ActionBar Add an initial OrganizationProfileView built on the Profile shell, with General, Members, Security, Billing, and API Keys pages. General covers workspace detail editing and guarded leave/delete; Members is a first pass at a responsive table with sorting, selection, bulk actions, and empty and no-results states across its members, invitations, and requests tabs. Add styled Tabs and ActionBar components, and rework Pagination into the compact table-footer control the members table uses. Swingset gains stories and docs for every new surface. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/organization-profile-scaffold.md | 9 + .../action-bar/action-bar.styles.ts | 79 +++ .../components/action-bar/action-bar.test.tsx | 73 ++ .../src/components/action-bar/action-bar.tsx | 148 ++++ .../mosaic/src/components/action-bar/index.ts | 7 + .../empty-state/empty-state.styles.ts | 7 +- .../src/components/pagination/page-items.ts | 33 - .../pagination/pagination.styles.ts | 80 ++- .../components/pagination/pagination.test.tsx | 270 ++------ .../src/components/pagination/pagination.tsx | 255 ++++--- .../src/components/profile/profile.styles.ts | 19 +- .../mosaic/src/components/profile/profile.tsx | 9 +- .../src/components/table/table.styles.ts | 11 + .../mosaic/src/components/table/table.tsx | 20 +- packages/mosaic/src/components/tabs/index.ts | 9 + .../mosaic/src/components/tabs/tabs.styles.ts | 50 ++ .../mosaic/src/components/tabs/tabs.test.tsx | 33 + packages/mosaic/src/components/tabs/tabs.tsx | 80 +++ ...zation-profile-general-panel.view.test.tsx | 206 ++++++ ...zation-profile-members-panel.view.test.tsx | 334 +++++++++ ...anization-profile-api-keys-panel.styles.ts | 12 + ...ganization-profile-api-keys-panel.view.tsx | 16 + ...ganization-profile-billing-panel.styles.ts | 12 + ...rganization-profile-billing-panel.view.tsx | 16 + ...organization-profile-bulk-actions.view.tsx | 112 +++ ...ganization-profile-general-panel.styles.ts | 23 + ...rganization-profile-general-panel.view.tsx | 646 ++++++++++++++++++ ...anization-profile-invitations-tab.view.tsx | 331 +++++++++ ...rganization-profile-members-pager.view.tsx | 43 ++ ...ganization-profile-members-panel.styles.ts | 141 ++++ ...rganization-profile-members-panel.types.ts | 71 ++ ...rganization-profile-members-panel.view.tsx | 522 ++++++++++++++ ...organization-profile-requests-tab.view.tsx | 317 +++++++++ ...organization-profile-role-control.view.tsx | 60 ++ ...anization-profile-security-panel.styles.ts | 12 + ...ganization-profile-security-panel.view.tsx | 16 + .../organization-profile.layout.ts | 56 ++ .../organization-profile.messages.ts | 67 ++ .../organization-profile.types.ts | 19 + .../organization-profile.view.tsx | 133 ++++ packages/mosaic/src/icons/glyphs/user-2.tsx | 18 + packages/mosaic/src/icons/glyphs/user.tsx | 18 + packages/mosaic/src/icons/registry.tsx | 4 + packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/styles/index.ts | 17 + .../swingset/src/components/DocsViewer.tsx | 15 +- packages/swingset/src/lib/registry.ts | 94 ++- packages/swingset/src/stories/action-bar.mdx | 49 ++ .../src/stories/action-bar.stories.tsx | 80 +++ .../fixtures/organization-profile.test.tsx | 32 + .../stories/fixtures/organization-profile.ts | 202 ++++++ .../organization-profile-api-keys-panel.mdx | 10 + ...ization-profile-api-keys-panel.stories.tsx | 19 + .../organization-profile-billing-panel.mdx | 10 + ...nization-profile-billing-panel.stories.tsx | 19 + .../organization-profile-general-panel.mdx | 10 + ...nization-profile-general-panel.stories.tsx | 23 + .../organization-profile-invitations-tab.mdx | 18 + ...zation-profile-invitations-tab.stories.tsx | 33 + .../organization-profile-members-panel.mdx | 45 ++ ...nization-profile-members-panel.stories.tsx | 53 ++ .../organization-profile-requests-tab.mdx | 18 + ...anization-profile-requests-tab.stories.tsx | 40 ++ .../organization-profile-security-panel.mdx | 10 + ...ization-profile-security-panel.stories.tsx | 19 + .../src/stories/organization-profile.mdx | 41 ++ .../stories/organization-profile.stories.tsx | 56 ++ packages/swingset/src/stories/pagination.mdx | 23 +- .../src/stories/pagination.stories.tsx | 6 +- packages/swingset/src/stories/tabs.mdx | 34 +- .../swingset/src/stories/tabs.stories.tsx | 14 +- 71 files changed, 4908 insertions(+), 481 deletions(-) create mode 100644 .changeset/organization-profile-scaffold.md create mode 100644 packages/mosaic/src/components/action-bar/action-bar.styles.ts create mode 100644 packages/mosaic/src/components/action-bar/action-bar.test.tsx create mode 100644 packages/mosaic/src/components/action-bar/action-bar.tsx create mode 100644 packages/mosaic/src/components/action-bar/index.ts delete mode 100644 packages/mosaic/src/components/pagination/page-items.ts create mode 100644 packages/mosaic/src/components/tabs/index.ts create mode 100644 packages/mosaic/src/components/tabs/tabs.styles.ts create mode 100644 packages/mosaic/src/components/tabs/tabs.test.tsx create mode 100644 packages/mosaic/src/components/tabs/tabs.tsx 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/__tests__/organization-profile-members-panel.view.test.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-bulk-actions.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-invitations-tab.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-pager.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel.types.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-role-control.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-security-panel.styles.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-security-panel.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/icons/glyphs/user-2.tsx create mode 100644 packages/mosaic/src/icons/glyphs/user.tsx create mode 100644 packages/swingset/src/stories/action-bar.mdx create mode 100644 packages/swingset/src/stories/action-bar.stories.tsx create mode 100644 packages/swingset/src/stories/fixtures/organization-profile.test.tsx create mode 100644 packages/swingset/src/stories/fixtures/organization-profile.ts create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-billing-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-billing-panel.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-invitations-tab.mdx create mode 100644 packages/swingset/src/stories/organization-profile-invitations-tab.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-members-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-members-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-requests-tab.mdx create mode 100644 packages/swingset/src/stories/organization-profile-requests-tab.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-security-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-security-panel.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-scaffold.md b/.changeset/organization-profile-scaffold.md new file mode 100644 index 00000000000..eb372a75819 --- /dev/null +++ b/.changeset/organization-profile-scaffold.md @@ -0,0 +1,9 @@ +--- +'@clerk/mosaic': patch +--- + +Add an initial `OrganizationProfileView`: an organization profile surface built on the `Profile` shell, with a sidebar of General, Members, Security, Billing, and API Keys pages. The General page provides workspace detail editing and guarded leave and delete actions. The Members page is a first pass at a responsive members table with avatars, sortable columns, row selection, bulk actions, and empty and filtered-no-results states for members, invitations, and requests. New `organizationProfile` localization keys ship with it. + +Also add styled `Tabs` and `ActionBar` components. Tabs provide an animated selection indicator, and ActionBar provides the floating bulk-action layout used by organization lists. + +`Pagination` is reworked into the compact table-footer control used by the members table: it now shows the visible item range, the results-per-page control, first/previous/next/last controls, and the current page over the total, instead of a row of page-number buttons. `hasFirstLast` defaults to `true`, the default `pageSizeOptions` are now `[10, 15, 20, 100]`, and the `siblingCount` prop is removed because there are no longer page numbers to window. New `rangeLabel` and `pageSizeLabelCompact` props make the remaining visible strings translatable. diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts new file mode 100644 index 00000000000..22f12b80d74 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -0,0 +1,79 @@ +import * as stylex from '@stylexjs/stylex'; + +import { + colorVars, + durationVars, + easingVars, + fontWeightVars, + radiusVars, + shadowVars, + space, + typeScaleVars, +} from '../../tokens.stylex'; + +const reduceMotion = '@media (prefers-reduced-motion: reduce)'; + +export const styles = stylex.create({ + positioner: { + display: 'flex', + justifyContent: 'center', + pointerEvents: 'none', + zIndex: 1, + bottom: space['2'], + }, + // Anchored to the nearest positioned ancestor — for a bounded container. + positionerAbsolute: { + insetInline: 0, + position: 'absolute', + }, + // Pinned to the foot of the nearest scroll container, so it stays in view while its surface + // scrolls. `margin-block-start: auto` drops it to the bottom when the column has room to spare. + positionerSticky: { + marginBlockStart: 'auto', + position: 'sticky', + }, + bar: { + borderRadius: radiusVars['--cl-radius-lg'], + gap: space['1'], + paddingBlock: space['1'], + paddingInline: space['1'], + alignItems: 'center', + backgroundColor: colorVars['--cl-color-background'], + boxShadow: shadowVars['--cl-shadow-lg'], + display: 'flex', + opacity: { + default: 1, + ':is([data-open="false"])': 0, + }, + pointerEvents: 'auto', + transform: { + default: 'translateY(0)', + ':is([data-open="false"])': 'translateY(0.25rem)', + }, + transitionDuration: { + default: `${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, + [reduceMotion]: `${durationVars['--cl-duration-instant']}, ${durationVars['--cl-duration-instant']}`, + ':is([data-open="false"])': `${durationVars['--cl-duration-instant']}, ${durationVars['--cl-duration-instant']}`, + }, + transitionProperty: 'opacity, transform', + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-enter']}`, + }, + maxWidth: 'calc(100% - 2 * var(--cl-spacing))', + }, + count: { + paddingInline: space['2'], + color: colorVars['--cl-color-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + whiteSpace: 'nowrap', + }, + separator: { + marginBlock: space['1'], + marginInline: space['0.5'], + alignSelf: 'stretch', + backgroundColor: colorVars['--cl-color-border'], + width: '1px', + }, +}); diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx new file mode 100644 index 00000000000..3eead27ce7b --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -0,0 +1,73 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ActionBar } from './action-bar'; + +const testStyles = stylex.create({ + positioner: { + bottom: '20px', + }, +}); + +describe('Mosaic ActionBar', () => { + it('renders a toolbar with its count and reflects data-open', () => { + render( + + 3 selected + , + ); + const bar = screen.getByRole('toolbar', { name: 'Bulk actions' }); + expect(bar).toHaveClass('cl-action-bar'); + expect(bar).toHaveAttribute('data-open', 'true'); + const count = screen.getByText('3 selected'); + expect(count).toHaveClass('cl-action-bar-count'); + expect(count.querySelector('.cl-icon')).not.toBeInTheDocument(); + }); + + it('marks the bar inert while closed', () => { + render( + + 0 selected + , + ); + const bar = screen.getByRole('toolbar', { name: 'Bulk actions', hidden: true }); + expect(bar).toHaveAttribute('data-open', 'false'); + expect(bar.inert).toBe(true); + }); + + it('styles the positioner independently from the bar', () => { + render( + , + ); + expect(screen.getByRole('toolbar', { name: 'Bulk actions' }).parentElement).toHaveClass( + ...(stylex.props(testStyles.positioner).className as string).split(' '), + ); + }); + + it('dismisses with a labelled button', async () => { + const onDismiss = vi.fn(); + render( + + + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Clear selection' })); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..ade2ac87972 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -0,0 +1,148 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps, MosaicElementProps, XStyle } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; +import { Icon } from '../icon'; +import { styles } from './action-bar.styles'; + +export interface ActionBarRootProps extends MosaicComponentProps<'div'> { + /** Whether the bar is shown. Toggling it animates the bar in and out. */ + open: boolean; + /** + * Pins the bar to the foot of the nearest scroll container instead of a positioned ancestor, so + * it stays in view while a long surface scrolls (a `Profile` page, a modal). Place `ActionBar.Root` + * as the last child of that scrolling column. Off, the bar is absolute within the nearest + * positioned ancestor — give that ancestor `position: relative`. + * + * @default false + */ + sticky?: boolean; + positionerXstyle?: XStyle; +} + +/** + * A floating bar of actions for a selection. It sits at the foot of its container — the nearest + * positioned ancestor, or the nearest scroll container with `sticky` — and animates in and out with + * `open`. While `open` is false it is inert and click-through, so the surface underneath stays + * usable. Compose a count, a separator, the actions, and a dismiss: + * + * @example + * 0}> + * {count} selected + * + * … + * + * + * + * + */ +const Root = React.forwardRef(function ActionBarRoot( + { open, sticky = false, render, xstyle, positionerXstyle, children, ...rest }, + ref, +) { + const barRef = React.useRef(null); + React.useEffect(() => { + if (barRef.current) { + barRef.current.inert = !open; + } + }, [open]); + + const bar = useRender({ + defaultTagName: 'div', + render, + ref: barRef, + props: { + ...mergeStyleProps(themeProps('action-bar', { open }), stylex.props(reset.base, styles.bar, xstyle), rest), + 'data-open': open, + role: 'toolbar', + children, + }, + }); + + return ( +
+ {bar} +
+ ); +}); + +export type ActionBarCountProps = MosaicComponentProps<'div'>; + +/** The leading count of what is selected, e.g. `3 selected`. */ +const Count = React.forwardRef(function ActionBarCount( + { render, xstyle, children, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('action-bar-count'), stylex.props(reset.base, styles.count, xstyle), rest), + children, + }, + }); +}); + +export type ActionBarSeparatorProps = MosaicComponentProps<'div'>; + +/** A vertical divider between groups of the bar. */ +const Separator = React.forwardRef(function ActionBarSeparator( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('action-bar-separator'), stylex.props(reset.base, styles.separator), rest), + 'aria-hidden': true, + }, + }); +}); + +export type ActionBarDismissProps = MosaicElementProps<'button'>; + +/** Dismisses the bar. A ghost icon button; defaults its label to "Clear selection". */ +const Dismiss = React.forwardRef(function ActionBarDismiss( + { xstyle, 'aria-label': ariaLabel, ...rest }, + ref, +) { + return ( + + ); +}); + +/** + * A floating bar of actions for a current selection, composed through `ActionBar.Root`, + * `ActionBar.Count`, `ActionBar.Separator`, and `ActionBar.Dismiss`. The actions themselves + * (a `Menu`, a `Button`) are whatever children you place between them. + */ +export const ActionBar = { Root, Count, Separator, Dismiss }; diff --git a/packages/mosaic/src/components/action-bar/index.ts b/packages/mosaic/src/components/action-bar/index.ts new file mode 100644 index 00000000000..fb244e5be89 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/index.ts @@ -0,0 +1,7 @@ +export { ActionBar } from './action-bar'; +export type { + ActionBarCountProps, + ActionBarDismissProps, + ActionBarRootProps, + ActionBarSeparatorProps, +} from './action-bar'; diff --git a/packages/mosaic/src/components/empty-state/empty-state.styles.ts b/packages/mosaic/src/components/empty-state/empty-state.styles.ts index ea79a311dfb..9e169775105 100644 --- a/packages/mosaic/src/components/empty-state/empty-state.styles.ts +++ b/packages/mosaic/src/components/empty-state/empty-state.styles.ts @@ -4,7 +4,7 @@ import { colorVars, fontWeightVars, space, typeScaleVars } from '../../tokens.st export const styles = stylex.create({ root: { - gap: space['1'], + gap: 0, paddingBlock: space['10'], paddingInline: space['6'], alignItems: 'center', @@ -16,7 +16,7 @@ export const styles = stylex.create({ }, icon: { display: 'inline-flex', - marginBlockEnd: space['1'], + marginBlockEnd: space['2'], }, label: { color: colorVars['--cl-color-foreground'], @@ -28,6 +28,7 @@ export const styles = stylex.create({ color: colorVars['--cl-color-foreground-secondary'], fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: space['1'], maxWidth: '32ch', }, actions: { @@ -36,6 +37,6 @@ export const styles = stylex.create({ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', - marginBlockStart: space['2'], + marginBlockStart: space['3'], }, }); diff --git a/packages/mosaic/src/components/pagination/page-items.ts b/packages/mosaic/src/components/pagination/page-items.ts deleted file mode 100644 index b1d94647ea7..00000000000 --- a/packages/mosaic/src/components/pagination/page-items.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type PageItem = number | 'start-ellipsis' | 'end-ellipsis'; - -function range(start: number, end: number): number[] { - const pages: number[] = []; - for (let page = start; page <= end; page++) { - pages.push(page); - } - return pages; -} - -export function getPageItems(page: number, pageCount: number, siblingCount: number): PageItem[] { - if (pageCount <= 1) { - return [1]; - } - - const windowStart = Math.max(Math.min(page - siblingCount, pageCount - 2 * siblingCount - 2), 3); - const windowEnd = Math.min(Math.max(page + siblingCount, 2 * siblingCount + 3), pageCount - 2); - - const items: PageItem[] = [1]; - if (windowStart > 3) { - items.push('start-ellipsis'); - } else if (pageCount > 3) { - items.push(2); - } - items.push(...range(windowStart, windowEnd)); - if (windowEnd < pageCount - 2) { - items.push('end-ellipsis'); - } else if (pageCount > 2) { - items.push(pageCount - 1); - } - items.push(pageCount); - return items; -} diff --git a/packages/mosaic/src/components/pagination/pagination.styles.ts b/packages/mosaic/src/components/pagination/pagination.styles.ts index 19e7e7bf259..7c92d45de40 100644 --- a/packages/mosaic/src/components/pagination/pagination.styles.ts +++ b/packages/mosaic/src/components/pagination/pagination.styles.ts @@ -2,47 +2,75 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, space, typeScaleVars } from '../../tokens.stylex'; +const compact = '@container (width < 48rem)' as const; + export const styles = stylex.create({ root: { gap: space['4'], - alignItems: 'center', + alignItems: { [compact]: 'stretch', default: 'center' }, display: 'flex', + flexDirection: { [compact]: 'column', default: 'row' }, flexWrap: 'wrap', justifyContent: 'space-between', }, - controls: { - gap: space['1'], + summary: { + gap: space['3'], alignItems: 'center', display: 'flex', + flexWrap: { [compact]: 'nowrap', default: 'wrap' }, + justifyContent: { [compact]: 'space-between', default: 'flex-start' }, + width: { [compact]: '100%', default: 'auto' }, }, - hitTarget: { - position: 'relative', - '::after': { - insetBlock: 0, - insetInline: `calc(${space['1']} / -2)`, - content: '""', - position: 'absolute', - }, - }, - page: { - color: { - default: colorVars['--cl-color-foreground-secondary'], - ':where([aria-current="page"])': colorVars['--cl-color-foreground'], - }, - }, - ellipsis: { + pageSize: { + gap: space['3'], alignItems: 'center', - color: colorVars['--cl-color-foreground-secondary'], - display: 'inline-flex', - justifyContent: 'center', - height: space['7'], - width: space['7'], + display: 'flex', }, - pageSize: { - gap: space['2'], + controls: { + gap: space['1'], + alignItems: 'center', + borderBlockStartColor: { [compact]: colorVars['--cl-color-border'], default: 'transparent' }, + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { [compact]: '1px', default: 0 }, + display: { [compact]: 'grid', default: 'flex' }, + gridTemplateColumns: { [compact]: '1fr auto 1fr', default: 'none' }, + paddingBlockStart: { [compact]: space['4'], default: 0 }, + width: { [compact]: '100%', default: 'auto' }, + }, + controlGroup: { + gap: space['1'], alignItems: 'center', display: 'flex', }, + controlGroupEnd: { + justifySelf: 'end', + }, + text: { + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + whiteSpace: 'nowrap', + }, + divider: { + alignSelf: 'center', + backgroundColor: colorVars['--cl-color-border'], + display: { [compact]: 'none', default: 'block' }, + height: '1.25rem', + width: '1px', + }, + pageSizeLabel: { + display: { [compact]: 'none', default: 'inline' }, + }, + pageSizeLabelCompact: { + display: { [compact]: 'inline', default: 'none' }, + }, + pageLabel: { + paddingInline: space['1'], + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + whiteSpace: 'nowrap', + }, pageSizeOption: { paddingBlock: 0, paddingInline: space['2'], diff --git a/packages/mosaic/src/components/pagination/pagination.test.tsx b/packages/mosaic/src/components/pagination/pagination.test.tsx index e693b0d07e3..a3e04e07ca1 100644 --- a/packages/mosaic/src/components/pagination/pagination.test.tsx +++ b/packages/mosaic/src/components/pagination/pagination.test.tsx @@ -5,7 +5,6 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { rtl } from '../../utils/rtl.styles'; -import { getPageItems } from './page-items'; import { Pagination } from './pagination'; import { styles } from './pagination.styles'; @@ -21,45 +20,19 @@ function controls(): ReturnType { return within(element); } -describe('getPageItems', () => { - it('shows every page when they all fit', () => { - expect(getPageItems(1, 2, 1)).toEqual([1, 2]); - expect(getPageItems(2, 3, 1)).toEqual([1, 2, 3]); - expect(getPageItems(1, 5, 1)).toEqual([1, 2, 3, 4, 5]); - expect(getPageItems(1, 7, 1)).toEqual([1, 2, 3, 4, 5, 6, 7]); - }); - - it('keeps the sequence the same length as the page moves', () => { - expect(getPageItems(1, 10, 1)).toEqual([1, 2, 3, 4, 5, 'end-ellipsis', 10]); - expect(getPageItems(10, 10, 1)).toEqual([1, 'start-ellipsis', 6, 7, 8, 9, 10]); - }); - - it('keeps the current page, its siblings, and both ends', () => { - expect(getPageItems(5, 10, 1)).toEqual([1, 'start-ellipsis', 4, 5, 6, 'end-ellipsis', 10]); - expect(getPageItems(5, 10, 2)).toEqual([1, 2, 3, 4, 5, 6, 7, 'end-ellipsis', 10]); - }); - - it('shows the page instead of an ellipsis that would hide only one', () => { - expect(getPageItems(4, 10, 1)).toEqual([1, 2, 3, 4, 5, 'end-ellipsis', 10]); - expect(getPageItems(7, 10, 1)).toEqual([1, 'start-ellipsis', 6, 7, 8, 9, 10]); - }); - - it('renders a single page', () => { - expect(getPageItems(1, 1, 1)).toEqual([1]); - }); -}); - describe('Mosaic Pagination', () => { - it('renders a labelled nav with the styling contract', () => { + it('renders the item range, page count, and styling contract', () => { render( , ); const nav = screen.getByRole('navigation', { name: 'Pagination' }); expect(nav).toHaveClass('cl-pagination'); + expect(screen.getByText('21–30 of 44')).toHaveClass('cl-pagination-range'); + expect(screen.getByText('3/5')).toHaveClass('cl-pagination-page-label'); }); it('uses the label prop as the accessible name', () => { @@ -74,65 +47,27 @@ describe('Mosaic Pagination', () => { expect(screen.getByRole('navigation', { name: 'Invoices' })).toBeInTheDocument(); }); - it('derives the page count from totalItems and pageSize', () => { - render( - , - ); - const pages = controls() - .getAllByRole('button') - .map(button => button.textContent) - .filter(Boolean); - expect(pages).toEqual(['1', '2', '3', '4', '5', '10']); - }); - - it('marks the current page', () => { - render( - , - ); - expect(screen.getByRole('button', { name: '3' })).toHaveAttribute('aria-current', 'page'); - expect(screen.getByRole('button', { name: '2' })).not.toHaveAttribute('aria-current'); - }); - - it('calls onChange with the clicked page', async () => { - const onChange = vi.fn(); - render( - , - ); - await userEvent.click(screen.getByRole('button', { name: '2' })); - expect(onChange).toHaveBeenCalledWith(2); - }); - - it('moves by step from the previous and next buttons', async () => { + it('moves with the first, previous, next, and last controls', async () => { const onChange = vi.fn(); render( , ); + await userEvent.click(screen.getByRole('button', { name: 'First page' })); + expect(onChange).toHaveBeenLastCalledWith(1); await userEvent.click(screen.getByRole('button', { name: 'Previous page' })); - expect(onChange).toHaveBeenLastCalledWith(3); + expect(onChange).toHaveBeenLastCalledWith(4); await userEvent.click(screen.getByRole('button', { name: 'Next page' })); - expect(onChange).toHaveBeenLastCalledWith(7); + expect(onChange).toHaveBeenLastCalledWith(6); + await userEvent.click(screen.getByRole('button', { name: 'Last page' })); + expect(onChange).toHaveBeenLastCalledWith(10); }); - it('clamps a step that would overshoot the ends', async () => { + it('moves by step and clamps at the ends', async () => { const onChange = vi.fn(); render( { onChange={onChange} />, ); + await userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + expect(onChange).toHaveBeenLastCalledWith(4); await userEvent.click(screen.getByRole('button', { name: 'Next page' })); expect(onChange).toHaveBeenLastCalledWith(10); }); - it('disables previous on the first page and next on the last', () => { + it('disables backward controls on the first page and forward controls on the last', () => { const { rerender } = render( { pageSize={10} />, ); + expect(screen.getByRole('button', { name: 'First page' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); @@ -167,34 +105,20 @@ describe('Mosaic Pagination', () => { ); expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Last page' })).toBeDisabled(); }); - it('renders first and last buttons only when asked', async () => { - const onChange = vi.fn(); - const { rerender } = render( + it('can omit the first and last controls', () => { + render( , ); expect(screen.queryByRole('button', { name: 'First page' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Last page' })).not.toBeInTheDocument(); - - rerender( - , - ); - await userEvent.click(screen.getByRole('button', { name: 'First page' })); - expect(onChange).toHaveBeenLastCalledWith(1); - await userEvent.click(screen.getByRole('button', { name: 'Last page' })); - expect(onChange).toHaveBeenLastCalledWith(10); }); it('disables every control when disabled', () => { @@ -203,7 +127,6 @@ describe('Mosaic Pagination', () => { page={5} totalItems={100} pageSize={10} - hasFirstLast disabled />, ); @@ -214,18 +137,7 @@ describe('Mosaic Pagination', () => { expect(screen.getByRole('combobox')).toBeDisabled(); }); - it('shows the page size in the results-per-page select', () => { - render( - , - ); - expect(screen.getByRole('combobox', { name: 'Results per page 25' })).toBeInTheDocument(); - }); - - it('calls onPageSizeChange with the chosen page size', async () => { + it('shows and changes the page size', async () => { const onPageSizeChange = vi.fn(); render( { onPageSizeChange={onPageSizeChange} />, ); - await userEvent.click(screen.getByRole('combobox')); - await userEvent.click(screen.getByRole('option', { name: '50' })); - expect(onPageSizeChange).toHaveBeenCalledWith(50); - }); - - it('uses the pageSizeLabel prop as the page size label', () => { - render( - , - ); - expect(screen.getByText('Rows per page')).toHaveClass('cl-text'); - expect(screen.getByRole('combobox', { name: 'Rows per page 10' })).toBeInTheDocument(); + const trigger = screen.getByRole('combobox', { name: /Results per page/ }); + expect(trigger).toHaveTextContent('10'); + await userEvent.click(trigger); + await userEvent.click(screen.getByRole('option', { name: '20' })); + expect(onPageSizeChange).toHaveBeenCalledWith(20); }); - it('uses the control label props as the control names', () => { + it('uses custom visible and accessible labels', () => { render( , ); + expect(screen.getByText('11 à 20 sur 100')).toHaveClass('cl-pagination-range'); + expect(screen.getByText('Rows per page')).toBeInTheDocument(); + expect(screen.getByText('Rows')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /Rows per page/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Forward' })).toBeInTheDocument(); @@ -278,7 +185,6 @@ describe('Mosaic Pagination', () => { page={2} totalItems={100} pageSize={10} - hasFirstLast />, ); const mirror = stylex.props(rtl.mirror).className ?? ''; @@ -287,120 +193,35 @@ describe('Mosaic Pagination', () => { } }); - it('extends every control hit target across the gap', () => { + it('uses small outlined navigation controls', () => { render( , ); - const hitTarget = stylex.props(styles.hitTarget).className ?? ''; - for (const name of ['First page', 'Previous page', '2', 'Next page', 'Last page']) { - expect(screen.getByRole('button', { name })).toHaveClass(hitTarget); + for (const button of controls().getAllByRole('button')) { + expect(button).toHaveAttribute('data-size', 'sm'); + expect(button).toHaveAttribute('data-variant', 'outline'); } }); - it('renders the page size label as secondary sm text', () => { - render( - , - ); - const label = screen.getByText('Results per page'); - expect(label).toHaveClass('cl-text', 'cl-pagination-label'); - expect(label).toHaveAttribute('data-size', 'sm'); - expect(label).toHaveAttribute('data-color', 'foreground-secondary'); - }); - - it('sizes the page size options to match the trigger', async () => { - render( - , - ); - await userEvent.click(screen.getByRole('combobox')); - expect(screen.getByRole('option', { name: '10' })).toHaveClass(stylex.props(styles.pageSizeOption).className ?? ''); - }); - - it('offers the given page sizes plus the current one', async () => { + it('offers valid given page sizes plus the current one', async () => { render( , - ); - await userEvent.click(screen.getByRole('combobox')); - expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['10', '15', '20']); - }); - - it('drops invalid and duplicate page size options', async () => { - render( - , ); await userEvent.click(screen.getByRole('combobox')); - expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['10', '12', '25']); - }); - - it('treats a page size below one as one', async () => { - const onChange = vi.fn(); - render( - , - ); - const pages = controls() - .getAllByRole('button') - .map(button => button.textContent) - .filter(Boolean); - expect(pages).toEqual(['1', '2', '3', '4', '5']); - await userEvent.click(controls().getByRole('button', { name: '5' })); - expect(onChange).toHaveBeenCalledWith(5); - }); - - it('treats a step below one as one', async () => { - const onChange = vi.fn(); - render( - , - ); - await userEvent.click(screen.getByRole('button', { name: 'Next page' })); - expect(onChange).toHaveBeenCalledWith(3); - }); - - it('treats a negative sibling count as zero', () => { - render( - , - ); - expect(controls().getByRole('button', { name: '5' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual(['12', '15', '20']); + expect(screen.getByRole('option', { name: '15' })).toHaveClass(stylex.props(styles.pageSizeOption).className ?? ''); }); - it('renders one page when there are no items', () => { + it('renders an empty range as a single disabled page', () => { render( { pageSize={10} />, ); - expect(screen.getByRole('button', { name: '1' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByText('0–0 of 0')).toBeInTheDocument(); + expect(screen.getByText('1/1')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); }); diff --git a/packages/mosaic/src/components/pagination/pagination.tsx b/packages/mosaic/src/components/pagination/pagination.tsx index dfbe55e1c5e..f4b7a94a779 100644 --- a/packages/mosaic/src/components/pagination/pagination.tsx +++ b/packages/mosaic/src/components/pagination/pagination.tsx @@ -1,16 +1,14 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { fill } from '../../localization/messages'; import type { MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; import { rtl } from '../../utils/rtl.styles'; import { Button } from '../button'; -import { ButtonContext } from '../button/button.context'; import { Icon } from '../icon'; import { Select } from '../select'; -import { Text } from '../text'; -import { getPageItems } from './page-items'; import { styles } from './pagination.styles'; export interface PaginationProps extends Omit, 'onChange'> { @@ -22,10 +20,13 @@ export interface PaginationProps extends Omit, 'onChan onPageSizeChange?: (pageSize: number) => void; hasFirstLast?: boolean; step?: number; - siblingCount?: number; disabled?: boolean; label?: string; + /** The item range, with `{start}`, `{end}` and `{total}` filled in. */ + rangeLabel?: string; pageSizeLabel?: string; + /** Stands in for `pageSizeLabel` when the pagination is too narrow to carry it. */ + pageSizeLabelCompact?: string; firstPageLabel?: string; previousPageLabel?: string; nextPageLabel?: string; @@ -36,19 +37,15 @@ function atLeast(value: number, min: number): number { return Number.isFinite(value) && value > min ? Math.floor(value) : min; } -const defaultPageSizeOptions = [10, 25, 50, 100]; +const defaultPageSizeOptions = [10, 15, 20, 100]; /** - * Page navigation for a paged list. Renders a labelled `nav` with previous/next controls, the - * page numbers around the current one, and a results-per-page control. Pages are 1-based; + * Page navigation for a paged list. Renders the current item range, a results-per-page control, + * first/previous/next/last controls, and the current page over the total. Pages are 1-based; * `onChange` receives the page the user asked for. * * @example * - * - * @example - * // With jump-to-ends controls and a wider window of pages - * */ export const Pagination = React.forwardRef(function MosaicPagination( { @@ -58,12 +55,13 @@ export const Pagination = React.forwardRef(functio onChange, pageSizeOptions = defaultPageSizeOptions, onPageSizeChange, - hasFirstLast = false, + hasFirstLast = true, step = 1, - siblingCount = 1, disabled = false, label = 'Pagination', + rangeLabel = '{start}–{end} of {total}', pageSizeLabel = 'Results per page', + pageSizeLabelCompact = 'Show', firstPageLabel = 'First page', previousPageLabel = 'Previous page', nextPageLabel = 'Next page', @@ -75,10 +73,11 @@ export const Pagination = React.forwardRef(functio ) { const itemsPerPage = atLeast(pageSize, 1); const pageStep = atLeast(step, 1); - const siblings = atLeast(siblingCount, 0); - const pageCount = Math.max(1, Math.ceil(atLeast(totalItems, 0) / itemsPerPage)); + const itemCount = atLeast(totalItems, 0); + const pageCount = Math.max(1, Math.ceil(itemCount / itemsPerPage)); const current = Math.min(atLeast(page, 1), pageCount); - const labelId = React.useId(); + const start = itemCount === 0 ? 0 : (current - 1) * itemsPerPage + 1; + const end = Math.min(current * itemsPerPage, itemCount); const pageSizeItems = React.useMemo(() => { const sizes = new Set( [...pageSizeOptions, itemsPerPage].filter(size => Number.isFinite(size) && size >= 1).map(Math.floor), @@ -87,164 +86,140 @@ export const Pagination = React.forwardRef(functio }, [pageSizeOptions, itemsPerPage]); const isFirst = current <= 1; const isLast = current >= pageCount; - - const pageDefaults = React.useMemo( - () => - ({ - color: 'neutral', - variant: 'ghost', - size: 'sm', - disabled, - styles: styles.page, - }) as const, - [disabled], - ); - const goTo = (next: number) => onChange?.(Math.min(Math.max(next, 1), pageCount)); return ( ); diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index 2b8f0e2a71c..b06cb9c584f 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -263,6 +263,13 @@ export const styles = stylex.create({ flexShrink: 0, }, + navItemBadge: { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + marginInlineStart: 'auto', + }, + branding: { display: 'block', marginBlockStart: 'auto', @@ -299,14 +306,18 @@ export const styles = stylex.create({ }, contentViewport: { - paddingBlock: { - [compact]: space['6'], - default: `calc(${space['16']} - ${SCROLL_INSET})`, - }, paddingInline: { [compact]: space['6'], default: space['16'], }, + paddingBlockEnd: { + [compact]: space['6'], + default: `calc(${space['8']} - ${SCROLL_INSET})`, + }, + paddingBlockStart: { + [compact]: space['6'], + default: `calc(${space['16']} - ${SCROLL_INSET})`, + }, }, /** The pages' column: held to a reading width and centred, however wide the frame runs. */ diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 0f24bd20f28..bcd1c535831 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -318,12 +318,14 @@ export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { value: string; /** Leads the label. Any node, so a page of the consumer's own can bring its own mark. */ icon?: React.ReactNode; + /** Trailing content aligned to the end of the row, e.g. a count `Badge`. */ + badge?: React.ReactNode; disabled?: boolean; } /** A destination. Selecting it shows the `Profile.ContentPanel` sharing its `value`. */ const NavItem = React.forwardRef(function ProfileNavItem( - { value, icon, disabled, children, render, xstyle, onClick, ...rest }, + { value, icon, badge, disabled, children, render, xstyle, onClick, ...rest }, ref, ) { const { compact, closeNav } = useProfileContext('Profile.NavItem'); @@ -355,6 +357,11 @@ const NavItem = React.forwardRef(functio ) : null} {children} + {badge != null ? ( + + {badge} + + ) : null} ); }); diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index 6a87e53cd02..fda6b01cdc0 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -10,11 +10,22 @@ export const styles = stylex.create({ borderWidth: '1px', overflow: 'hidden', backgroundColor: colorVars['--cl-color-background'], + containerType: 'inline-size', + maxWidth: '100%', + minWidth: 0, width: '100%', }, viewport: { overscrollBehaviorX: 'none', }, + // The viewport without the scroll-area's edge fade: it still scrolls horizontally when the + // columns overflow, but shows no left/right gradient. For a table whose columns are meant to fit. + plainViewport: { + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + overscrollBehaviorX: 'contain', + }, table: { borderCollapse: 'collapse', borderSpacing: 0, diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index ffb4365f257..89f856df317 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -20,9 +20,21 @@ export type TableAlign = keyof typeof aligns; export type TableSort = 'ascending' | 'descending' | 'none'; -export type TableProps = MosaicElementProps<'table'>; - -const Root = React.forwardRef(function MosaicTable({ xstyle, ...rest }, ref) { +export type TableProps = MosaicElementProps<'table'> & { + /** + * Whether a left/right gradient fades the edges while the table scrolls horizontally. On by + * default; the table still scrolls when off, it just shows no fade. Turn it off for a table + * whose columns are meant to fit, so an edge gradient never shows when there is nothing to scroll. + * + * @default true + */ + fade?: boolean; +}; + +const Root = React.forwardRef(function MosaicTable( + { xstyle, fade = true, ...rest }, + ref, +) { return (
(function MosaicTable tabIndex={0} {...mergeStyleProps( themeProps('table-viewport'), - stylex.props(reset.base, scrollAreaViewport('auto', 'inline'), styles.viewport), + stylex.props(reset.base, fade ? scrollAreaViewport('auto', 'inline') : styles.plainViewport, styles.viewport), )} > { + it('renders styled parts and changes the visible panel', async () => { + render( + + + Members + Invitations + + + Member list + Invitation list + , + ); + + expect(screen.getByRole('tablist')).toHaveClass('cl-tabs-list'); + expect(screen.getByRole('tab', { name: 'Members' })).toHaveClass('cl-tabs-tab'); + expect(screen.getByTestId('indicator')).toHaveClass('cl-tabs-indicator'); + expect(screen.getByText('Member list')).toBeVisible(); + expect(screen.getByText('Invitation list')).not.toBeVisible(); + + await userEvent.click(screen.getByRole('tab', { name: 'Invitations' })); + + expect(screen.getByRole('tab', { name: 'Invitations' })).toHaveAttribute('data-selected'); + expect(screen.getByText('Invitation list')).toBeVisible(); + }); +}); diff --git a/packages/mosaic/src/components/tabs/tabs.tsx b/packages/mosaic/src/components/tabs/tabs.tsx new file mode 100644 index 00000000000..e54e8479812 --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.tsx @@ -0,0 +1,80 @@ +import type { + TabsIndicatorProps as HeadlessTabsIndicatorProps, + TabsListProps as HeadlessTabsListProps, + TabsPanelProps as HeadlessTabsPanelProps, + TabsProps as HeadlessTabsRootProps, + TabsTabProps as HeadlessTabsTabProps, + TabsTriggerProps as HeadlessTabsTriggerProps, +} from '@clerk/headless/tabs'; +import { Tabs as Primitive } from '@clerk/headless/tabs'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicStyleProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { styles } from './tabs.styles'; + +type StyledProps = Omit & MosaicStyleProps; + +export type TabsRootProps = HeadlessTabsRootProps; +export type TabsListProps = StyledProps; +export type TabsTabProps = StyledProps; +export type TabsTriggerProps = StyledProps; +export type TabsPanelProps = StyledProps; +export type TabsIndicatorProps = StyledProps; + +function List({ xstyle, ...rest }: TabsListProps) { + return ( + + ); +} + +const Tab = React.forwardRef(function MosaicTabsTab({ xstyle, ...rest }, ref) { + return ( + + ); +}); + +const Trigger = React.forwardRef(function MosaicTabsTrigger( + { xstyle, ...rest }, + ref, +) { + return ( + + ); +}); + +const Panel = React.forwardRef(function MosaicTabsPanel({ xstyle, ...rest }, ref) { + return ( + + ); +}); + +function Indicator({ xstyle, ...rest }: TabsIndicatorProps) { + return ( + + ); +} + +export const Tabs = { + Root: Primitive.Root, + List, + Tab, + Trigger, + Panel, + Indicator, +}; diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx new file mode 100644 index 00000000000..ca9b609b3ea --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx @@ -0,0 +1,206 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type { OrganizationProfileGeneralPanelViewProps } from '../organization-profile-general-panel.view'; +import { OrganizationProfileGeneralPanelView } from '../organization-profile-general-panel.view'; + +function renderPanel(overrides: Partial = {}) { + const props: OrganizationProfileGeneralPanelViewProps = { + name: 'Clerk', + slug: 'clerk-workspace', + membersCount: 20, + onLogoChange: vi.fn(() => Promise.resolve()), + onRemoveLogo: vi.fn(() => Promise.resolve()), + onSubmitName: vi.fn(() => Promise.resolve()), + onSubmitSlug: vi.fn(() => Promise.resolve()), + onCopySlug: vi.fn(() => Promise.resolve()), + onLeave: vi.fn(() => Promise.resolve()), + onDelete: vi.fn(() => Promise.resolve()), + ...overrides, + }; + + return { + props, + ...render( + + + , + ), + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('OrganizationProfileGeneralPanelView', () => { + it('renders workspace details and the danger zone', () => { + renderPanel(); + + expect(screen.getByRole('heading', { name: 'General' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Workspace details' })).toBeInTheDocument(); + expect(screen.getByText('Clerk')).toBeInTheDocument(); + expect(screen.getByText('clerk-workspace')).toBeInTheDocument(); + expect( + within(screen.getByText('clerk-workspace').closest('.cl-section-content') as HTMLElement).getByRole('button', { + name: 'Copy', + }), + ).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Leave workspace' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete workspace' })).toBeInTheDocument(); + expect(document.querySelector('.cl-avatar')).toHaveAttribute('data-shape', 'square'); + }); + + it('uses callback presence to expose edits and destructive actions', () => { + renderPanel({ + slug: undefined, + onLogoChange: undefined, + onRemoveLogo: undefined, + onSubmitName: undefined, + onSubmitSlug: undefined, + onCopySlug: undefined, + onLeave: undefined, + onDelete: undefined, + }); + + expect(screen.queryByText('Workspace slug')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Danger zone' })).not.toBeInTheDocument(); + }); + + it('shows an enabled empty slug without a copy action', () => { + renderPanel({ slug: '' }); + + expect(screen.getByText('Not set')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2); + }); + + it('uploads supported logos and rejects unsupported files', async () => { + const onLogoChange = vi.fn(() => Promise.resolve()); + const onLogoReject = vi.fn(); + const user = userEvent.setup(); + renderPanel({ onLogoChange, onLogoReject }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, new File(['logo'], 'logo.png', { type: 'image/png' })); + await waitFor(() => expect(onLogoChange).toHaveBeenCalledOnce()); + + fireEvent.change(input, { target: { files: [new File(['plain'], 'logo.txt', { type: 'text/plain' })] } }); + expect(onLogoReject).toHaveBeenCalledOnce(); + expect(screen.getByRole('alert')).toHaveTextContent('File type not supported'); + }); + + it('offers change and remove after a logo has been uploaded', async () => { + const onRemoveLogo = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + renderPanel({ hasImage: true, imageUrl: 'https://example.com/logo.png', onRemoveLogo }); + + await user.click(screen.getByRole('button', { name: 'Manage logo' })); + expect(screen.getByRole('menuitem', { name: 'Change logo' })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Remove logo' })); + await waitFor(() => expect(onRemoveLogo).toHaveBeenCalledOnce()); + }); + + it('edits the workspace name and closes after a successful save', async () => { + const onSubmitName = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + renderPanel({ onSubmitName }); + + await user.click(screen.getAllByRole('button', { name: 'Edit' })[0]); + const dialog = screen.getByRole('dialog', { name: 'Edit workspace name' }); + const input = within(dialog).getByRole('textbox', { name: 'Name' }); + const save = within(dialog).getByRole('button', { name: 'Save changes' }); + + expect(input).toHaveValue('Clerk'); + expect(save).toHaveAttribute('aria-disabled', 'true'); + await user.clear(input); + await user.type(input, 'Clerk Inc.'); + await user.click(save); + + expect(onSubmitName).toHaveBeenCalledWith('Clerk Inc.'); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit workspace name' })).not.toBeInTheDocument()); + }); + + it('keeps the slug dialog open and shows a rejected API message under the field', async () => { + const onSubmitSlug = vi.fn(() => Promise.reject(new Error('That workspace slug is already in use.'))); + const user = userEvent.setup(); + renderPanel({ onSubmitSlug }); + + await user.click(screen.getAllByRole('button', { name: 'Edit' })[1]); + const dialog = screen.getByRole('dialog', { name: 'Edit workspace slug' }); + const input = within(dialog).getByRole('textbox', { name: 'Workspace slug' }); + + expect(within(dialog).getByText(/Changing it may break existing links/)).toBeInTheDocument(); + await user.clear(input); + await user.type(input, 'new-slug'); + await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + expect(await within(dialog).findByText('That workspace slug is already in use.')).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('copies the slug and clears success feedback after two seconds', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const onCopySlug = vi.fn(() => Promise.resolve()); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel({ onCopySlug }); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(onCopySlug).toHaveBeenCalledWith('clerk-workspace'); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Copied'); + + await act(() => vi.advanceTimersByTime(2000)); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('shows copy failure feedback and keeps copy available', async () => { + const onCopySlug = vi.fn(() => Promise.reject(new Error('Clipboard blocked'))); + const user = userEvent.setup(); + renderPanel({ onCopySlug }); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Couldn’t copy'); + expect(screen.getByRole('button', { name: 'Copy' })).not.toBeDisabled(); + }); + + it('leaves only after an exact workspace-name confirmation', async () => { + const onLeave = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + renderPanel({ onLeave }); + + await user.click(screen.getByRole('button', { name: 'Leave workspace' })); + const dialog = screen.getByRole('dialog', { name: 'Leave workspace' }); + const confirm = within(dialog).getByRole('button', { name: 'Leave workspace' }); + + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + await user.type(within(dialog).getByRole('textbox'), 'clerk'); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + await user.clear(within(dialog).getByRole('textbox')); + await user.type(within(dialog).getByRole('textbox'), 'Clerk{Enter}'); + + expect(onLeave).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Leave workspace' })).not.toBeInTheDocument()); + }); + + it('includes the member count and explains a failed delete', async () => { + const onDelete = vi.fn(() => Promise.reject(new Error('Delete is disabled.'))); + const user = userEvent.setup(); + renderPanel({ membersCount: 20, onDelete }); + + await user.click(screen.getByRole('button', { name: 'Delete workspace' })); + const dialog = screen.getByRole('dialog', { name: 'Delete workspace' }); + expect(within(dialog).getByText(/This removes 20 members/)).toBeInTheDocument(); + + await user.type(within(dialog).getByRole('textbox'), 'Clerk'); + await user.click(within(dialog).getByRole('button', { name: 'Delete workspace' })); + + expect(await within(dialog).findByText('Delete is disabled.')).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx new file mode 100644 index 00000000000..d0ea9dffb18 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx @@ -0,0 +1,334 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { colorVars } from '../../../tokens.stylex'; +import { styles } from '../organization-profile-members-panel.styles'; +import type { OrganizationProfileMembersPanelViewProps } from '../organization-profile-members-panel.view'; +import { OrganizationProfileMembersPanelView } from '../organization-profile-members-panel.view'; + +const roles = [ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, +]; + +const members = [ + { id: 'm1', name: 'Kyle Mac', email: 'kyle@clerk.dev', role: 'member', addedLabel: 'Jun 14, 2026', addedAt: 3 }, + { id: 'm2', name: 'Colin Sidoti', email: 'colin@clerk.dev', role: 'admin', addedLabel: 'May 25, 2026', addedAt: 2 }, + { id: 'm3', name: 'Steve Hayes', email: 'steve@clerk.dev', role: 'admin', addedLabel: 'May 1, 2026', addedAt: 1 }, +]; + +const invitations = [ + { id: 'i1', email: 'ksearle@clerk.dev', role: 'member', invitedLabel: 'Jun 14, 2026', invitedAt: 2 }, +]; + +const requests = [ + { + id: 'r1', + name: 'Jared Dunn', + email: 'jared@clerk.dev', + role: 'member', + requestedLabel: 'May 5, 2026', + requestedAt: 1, + }, +]; + +function renderPanel(overrides: Partial = {}) { + const props: OrganizationProfileMembersPanelViewProps = { + members, + invitations, + requests, + roles, + currentUserId: 'm3', + onChangeRole: vi.fn(), + onRemoveMembers: vi.fn(), + onChangeInvitationRole: vi.fn(), + onRevokeInvitation: vi.fn(), + onChangeRequestRole: vi.fn(), + onAcceptRequest: vi.fn(), + onDeclineRequest: vi.fn(), + onInvite: vi.fn(), + ...overrides, + }; + render( + + + , + ); + return props; +} + +describe('OrganizationProfileMembersPanelView', () => { + it('lists members and marks the current user, whose role is not editable', () => { + renderPanel(); + expect(screen.getByText('Kyle Mac')).toBeInTheDocument(); + expect(screen.getByText('(you)')).toBeInTheDocument(); + // Steve Hayes is the viewer, so his role renders as a disabled button, not an open control. + const steveRow = screen.getByText('Steve Hayes').closest('tr') as HTMLElement; + const currentUserRole = within(steveRow).getByRole('button', { name: 'Admin' }); + expect(currentUserRole).toBeDisabled(); + expect(currentUserRole).toHaveAttribute('data-size', 'md'); + expect(currentUserRole).toHaveAttribute('data-variant', 'ghost'); + // A non-viewer admin exposes the role control. + const colinRow = screen.getByText('Colin Sidoti').closest('tr') as HTMLElement; + const editableRole = within(colinRow).getByRole('button', { name: /Admin/ }); + expect(editableRole).toHaveAttribute('data-size', 'md'); + expect(editableRole).toHaveAttribute('data-variant', 'ghost'); + }); + + it('keeps the name column wide and truncates identity text across member tabs', async () => { + const user = userEvent.setup(); + const longMemberName = 'Alexandria Catherine Montgomery-Worthington'; + const longMemberEmail = 'alexandria.catherine.montgomery-worthington@clerk.example.com'; + const longInvitationEmail = 'pending.invitation.with.an.unusually.long.address@clerk.example.com'; + const longRequestName = 'Bartholomew Maximilian Requester-Sutherland'; + const longRequestEmail = 'bartholomew.maximilian.requester-sutherland@clerk.example.com'; + const nameColumnClass = stylex.props(styles.nameColumn).className as string; + const nameClasses = (stylex.props(styles.name).className as string).split(' '); + const emailClasses = (stylex.props(styles.email).className as string).split(' '); + + renderPanel({ + members: [ + { + id: 'm-long', + name: longMemberName, + email: longMemberEmail, + role: 'member', + addedLabel: 'Jun 14, 2026', + addedAt: 3, + }, + ], + invitations: [ + { + id: 'i-long', + email: longInvitationEmail, + role: 'member', + invitedLabel: 'Jun 14, 2026', + invitedAt: 2, + }, + ], + requests: [ + { + id: 'r-long', + name: longRequestName, + email: longRequestEmail, + role: 'member', + requestedLabel: 'May 5, 2026', + requestedAt: 1, + }, + ], + }); + + const expectNameColumn = (identity: HTMLElement) => { + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveClass(nameColumnClass); + expect(identity.closest('td')).toHaveClass(nameColumnClass); + }; + + const memberName = screen.getByText(longMemberName); + expectNameColumn(memberName); + expect(memberName).toHaveClass(...nameClasses); + expect(screen.getByText(longMemberEmail)).toHaveClass(...emailClasses); + + await user.click(screen.getByRole('tab', { name: 'Invitations' })); + const invitationEmail = screen.getByText(longInvitationEmail); + expectNameColumn(invitationEmail); + expect(invitationEmail).toHaveClass(...emailClasses); + + await user.click(screen.getByRole('tab', { name: /Requests/ })); + const requestName = screen.getByText(longRequestName); + expectNameColumn(requestName); + expect(requestName).toHaveClass(...nameClasses); + expect(screen.getByText(longRequestEmail)).toHaveClass(...emailClasses); + }); + + it('filters the members list by search', async () => { + const user = userEvent.setup(); + renderPanel(); + const search = screen.getByRole('textbox', { name: 'Search members' }); + expect(search).toHaveClass(...(stylex.props(styles.toolbarSearchInput).className as string).split(' ')); + expect(screen.getByRole('combobox', { name: /Filter by role/ })).toHaveAttribute('data-size', 'md'); + await user.type(search, 'colin'); + expect(screen.getByText('Colin Sidoti')).toBeInTheDocument(); + expect(screen.queryByText('Kyle Mac')).not.toBeInTheDocument(); + }); + + it('shows the members empty state and sends an invite', async () => { + const user = userEvent.setup(); + const props = renderPanel({ members: [], defaultTab: 'members' }); + expect(screen.getByRole('tab', { name: 'Members' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('No members yet')).toBeInTheDocument(); + expect(screen.getByText('Invite people to add them to this organization.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Send invite' })); + expect(props.onInvite).toHaveBeenCalledOnce(); + }); + + it('opens directly to the invitations empty state and sends an invite', async () => { + const user = userEvent.setup(); + const props = renderPanel({ invitations: [], defaultTab: 'invitations' }); + expect(screen.getByRole('tab', { name: 'Invitations' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('No pending invitations')).toBeInTheDocument(); + expect( + screen.getByText('Invite teammates by email and they’ll show up here until they accept.'), + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Invite member' })); + expect(props.onInvite).toHaveBeenCalledOnce(); + }); + + it('opens directly to the requests empty state without an empty-state action', () => { + renderPanel({ requests: [], defaultTab: 'requests' }); + expect(screen.getByRole('tab', { name: 'Requests' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('No pending requests')).toBeInTheDocument(); + expect(screen.getByText('Requests to join this organization will show up here.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clear filters' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Send invite' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Invite member' })).not.toBeInTheDocument(); + }); + + it.each([ + { tab: 'members' as const, query: 'Kyle', emptyLabel: 'No members found', result: 'Kyle Mac' }, + { + tab: 'invitations' as const, + query: 'ksearle', + emptyLabel: 'No invitations found', + result: 'ksearle@clerk.dev', + }, + { tab: 'requests' as const, query: 'Jared', emptyLabel: 'No requests found', result: 'Jared Dunn' }, + ])('clears search and role filters from the $tab no-results state', async ({ tab, query, emptyLabel, result }) => { + const user = userEvent.setup(); + renderPanel({ defaultTab: tab }); + const roleFilter = screen.getByRole('combobox', { name: /Filter by role/ }); + const search = screen.getByRole('textbox', { name: 'Search members' }); + await user.click(roleFilter); + await user.click(screen.getByRole('option', { name: 'Admin' })); + await user.type(search, query); + expect(screen.getByText(emptyLabel)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Clear filters' })); + expect(search).toHaveValue(''); + expect(roleFilter).toHaveTextContent('All Roles'); + expect(screen.getByText(result)).toBeInTheDocument(); + }); + + it('shows the invitations tab with its rows and a revoke action', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + await user.click(screen.getByRole('tab', { name: 'Invitations' })); + const invitationRow = screen.getByText('ksearle@clerk.dev').closest('tr') as HTMLElement; + const invitationAvatar = invitationRow.querySelector('.cl-avatar') as HTMLElement; + expect(invitationAvatar).toHaveClass(...(stylex.props(styles.invitationAvatar).className as string).split(' ')); + const invitationAvatarRing = invitationAvatar.querySelector('circle'); + expect(invitationAvatarRing).toHaveAttribute('stroke', colorVars['--cl-color-border']); + expect(invitationAvatarRing).toHaveAttribute('stroke-dasharray', '4 4'); + expect(invitationAvatarRing).toHaveAttribute('stroke-linecap', 'round'); + expect(invitationAvatar.querySelector('.cl-icon')).toBeInTheDocument(); + const invitationRole = within(invitationRow).getByRole('button', { name: /Member/ }); + expect(invitationRole).toHaveAttribute('data-size', 'md'); + await user.click(invitationRole); + await user.click(screen.getByRole('menuitem', { name: 'Admin' })); + expect(props.onChangeInvitationRole).toHaveBeenCalledWith(['i1'], 'admin'); + await user.click(screen.getByRole('button', { name: 'Manage invitation for ksearle@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(props.onRevokeInvitation).toHaveBeenCalledWith(['i1']); + }); + + it('shows the requests tab with a count badge and accept/decline actions', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + // Badge count beside the Requests tab. + expect(within(screen.getByRole('tab', { name: /Requests/ })).getByText('1')).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: /Requests/ })); + const requestRow = screen.getByText('Jared Dunn').closest('tr') as HTMLElement; + const requestRole = within(requestRow).getByRole('button', { name: /Member/ }); + expect(requestRole).toHaveAttribute('data-size', 'md'); + await user.click(requestRole); + await user.click(screen.getByRole('menuitem', { name: 'Admin' })); + expect(props.onChangeRequestRole).toHaveBeenCalledWith(['r1'], 'admin'); + await user.click(screen.getByRole('button', { name: 'Accept' })); + expect(props.onAcceptRequest).toHaveBeenCalledWith('r1'); + await user.click(screen.getByRole('button', { name: 'Decline' })); + expect(props.onDeclineRequest).toHaveBeenCalledWith(['r1']); + }); + + it('confirms before removing a member from the row menu', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + await user.click(screen.getByRole('button', { name: 'Manage Kyle Mac' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); + // The confirmation dialog appears; nothing is removed until it is confirmed. + expect(props.onRemoveMembers).not.toHaveBeenCalled(); + const dialog = await screen.findByRole('alertdialog', { name: 'Remove 1 member' }); + expect( + within(dialog).getByText('They will lose access to this workspace and its applications.'), + ).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Remove members' })); + expect(props.onRemoveMembers).toHaveBeenCalledWith(['m1']); + }); + + it('counts the selection in the bulk remove confirmation', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle Mac' })); + await user.click(screen.getByRole('checkbox', { name: 'Select Colin Sidoti' })); + await user.click(screen.getByRole('button', { name: 'Remove selected members' })); + const dialog = await screen.findByRole('alertdialog', { name: 'Remove 2 members' }); + await user.click(within(dialog).getByRole('button', { name: 'Remove members' })); + expect(props.onRemoveMembers).toHaveBeenCalledWith(['m1', 'm2']); + }); + + it('changes roles and removes selected invitations in bulk', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + await user.click(screen.getByRole('tab', { name: 'Invitations' })); + await user.click(screen.getByRole('checkbox', { name: 'Select invitation for ksearle@clerk.dev' })); + const toolbar = screen.getByRole('toolbar', { name: 'Invitation bulk actions' }); + await user.click(within(toolbar).getByRole('button', { name: /Change role/ })); + await user.click(screen.getByRole('menuitem', { name: 'Admin' })); + expect(props.onChangeInvitationRole).toHaveBeenCalledWith(['i1'], 'admin'); + + await user.click(screen.getByRole('checkbox', { name: 'Select invitation for ksearle@clerk.dev' })); + await user.click(screen.getByRole('button', { name: 'Remove selected invitations' })); + expect(props.onRevokeInvitation).toHaveBeenCalledWith(['i1']); + }); + + it('changes roles and removes selected requests in bulk', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + await user.click(screen.getByRole('tab', { name: /Requests/ })); + await user.click(screen.getByRole('checkbox', { name: 'Select request from Jared Dunn' })); + const toolbar = screen.getByRole('toolbar', { name: 'Request bulk actions' }); + await user.click(within(toolbar).getByRole('button', { name: /Change role/ })); + await user.click(screen.getByRole('menuitem', { name: 'Admin' })); + expect(props.onChangeRequestRole).toHaveBeenCalledWith(['r1'], 'admin'); + + await user.click(screen.getByRole('checkbox', { name: 'Select request from Jared Dunn' })); + await user.click(screen.getByRole('button', { name: 'Remove selected requests' })); + expect(props.onDeclineRequest).toHaveBeenCalledWith(['r1']); + }); + + it('reveals the bulk action bar once rows are selected', async () => { + const user = userEvent.setup(); + renderPanel(); + const toolbar = screen.getByRole('toolbar', { name: 'Member bulk actions', hidden: true }); + expect(toolbar).toHaveAttribute('data-open', 'false'); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle Mac' })); + expect(toolbar).toHaveAttribute('data-open', 'true'); + expect(within(toolbar).getByText('1 selected')).toBeInTheDocument(); + const buttons = within(toolbar).getAllByRole('button'); + expect(buttons).toHaveLength(3); + buttons.forEach(button => expect(button).toHaveAttribute('data-size', 'md')); + }); + + it('hides mutation affordances when their callbacks are absent', () => { + renderPanel({ onRemoveMembers: undefined, onChangeRole: undefined }); + expect(screen.queryByRole('button', { name: 'Manage Kyle Mac' })).not.toBeInTheDocument(); + // With no onChangeRole, roles render as a disabled button rather than a menu trigger. + const kyleRow = screen.getByText('Kyle Mac').closest('tr') as HTMLElement; + expect(within(kyleRow).getByRole('button', { name: /Member/ })).toBeDisabled(); + }); + + it('leaves the invite button inert but present when onInvite is absent', () => { + renderPanel({ onInvite: undefined }); + expect(screen.getByRole('button', { name: /Invite/ })).toBeEnabled(); + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts new file mode 100644 index 00000000000..9370e4f751a --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.styles.ts @@ -0,0 +1,12 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx new file mode 100644 index 00000000000..18097b1ccf3 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx @@ -0,0 +1,16 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Profile } from '../../components/profile'; +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './organization-profile-api-keys-panel.styles'; + +export type OrganizationProfileApiKeysPanelViewProps = Record; + +export function OrganizationProfileApiKeysPanelView(_props: OrganizationProfileApiKeysPanelViewProps): ReactElement { + return ( +
+ API Keys +
+ ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.styles.ts b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.styles.ts new file mode 100644 index 00000000000..9370e4f751a --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.styles.ts @@ -0,0 +1,12 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx new file mode 100644 index 00000000000..e782f22ba71 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx @@ -0,0 +1,16 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Profile } from '../../components/profile'; +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './organization-profile-billing-panel.styles'; + +export type OrganizationProfileBillingPanelViewProps = Record; + +export function OrganizationProfileBillingPanelView(_props: OrganizationProfileBillingPanelViewProps): ReactElement { + return ( +
+ Billing +
+ ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-bulk-actions.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-bulk-actions.view.tsx new file mode 100644 index 00000000000..b3ef6dd6a4b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-bulk-actions.view.tsx @@ -0,0 +1,112 @@ +import type { ReactElement } from 'react'; +import { createPortal } from 'react-dom'; + +import { ActionBar } from '../../components/action-bar'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Menu } from '../../components/menu'; +import { styles } from './organization-profile-members-panel.styles'; +import type { OrganizationRole } from './organization-profile-members-panel.types'; + +/** + * Where the bulk bar renders and how it is pinned. `frame` anchors it to a bounded surface (the + * dialog's non-scrolling frame). `viewport` pins it to the window, centered, the way a modal floats + * — used by the embedded profile, which has no bounded frame of its own. `null` renders it inline. + */ +export interface OrganizationProfileBulkOverlay { + container: HTMLElement; + mode: 'frame' | 'viewport'; +} + +export interface OrganizationProfileBulkActionsProps { + /** How many rows are selected. The bar renders nothing when zero. */ + count: number; + roles: OrganizationRole[]; + /** Where and how the floating bar is pinned; falls back to inline when absent. */ + overlay: OrganizationProfileBulkOverlay | null; + /** Change the role of the selection. Omitted hides the control. */ + onChangeRole?: (role: string) => void; + /** Remove the selection. Omitted hides the control. */ + onRemove?: () => void; + /** The remove control's accessible label, e.g. "Remove selected members". */ + removeLabel: string; + onDismiss: () => void; + 'aria-label': string; +} + +/** + * The floating bulk-action bar shared by the members, invitations, and requests tabs: a count, a + * "Change role" menu, a remove control, and a dismiss. It pins to the surface's non-scrolling frame + * so it clears the scroll region's bottom fade. + */ +export function OrganizationProfileBulkActions({ + count, + roles, + overlay, + onChangeRole, + onRemove, + removeLabel, + onDismiss, + 'aria-label': ariaLabel, +}: OrganizationProfileBulkActionsProps): ReactElement { + const positionerXstyle = overlay + ? overlay.mode === 'viewport' + ? styles.bulkActionBarViewportPositioner + : styles.bulkActionBarOverlayPositioner + : undefined; + const bar = ( + 0} + aria-label={ariaLabel} + positionerXstyle={positionerXstyle} + > + {count} selected + + {onChangeRole ? ( + + + } + > + Change role + + + + {roles.map(role => ( + onChangeRole(role.value)} + > + {role.label} + + ))} + + + ) : null} + {onChangeRole && onRemove ? : null} + {onRemove ? ( + + ) : null} + + + + ); + return overlay ? createPortal(bar, overlay.container) : bar; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.styles.ts b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.styles.ts new file mode 100644 index 00000000000..7d5f609b28b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.styles.ts @@ -0,0 +1,23 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + slugValue: { + gap: space['0.5'], + alignItems: 'center', + display: 'flex', + }, +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx new file mode 100644 index 00000000000..70299274909 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx @@ -0,0 +1,646 @@ +import type { FileRejection } from '@clerk/headless/file-upload'; +import { FileUpload } from '@clerk/headless/file-upload'; +import * as stylex from '@stylexjs/stylex'; +import type { FormEvent, ReactElement } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; + +import { Destructive } from '../../blocks/destructive'; +import { Avatar } from '../../components/avatar'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Icon } from '../../components/icon'; +import { Input } from '../../components/input'; +import { Menu } from '../../components/menu'; +import { Profile } from '../../components/profile'; +import { Section } from '../../components/section'; +import { Tooltip } from '../../components/tooltip'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { fill, plural, useLocale, useMessages } from '../../localization'; +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './organization-profile-general-panel.styles'; + +const LOGO_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; +const LOGO_MAX_BYTES = 10 * 1000 * 1000; + +export interface OrganizationProfileGeneralPanelViewProps { + name: string; + slug?: string; + imageUrl?: string; + hasImage?: boolean; + membersCount: number; + onLogoChange?: (file: File) => Promise; + onLogoReject?: (rejections: FileRejection[]) => void; + onRemoveLogo?: () => Promise; + onSubmitName?: (name: string) => Promise; + onSubmitSlug?: (slug: string) => Promise; + onCopySlug?: (slug: string) => Promise; + onLeave?: () => Promise; + onDelete?: () => Promise; +} + +type EditDialogProps = { + label: string; + title: string; + description?: string; + value: string; + onSubmit: (value: string) => Promise; +}; + +function EditDialog({ label, title, description, value: savedValue, onSubmit }: EditDialogProps) { + const m = useMessages('organizationProfile'); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(savedValue); + const [isSaving, setIsSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const formId = useId(); + const inputRef = useRef(null); + const canSave = value.trim() !== '' && value !== savedValue; + + const handleOpenChange = (nextOpen: boolean) => { + if (isSaving) { + return; + } + if (nextOpen) { + setValue(savedValue); + setErrorMessage(undefined); + } + setOpen(nextOpen); + }; + + const save = async () => { + setIsSaving(true); + setErrorMessage(undefined); + try { + await onSubmit(value); + setOpen(false); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : m.general.errors.generic); + } finally { + setIsSaving(false); + } + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSave && !isSaving) { + void save(); + } + }; + + return ( + + + } + > + {m.general.edit} + + + + + {title} + {description ? {description} : null} + + + } + > + + {label} + { + setValue(event.target.value); + setErrorMessage(undefined); + }} + /> + + {errorMessage} + + + + + + {m.general.cancel} + + } + /> + + {m.general.save} + + + + + + ); +} + +function LogoRow({ + name, + imageUrl, + hasImage = false, + onChange, + onReject, + onRemove, +}: { + name: string; + imageUrl?: string; + hasImage?: boolean; + onChange?: (file: File) => Promise; + onReject?: (rejections: FileRejection[]) => void; + onRemove?: () => Promise; +}) { + const m = useMessages('organizationProfile'); + const [errorMessage, setErrorMessage] = useState(); + const [isSaving, setIsSaving] = useState(false); + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + const run = async (action: () => Promise) => { + setIsSaving(true); + setErrorMessage(undefined); + try { + await action(); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : m.general.errors.generic); + } finally { + setIsSaving(false); + } + }; + + return ( + } + onReject={rejections => { + const rejection = rejections[0]; + setErrorMessage(rejection ? m.general.logo.errors[rejection.reason] : undefined); + onReject?.(rejections); + }} + onValueChange={files => { + const file = files[0]; + if (file && onChange) { + void run(() => onChange(file)); + } + }} + > + + + + + {initials} + + + + {m.general.logo.label} + {m.general.logo.description} + + void run(onRemove) : undefined} + /> + + {errorMessage ? {errorMessage} : null} + + ); +} + +function LogoActions({ + canChange, + hasImage, + isSaving, + onRemove, +}: { + canChange: boolean; + hasImage: boolean; + isSaving: boolean; + onRemove?: () => void; +}) { + const m = useMessages('organizationProfile'); + const { openFilePicker } = FileUpload.useFileUpload(); + + if (!hasImage && canChange) { + return ( + + + } + > + {m.general.logo.upload} + + + ); + } + + if (!hasImage || (!canChange && !onRemove)) { + return null; + } + + return ( + + + + + {canChange ? ( + + + + + {m.general.logo.change} + + ) : null} + {onRemove ? ( + + + + + {m.general.logo.remove} + + ) : null} + + + + ); +} + +function CopySlug({ slug, onCopy }: { slug: string; onCopy: (slug: string) => Promise }) { + const m = useMessages('organizationProfile'); + const [status, setStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle'); + const timeoutRef = useRef>(); + + useEffect(() => { + setStatus('idle'); + }, [slug]); + + useEffect( + () => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, + [], + ); + + const copy = async () => { + setStatus('pending'); + try { + await onCopy(slug); + setStatus('success'); + } catch { + setStatus('error'); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => setStatus('idle'), 2000); + }; + + const feedback = status === 'success' ? m.general.slug.copied : status === 'error' ? m.general.slug.copyFailed : null; + + return ( + <> + + + } + onClick={() => void copy()} + > + + + {feedback} + + {feedback} + + ); +} + +function WorkspaceDetails({ + name, + slug, + imageUrl, + hasImage, + onLogoChange, + onLogoReject, + onRemoveLogo, + onSubmitName, + onSubmitSlug, + onCopySlug, +}: Omit) { + const m = useMessages('organizationProfile'); + + return ( + + {m.general.detailsTitle} + + + + + + {m.general.name.label} + {name} + + {onSubmitName ? ( + + + + ) : null} + + + {slug !== undefined ? ( + + + + {m.general.slug.label} + + {slug || m.general.slug.empty} + {slug && onCopySlug ? ( + + ) : null} + + + {onSubmitSlug ? ( + + + + ) : null} + + + ) : null} + + + ); +} + +function useDestructiveAction(action: () => Promise) { + const m = useMessages('organizationProfile'); + const [open, setOpen] = useState(false); + const [isPending, setIsPending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const onOpenChange = (nextOpen: boolean) => { + if (isPending) { + return; + } + setOpen(nextOpen); + if (!nextOpen) { + setErrorMessage(undefined); + } + }; + + const onConfirm = async () => { + if (isPending) { + return; + } + setIsPending(true); + setErrorMessage(undefined); + try { + await action(); + setOpen(false); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : m.general.errors.generic); + } finally { + setIsPending(false); + } + }; + + return { open, onOpenChange, onConfirm, isPending, errorMessage }; +} + +function DangerAction({ + name, + label, + description, + dialogTitle, + dialogDescription, + onAction, +}: { + name: string; + label: string; + description: string; + dialogTitle: string; + dialogDescription: string; + onAction: () => Promise; +}) { + const m = useMessages('organizationProfile'); + const action = useDestructiveAction(onAction); + + return ( + + + + {label} + {description} + + + + {label} + + } + title={dialogTitle} + description={dialogDescription} + fieldLabel={fill(m.general.danger.fieldLabel, { phrase: name })} + confirmationValue={name} + actionLabel={label} + cancelLabel={m.general.cancel} + onDelete={() => void action.onConfirm()} + isDeleting={action.isPending} + errorMessage={action.errorMessage} + /> + + + + ); +} + +function DangerZone({ + name, + membersCount, + onLeave, + onDelete, +}: Pick) { + const m = useMessages('organizationProfile'); + const locale = useLocale(); + + if (!onLeave && !onDelete) { + return null; + } + + return ( + + {m.general.danger.title} + + {onLeave ? ( + + ) : null} + {onDelete ? ( + + ) : null} + + + ); +} + +export function OrganizationProfileGeneralPanelView({ + name, + slug, + imageUrl, + hasImage = false, + membersCount, + onLogoChange, + onLogoReject, + onRemoveLogo, + onSubmitName, + onSubmitSlug, + onCopySlug, + onLeave, + onDelete, +}: OrganizationProfileGeneralPanelViewProps): ReactElement { + const m = useMessages('organizationProfile'); + + return ( +
+ {m.pages.general} +
+ + +
+
+ ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx new file mode 100644 index 00000000000..9c9437afaad --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx @@ -0,0 +1,331 @@ +import type { OnChangeFn, SortingState } from '@clerk/headless/hooks'; +import { useDataTable } from '@clerk/headless/hooks'; +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { Avatar } from '../../components/avatar'; +import { Button } from '../../components/button'; +import { EmptyState } from '../../components/empty-state'; +import { Icon } from '../../components/icon'; +import { Menu } from '../../components/menu'; +import type { TableSort } from '../../components/table'; +import { Table } from '../../components/table'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { colorVars } from '../../tokens.stylex'; +import type { OrganizationProfileBulkOverlay } from './organization-profile-bulk-actions.view'; +import { OrganizationProfileBulkActions } from './organization-profile-bulk-actions.view'; +import { + hasMembersPager, + MEMBERS_PAGE_SIZE, + OrganizationProfileMembersPager, +} from './organization-profile-members-pager.view'; +import { styles } from './organization-profile-members-panel.styles'; +import type { OrganizationInvitation, OrganizationRole } from './organization-profile-members-panel.types'; +import { OrganizationProfileRoleControl } from './organization-profile-role-control.view'; + +const sortKeys = { + name: (invitation: OrganizationInvitation) => invitation.email, + role: (invitation: OrganizationInvitation) => invitation.role, + invited: (invitation: OrganizationInvitation) => invitation.invitedAt, +}; + +type SortableColumn = keyof typeof sortKeys; + +const isSortableColumn = (id: string): id is SortableColumn => id in sortKeys; + +function compare(a: string | number, b: string | number) { + return typeof a === 'string' || typeof b === 'string' ? String(a).localeCompare(String(b)) : a - b; +} + +function sortFor(sorting: SortingState, column: SortableColumn): TableSort { + const current = sorting[0]; + if (!current || current.id !== column) { + return 'none'; + } + return current.desc ? 'descending' : 'ascending'; +} + +function cycleSort(setSorting: OnChangeFn, column: SortableColumn) { + setSorting(sorting => { + const current = sorting[0]; + if (!current || current.id !== column) { + return [{ id: column, desc: false }]; + } + return current.desc ? [] : [{ id: column, desc: true }]; + }); +} + +function sortInvitations(invitations: OrganizationInvitation[], sorting: SortingState) { + const current = sorting[0]; + if (!current || !isSortableColumn(current.id)) { + return invitations; + } + const key = sortKeys[current.id]; + const sorted = [...invitations].sort((a, b) => compare(key(a), key(b))); + return current.desc ? sorted.reverse() : sorted; +} + +export interface OrganizationProfileInvitationsTabViewProps { + invitations: OrganizationInvitation[]; + roles: OrganizationRole[]; + /** The active search, applied to the invitee email. */ + search: string; + /** The active role filter, or `null` for all roles. */ + roleFilter: string | null; + /** Where and how the floating bulk bar is pinned. */ + overlay: OrganizationProfileBulkOverlay | null; + /** Change the role of the selected invitations. Omitted hides the bulk affordance. */ + onChangeRole?: (invitationIds: string[], role: string) => void; + /** Revoke one or more invitations. Omitted hides the affordances that call it. */ + onRevoke?: (invitationIds: string[]) => void; + /** Opens the invite flow, from the empty state. Inert while absent. */ + onInvite?: () => void; + onClearFilters?: () => void; +} + +export function OrganizationProfileInvitationsTabView({ + invitations, + roles, + search, + roleFilter, + overlay, + onChangeRole, + onRevoke, + onInvite, + onClearFilters, +}: OrganizationProfileInvitationsTabViewProps): ReactElement { + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: MEMBERS_PAGE_SIZE }); + + const query = search.trim().toLowerCase(); + const filtered = React.useMemo( + () => + invitations.filter( + invitation => + (query === '' || invitation.email.toLowerCase().includes(query)) && + (roleFilter === null || invitation.role === roleFilter), + ), + [invitations, query, roleFilter], + ); + const sorted = React.useMemo(() => sortInvitations(filtered, sorting), [filtered, sorting]); + + const pageCount = Math.max(1, Math.ceil(sorted.length / pagination.pageSize)); + const pageIndex = Math.min(pagination.pageIndex, pageCount - 1); + const pageStart = pageIndex * pagination.pageSize; + const pageRows = sorted.slice(pageStart, pageStart + pagination.pageSize); + + const table = useDataTable({ + data: pageRows, + getRowId: row => row.id, + totalCount: filtered.length, + sorting, + onSortingChange: setSorting, + }); + + const allSelected = table.rows.length > 0 && table.rows.every(row => row.getIsSelected()); + const someSelected = table.rows.some(row => row.getIsSelected()); + const toggleAll = () => { + const next = { ...table.rowSelection }; + for (const row of table.rows) { + next[row.id] = !allSelected; + } + table.setRowSelection(next); + }; + const selectedIds = Object.entries(table.rowSelection) + .filter(([, selected]) => selected) + .map(([id]) => id); + const selectedCount = selectedIds.length; + const clearSelection = () => table.setRowSelection({}); + + const isFiltered = query !== '' || roleFilter !== null; + + return ( + <> + + + + + cycleSort(table.setSorting, 'name')} + xstyle={styles.nameColumn} + > + Name + + cycleSort(table.setSorting, 'role')} + > + Role + + cycleSort(table.setSorting, 'invited')} + > + Invited + + + Actions + + + + + {table.rows.length === 0 ? ( + +
+ + + {isFiltered ? 'No invitations found' : 'No pending invitations'} + + {isFiltered + ? 'No pending invitations match your search.' + : 'Invite teammates by email and they’ll show up here until they accept.'} + + {(isFiltered && onClearFilters) || (!isFiltered && onInvite) ? ( + + {isFiltered ? ( + + ) : ( + + )} + + ) : null} + +
+
+ ) : ( + table.rows.map(row => ( + + + +
+ + {row.original.imageUrl ? ( + <> + + + + ) : ( + <> + + + + + + )} + + {row.original.email} +
+
+ + onChangeRole([row.original.id], role) : undefined} + /> + + {row.original.invitedLabel} + + {onRevoke ? ( + + + + onRevoke([row.original.id])} + > + Revoke invitation + + + + ) : null} + +
+ )) + )} +
+
+ + {hasMembersPager(filtered.length) ? ( + setPagination(state => ({ ...state, pageIndex: page - 1 }))} + onPageSizeChange={pageSize => setPagination({ pageIndex: 0, pageSize })} + /> + ) : null} + + {selectedCount > 0 ?
: null} + + { + onChangeRole(selectedIds, role); + clearSelection(); + } + : undefined + } + onRemove={ + onRevoke + ? () => { + onRevoke(selectedIds); + clearSelection(); + } + : undefined + } + removeLabel='Remove selected invitations' + onDismiss={clearSelection} + aria-label='Invitation bulk actions' + /> + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-pager.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-pager.view.tsx new file mode 100644 index 00000000000..925fc8fd841 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-pager.view.tsx @@ -0,0 +1,43 @@ +import type { ReactElement } from 'react'; + +import { Pagination } from '../../components/pagination'; + +/** The page size every members list starts at, and the smallest one its pager offers. */ +export const MEMBERS_PAGE_SIZE = 10; + +/** + * Whether a list of `totalItems` rows is worth a pager. Measured against the smallest page size on + * offer rather than the current one, so raising the page size never hides the control that set it. + */ +export function hasMembersPager(totalItems: number): boolean { + return totalItems > MEMBERS_PAGE_SIZE; +} + +export interface OrganizationProfileMembersPagerProps { + /** The current page, 1-based. */ + page: number; + /** How many rows are shown per page. */ + pageSize: number; + /** The total number of rows across every page. */ + totalItems: number; + onChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; +} + +export function OrganizationProfileMembersPager({ + page, + pageSize, + totalItems, + onChange, + onPageSizeChange, +}: OrganizationProfileMembersPagerProps): ReactElement { + return ( + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.styles.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.styles.ts new file mode 100644 index 00000000000..f626647a598 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.styles.ts @@ -0,0 +1,141 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space, typeScaleVars } from '../../tokens.stylex'; + +const compact = '@container (width < 40rem)' as const; + +export const styles = stylex.create({ + root: { + gap: space['4'], + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', + minWidth: 0, + width: '100%', + }, + // Room to scroll the last rows clear of the pinned bulk bar — only while it is showing. + rootWithBulkBar: { + paddingBlockEnd: space['8'], + }, + // The sub-tabs' equivalent of `rootWithBulkBar`: a spacer at the foot of the scroll content. + bulkSpacer: { + flexShrink: 0, + height: space['8'], + }, + // Tabs and the toolbar above the table. + header: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + }, + member: { + gap: space['2.5'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + // A pending-invite placeholder: a dashed ring around a muted user glyph, in place of a photo. + invitationAvatar: { + color: colorVars['--cl-color-foreground-secondary'], + }, + invitationAvatarRing: { + inset: 0, + pointerEvents: 'none', + position: 'absolute', + height: '100%', + width: '100%', + }, + memberText: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minWidth: 0, + }, + nameColumn: { + minWidth: '250px', + }, + name: { + overflow: 'hidden', + color: colorVars['--cl-color-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + you: { + color: colorVars['--cl-color-foreground-secondary'], + }, + email: { + overflow: 'hidden', + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + // At least as wide as the "Change role" trigger, so a short role list lines the popup up with the + // button; a longer label grows it past the button, and the centered placement keeps it balanced. + roleMenuPopup: { + minWidth: 'var(--cl-anchor-width)', + }, + bulkActionBarOverlayPositioner: { + bottom: space['5'], + }, + // The embedded profile has no bounded frame, so the bar pins to the window — centered, like a + // modal's floating bar — rather than tracking the content column. + bulkActionBarViewportPositioner: { + position: 'fixed', + bottom: space['5'], + }, + toolbar: { + gap: space['3'], + alignItems: { [compact]: 'stretch', default: 'center' }, + display: 'flex', + flexDirection: { [compact]: 'column', default: 'row' }, + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + // Search and the role filter, grouped together at the start of the toolbar. + toolbarStart: { + gap: space['2'], + alignItems: { [compact]: 'stretch', default: 'center' }, + display: 'flex', + flexDirection: { [compact]: 'column', default: 'row' }, + flexGrow: 1, + flexWrap: 'wrap', + width: { [compact]: '100%', default: 'auto' }, + }, + toolbarSearch: { + flexBasis: { [compact]: 'auto', default: '16rem' }, + flexGrow: 1, + maxWidth: { [compact]: 'none', default: '20rem' }, + minWidth: '12rem', + width: { [compact]: '100%', default: 'auto' }, + }, + toolbarSearchControl: { + minHeight: { default: space['8'], '@media (pointer: coarse)': space['8'] }, + }, + toolbarSearchInput: { + paddingInlineStart: space['2'], + }, + toolbarControl: { + width: { [compact]: '100%', default: 'auto' }, + }, + toolbarActions: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + width: { [compact]: '100%', default: 'auto' }, + }, + actions: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + }, + empty: { + paddingBlock: space['10'], + }, +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.types.ts new file mode 100644 index 00000000000..1c360d33509 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.types.ts @@ -0,0 +1,71 @@ +export interface OrganizationRole { + value: string; + label: string; +} + +export interface OrganizationMember { + id: string; + name: string; + email: string; + imageUrl?: string; + /** The `value` of the member's current role. */ + role: string; + /** A preformatted date, e.g. `May 5, 2026`. */ + addedLabel: string; + /** The sort key behind `addedLabel`. */ + addedAt: number; +} + +export interface OrganizationInvitation { + id: string; + email: string; + imageUrl?: string; + /** The `value` of the role the invitee will hold. */ + role: string; + /** A preformatted date, e.g. `Jun 14, 2026`. */ + invitedLabel: string; + /** The sort key behind `invitedLabel`. */ + invitedAt: number; +} + +export interface OrganizationRequest { + id: string; + name: string; + email: string; + imageUrl?: string; + /** The `value` of the role the requester is asking for. */ + role: string; + /** A preformatted date, e.g. `May 5, 2026`. */ + requestedLabel: string; + /** The sort key behind `requestedLabel`. */ + requestedAt: number; +} + +/** The tabs the members page offers, by the id its tab bar knows each one as. */ +export type OrganizationProfileMembersTab = 'members' | 'invitations' | 'requests'; + +export interface OrganizationProfileMembersPanelViewProps { + members: OrganizationMember[]; + invitations?: OrganizationInvitation[]; + requests?: OrganizationRequest[]; + roles: OrganizationRole[]; + defaultTab?: OrganizationProfileMembersTab; + /** The viewer's own member id. Their row cannot be selected and their role cannot be changed. */ + currentUserId?: string; + /** Omitted when the viewer cannot change member roles, which hides the affordances that call it. */ + onChangeRole?: (memberIds: string[], role: string) => void; + /** Omitted when the viewer cannot remove members, which hides the affordances that call it. */ + onRemoveMembers?: (memberIds: string[]) => void; + /** Change the role one or more pending invitations will grant. Omitted hides the affordance. */ + onChangeInvitationRole?: (invitationIds: string[], role: string) => void; + /** Revoke one or more pending invitations. Omitted hides the affordances that call it. */ + onRevokeInvitation?: (invitationIds: string[]) => void; + /** Change the role one or more join requests would be accepted with. Omitted hides the affordance. */ + onChangeRequestRole?: (requestIds: string[], role: string) => void; + /** Accept a single join request. Omitted hides the affordance that calls it. */ + onAcceptRequest?: (requestId: string) => void; + /** Decline one or more join requests. Omitted hides the affordances that call it. */ + onDeclineRequest?: (requestIds: string[]) => void; + /** Opens the invite flow. Absent until the invite surface is wired, which leaves the button inert. */ + onInvite?: () => void; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx new file mode 100644 index 00000000000..968476f7dca --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -0,0 +1,522 @@ +import type { OnChangeFn, SortingState } from '@clerk/headless/hooks'; +import { useDataTable } from '@clerk/headless/hooks'; +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { Confirmation } from '../../blocks/confirmation'; +import { Avatar } from '../../components/avatar'; +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { EmptyState } from '../../components/empty-state'; +import { Icon } from '../../components/icon'; +import { InputGroup } from '../../components/input-group'; +import { Menu } from '../../components/menu'; +import { Profile } from '../../components/profile'; +import { Select } from '../../components/select'; +import type { TableSort } from '../../components/table'; +import { Table } from '../../components/table'; +import { Tabs } from '../../components/tabs'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import { mergeStyleProps, themeProps } from '../../props'; +import type { OrganizationProfileBulkOverlay } from './organization-profile-bulk-actions.view'; +import { OrganizationProfileBulkActions } from './organization-profile-bulk-actions.view'; +import { OrganizationProfileInvitationsTabView } from './organization-profile-invitations-tab.view'; +import { + hasMembersPager, + MEMBERS_PAGE_SIZE, + OrganizationProfileMembersPager, +} from './organization-profile-members-pager.view'; +import { styles } from './organization-profile-members-panel.styles'; +import type { + OrganizationMember, + OrganizationProfileMembersPanelViewProps, + OrganizationProfileMembersTab, +} from './organization-profile-members-panel.types'; +import { OrganizationProfileRequestsTabView } from './organization-profile-requests-tab.view'; +import { OrganizationProfileRoleControl } from './organization-profile-role-control.view'; + +export type { + OrganizationInvitation, + OrganizationMember, + OrganizationProfileMembersPanelViewProps, + OrganizationRequest, + OrganizationRole, +} from './organization-profile-members-panel.types'; + +const ALL_ROLES = 'all'; + +const sortKeys = { + name: (member: OrganizationMember) => member.name, + role: (member: OrganizationMember) => member.role, + added: (member: OrganizationMember) => member.addedAt, +}; + +type SortableColumn = keyof typeof sortKeys; + +const isSortableColumn = (id: string): id is SortableColumn => id in sortKeys; + +function compare(a: string | number, b: string | number) { + return typeof a === 'string' || typeof b === 'string' ? String(a).localeCompare(String(b)) : a - b; +} + +function sortFor(sorting: SortingState, column: SortableColumn): TableSort { + const current = sorting[0]; + if (!current || current.id !== column) { + return 'none'; + } + return current.desc ? 'descending' : 'ascending'; +} + +function cycleSort(setSorting: OnChangeFn, column: SortableColumn) { + setSorting(sorting => { + const current = sorting[0]; + if (!current || current.id !== column) { + return [{ id: column, desc: false }]; + } + return current.desc ? [] : [{ id: column, desc: true }]; + }); +} + +function sortMembers(members: OrganizationMember[], sorting: SortingState) { + const current = sorting[0]; + if (!current || !isSortableColumn(current.id)) { + return members; + } + const key = sortKeys[current.id]; + const sorted = [...members].sort((a, b) => compare(key(a), key(b))); + return current.desc ? sorted.reverse() : sorted; +} + +function initials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(word => word[0]?.toUpperCase() ?? '') + .join(''); +} + +function MemberCell({ member, isCurrentUser }: { member: OrganizationMember; isCurrentUser: boolean }): ReactElement { + return ( +
+ + {member.imageUrl ? ( + + ) : null} + {initials(member.name)} + +
+ + {member.name} + {isCurrentUser ? (you) : null} + + {member.email} +
+
+ ); +} + +export function OrganizationProfileMembersPanelView({ + members, + invitations = [], + requests = [], + roles, + defaultTab = 'members', + currentUserId, + onChangeRole, + onRemoveMembers, + onChangeInvitationRole, + onRevokeInvitation, + onChangeRequestRole, + onAcceptRequest, + onDeclineRequest, + onInvite, +}: OrganizationProfileMembersPanelViewProps): ReactElement { + const [activeTab, setActiveTab] = React.useState(defaultTab); + const [search, setSearch] = React.useState(''); + const [roleFilter, setRoleFilter] = React.useState(null); + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: MEMBERS_PAGE_SIZE }); + const [removeIds, setRemoveIds] = React.useState(null); + + // Where the bulk bar floats. In a dialog it pins to the profile's non-scrolling frame (a + // positioned element) so it clears the scroll region's bottom fade. Embedded, that frame is not a + // positioning context, so the bar pins to the window instead — centered, like a modal's bar — + // rather than tracking the content column. With no profile at all it renders inline. + const rootRef = React.useRef(null); + const [overlay, setOverlay] = React.useState(null); + React.useEffect(() => { + const content = rootRef.current?.closest('.cl-profile-content'); + if (!content) { + setOverlay(null); + return; + } + const bounded = getComputedStyle(content).position !== 'static'; + setOverlay(bounded ? { container: content, mode: 'frame' } : { container: document.body, mode: 'viewport' }); + }, []); + + const query = search.trim().toLowerCase(); + + const filteredMembers = React.useMemo( + () => + members.filter( + member => + (query === '' || member.name.toLowerCase().includes(query) || member.email.toLowerCase().includes(query)) && + (roleFilter === null || member.role === roleFilter), + ), + [members, query, roleFilter], + ); + + const sorted = React.useMemo(() => sortMembers(filteredMembers, sorting), [filteredMembers, sorting]); + + // A change to the active list can leave the current page out of range; reset to the first. + React.useEffect(() => { + setPagination(state => ({ ...state, pageIndex: 0 })); + }, [query, roleFilter, activeTab]); + + const pageCount = Math.max(1, Math.ceil(sorted.length / pagination.pageSize)); + const pageIndex = Math.min(pagination.pageIndex, pageCount - 1); + const pageStart = pageIndex * pagination.pageSize; + const pageRows = sorted.slice(pageStart, pageStart + pagination.pageSize); + + const table = useDataTable({ + data: pageRows, + getRowId: row => row.id, + totalCount: filteredMembers.length, + sorting, + onSortingChange: setSorting, + }); + + // The viewer cannot act on themselves, so their row is never part of a selection. + const selectableRows = table.rows.filter(row => row.original.id !== currentUserId); + const allSelectableSelected = selectableRows.length > 0 && selectableRows.every(row => row.getIsSelected()); + const someSelectableSelected = selectableRows.some(row => row.getIsSelected()); + const toggleAllSelectable = () => { + const next = { ...table.rowSelection }; + for (const row of selectableRows) { + next[row.id] = !allSelectableSelected; + } + table.setRowSelection(next); + }; + + const selectedIds = Object.entries(table.rowSelection) + .filter(([id, selected]) => selected && id !== currentUserId) + .map(([id]) => id); + const selectedCount = selectedIds.length; + + const clearSelection = () => table.setRowSelection({}); + + const isFiltered = query !== '' || roleFilter !== null; + + const clearFilters = () => { + setSearch(''); + setRoleFilter(null); + }; + + const confirmRemove = () => { + if (removeIds) { + onRemoveMembers?.(removeIds); + const removed = new Set(removeIds); + table.setRowSelection(Object.fromEntries(Object.entries(table.rowSelection).filter(([id]) => !removed.has(id)))); + } + setRemoveIds(null); + }; + const removeCount = removeIds?.length ?? 0; + + const roleItems = [{ value: ALL_ROLES, label: 'All Roles' }, ...roles]; + + const requestCount = requests.length; + + return ( +
0 && styles.rootWithBulkBar), + )} + > + Members + +
+ setActiveTab(value as OrganizationProfileMembersTab)} + > + + Members + Invitations + + Requests + {requestCount > 0 ? {requestCount} : null} + + + + + +
+
+
+ + + + + setSearch(event.target.value)} + xstyle={styles.toolbarSearchInput} + /> + {search ? ( + + + + ) : null} + +
+ setRoleFilter(value === ALL_ROLES ? null : value)} + > + + } + xstyle={styles.toolbarControl} + /> + + +
+
+ +
+
+
+ + {activeTab === 'members' ? ( + <> + + + + + cycleSort(table.setSorting, 'name')} + xstyle={styles.nameColumn} + > + Name + + cycleSort(table.setSorting, 'role')} + > + Role + + cycleSort(table.setSorting, 'added')} + > + Added + + + Actions + + + + + {table.rows.length === 0 ? ( + +
+ + + {isFiltered ? 'No members found' : 'No members yet'} + + {isFiltered + ? 'Your search did not return any results.' + : 'Invite people to add them to this organization.'} + + {isFiltered || onInvite ? ( + + {isFiltered ? ( + + ) : ( + + )} + + ) : null} + +
+
+ ) : ( + table.rows.map(row => { + const isCurrentUser = row.original.id === currentUserId; + return ( + + + + + + + onChangeRole([row.original.id], role) + } + /> + + {row.original.addedLabel} + + {onRemoveMembers && !isCurrentUser ? ( + + + + setRemoveIds([row.original.id])} + > + Remove from organization + + + + ) : null} + + + ); + }) + )} +
+
+ + {hasMembersPager(filteredMembers.length) ? ( + setPagination(state => ({ ...state, pageIndex: page - 1 }))} + onPageSizeChange={pageSize => setPagination({ pageIndex: 0, pageSize })} + /> + ) : null} + + { + onChangeRole(selectedIds, role); + clearSelection(); + } + : undefined + } + onRemove={onRemoveMembers ? () => setRemoveIds(selectedIds) : undefined} + removeLabel='Remove selected members' + onDismiss={clearSelection} + aria-label='Member bulk actions' + /> + + ) : null} + + {activeTab === 'invitations' ? ( + + ) : null} + + {activeTab === 'requests' ? ( + + ) : null} + + {onRemoveMembers ? ( + { + if (!open) { + setRemoveIds(null); + } + }} + color='negative' + title={`Remove ${removeCount} ${removeCount === 1 ? 'member' : 'members'}`} + description='They will lose access to this workspace and its applications.' + actionLabel='Remove members' + cancelLabel='Cancel' + onConfirm={confirmRemove} + /> + ) : null} +
+ ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx new file mode 100644 index 00000000000..fa55f1075be --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx @@ -0,0 +1,317 @@ +import type { OnChangeFn, SortingState } from '@clerk/headless/hooks'; +import { useDataTable } from '@clerk/headless/hooks'; +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { Avatar } from '../../components/avatar'; +import { Button } from '../../components/button'; +import { EmptyState } from '../../components/empty-state'; +import type { TableSort } from '../../components/table'; +import { Table } from '../../components/table'; +import { VisuallyHidden } from '../../components/visually-hidden'; +import type { OrganizationProfileBulkOverlay } from './organization-profile-bulk-actions.view'; +import { OrganizationProfileBulkActions } from './organization-profile-bulk-actions.view'; +import { + hasMembersPager, + MEMBERS_PAGE_SIZE, + OrganizationProfileMembersPager, +} from './organization-profile-members-pager.view'; +import { styles } from './organization-profile-members-panel.styles'; +import type { OrganizationRequest, OrganizationRole } from './organization-profile-members-panel.types'; +import { OrganizationProfileRoleControl } from './organization-profile-role-control.view'; + +const sortKeys = { + name: (request: OrganizationRequest) => request.name, + role: (request: OrganizationRequest) => request.role, + requested: (request: OrganizationRequest) => request.requestedAt, +}; + +type SortableColumn = keyof typeof sortKeys; + +const isSortableColumn = (id: string): id is SortableColumn => id in sortKeys; + +function compare(a: string | number, b: string | number) { + return typeof a === 'string' || typeof b === 'string' ? String(a).localeCompare(String(b)) : a - b; +} + +function sortFor(sorting: SortingState, column: SortableColumn): TableSort { + const current = sorting[0]; + if (!current || current.id !== column) { + return 'none'; + } + return current.desc ? 'descending' : 'ascending'; +} + +function cycleSort(setSorting: OnChangeFn, column: SortableColumn) { + setSorting(sorting => { + const current = sorting[0]; + if (!current || current.id !== column) { + return [{ id: column, desc: false }]; + } + return current.desc ? [] : [{ id: column, desc: true }]; + }); +} + +function sortRequests(requests: OrganizationRequest[], sorting: SortingState) { + const current = sorting[0]; + if (!current || !isSortableColumn(current.id)) { + return requests; + } + const key = sortKeys[current.id]; + const sorted = [...requests].sort((a, b) => compare(key(a), key(b))); + return current.desc ? sorted.reverse() : sorted; +} + +function initials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(word => word[0]?.toUpperCase() ?? '') + .join(''); +} + +export interface OrganizationProfileRequestsTabViewProps { + requests: OrganizationRequest[]; + roles: OrganizationRole[]; + /** The active search, applied to the requester name and email. */ + search: string; + /** The active role filter, or `null` for all roles. */ + roleFilter: string | null; + /** Where and how the floating bulk bar is pinned. */ + overlay: OrganizationProfileBulkOverlay | null; + /** Change the role the selected requests would be accepted with. Omitted hides the bulk affordance. */ + onChangeRole?: (requestIds: string[], role: string) => void; + /** Accept a single request. Omitted hides the affordance. */ + onAccept?: (requestId: string) => void; + /** Decline one or more requests. Omitted hides the affordances that call it. */ + onDecline?: (requestIds: string[]) => void; + onClearFilters?: () => void; +} + +export function OrganizationProfileRequestsTabView({ + requests, + roles, + search, + roleFilter, + overlay, + onChangeRole, + onAccept, + onDecline, + onClearFilters, +}: OrganizationProfileRequestsTabViewProps): ReactElement { + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: MEMBERS_PAGE_SIZE }); + + const query = search.trim().toLowerCase(); + const filtered = React.useMemo( + () => + requests.filter( + request => + (query === '' || request.name.toLowerCase().includes(query) || request.email.toLowerCase().includes(query)) && + (roleFilter === null || request.role === roleFilter), + ), + [requests, query, roleFilter], + ); + const sorted = React.useMemo(() => sortRequests(filtered, sorting), [filtered, sorting]); + + const pageCount = Math.max(1, Math.ceil(sorted.length / pagination.pageSize)); + const pageIndex = Math.min(pagination.pageIndex, pageCount - 1); + const pageStart = pageIndex * pagination.pageSize; + const pageRows = sorted.slice(pageStart, pageStart + pagination.pageSize); + + const table = useDataTable({ + data: pageRows, + getRowId: row => row.id, + totalCount: filtered.length, + sorting, + onSortingChange: setSorting, + }); + + const allSelected = table.rows.length > 0 && table.rows.every(row => row.getIsSelected()); + const someSelected = table.rows.some(row => row.getIsSelected()); + const toggleAll = () => { + const next = { ...table.rowSelection }; + for (const row of table.rows) { + next[row.id] = !allSelected; + } + table.setRowSelection(next); + }; + const selectedIds = Object.entries(table.rowSelection) + .filter(([, selected]) => selected) + .map(([id]) => id); + const selectedCount = selectedIds.length; + const clearSelection = () => table.setRowSelection({}); + + const isFiltered = query !== '' || roleFilter !== null; + + return ( + <> + + + + + cycleSort(table.setSorting, 'name')} + xstyle={styles.nameColumn} + > + Name + + cycleSort(table.setSorting, 'role')} + > + Role + + cycleSort(table.setSorting, 'requested')} + > + Requested + + + Actions + + + + + {table.rows.length === 0 ? ( + +
+ + + {isFiltered ? 'No requests found' : 'No pending requests'} + + {isFiltered + ? 'No pending requests match your search.' + : 'Requests to join this organization will show up here.'} + + {isFiltered && onClearFilters ? ( + + + + ) : null} + +
+
+ ) : ( + table.rows.map(row => ( + + + +
+ + {row.original.imageUrl ? ( + + ) : null} + {initials(row.original.name)} + +
+ {row.original.name} + {row.original.email} +
+
+
+ + onChangeRole([row.original.id], role) : undefined} + /> + + {row.original.requestedLabel} + + {onAccept || onDecline ? ( +
+ {onDecline ? ( + + ) : null} + {onAccept ? ( + + ) : null} +
+ ) : null} +
+
+ )) + )} +
+
+ + {hasMembersPager(filtered.length) ? ( + setPagination(state => ({ ...state, pageIndex: page - 1 }))} + onPageSizeChange={pageSize => setPagination({ pageIndex: 0, pageSize })} + /> + ) : null} + + {selectedCount > 0 ?
: null} + + { + onChangeRole(selectedIds, role); + clearSelection(); + } + : undefined + } + onRemove={ + onDecline + ? () => { + onDecline(selectedIds); + clearSelection(); + } + : undefined + } + removeLabel='Remove selected requests' + onDismiss={clearSelection} + aria-label='Request bulk actions' + /> + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-role-control.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-role-control.view.tsx new file mode 100644 index 00000000000..ebc51994ea2 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-role-control.view.tsx @@ -0,0 +1,60 @@ +import type { ReactElement } from 'react'; + +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Menu } from '../../components/menu'; +import type { OrganizationRole } from './organization-profile-members-panel.types'; + +export interface OrganizationProfileRoleControlProps { + value: string; + roles: OrganizationRole[]; + onChangeRole?: (role: string) => void; +} + +export function OrganizationProfileRoleControl({ + value, + roles, + onChangeRole, +}: OrganizationProfileRoleControlProps): ReactElement { + const label = roles.find(role => role.value === value)?.label ?? value; + if (!onChangeRole) { + return ( + + ); + } + return ( + + + } + > + {label} + + + + {roles.map(role => ( + onChangeRole(role.value)} + > + {role.label} + + ))} + + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.styles.ts b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.styles.ts new file mode 100644 index 00000000000..9370e4f751a --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.styles.ts @@ -0,0 +1,12 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx new file mode 100644 index 00000000000..76e3ddb5260 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx @@ -0,0 +1,16 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Profile } from '../../components/profile'; +import { mergeStyleProps, themeProps } from '../../props'; +import { styles } from './organization-profile-security-panel.styles'; + +export type OrganizationProfileSecurityPanelViewProps = Record; + +export function OrganizationProfileSecurityPanelView(_props: OrganizationProfileSecurityPanelViewProps): ReactElement { + return ( +
+ Security +
+ ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts new file mode 100644 index 00000000000..03e912e16ab --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts @@ -0,0 +1,56 @@ +import type { IconName } from '../../icons/registry'; +import { applyOrder } from '../../utils/apply-order'; +import type { + CustomProfilePage, + OrganizationProfilePageId, + OrganizationProfilePages, +} from './organization-profile.types'; + +/** The built-in pages in the order the profile lists them before a consumer reorders anything. */ +export const ORGANIZATION_PROFILE_PAGE_IDS: readonly OrganizationProfilePageId[] = [ + 'general', + 'members', + 'security', + 'billing', + 'apiKeys', +]; + +export const ORGANIZATION_PROFILE_PAGE_ICONS: Record = { + general: 'cog-6-teeth', + members: 'users', + security: 'shield-check', + billing: 'credit-card', + apiKeys: 'code', +}; + +/** One row of the navigation: a built-in page by id, or a page of the consumer's own. */ +export type OrganizationProfileNavEntry = + | { id: OrganizationProfilePageId; custom?: undefined } + | { id: string; custom: CustomProfilePage }; + +/** The built-in pages this instance was given content for. `general` is always among them. */ +export function getAvailableOrganizationProfilePages(pages: OrganizationProfilePages): OrganizationProfilePageId[] { + return ORGANIZATION_PROFILE_PAGE_IDS.filter(id => pages[id] !== undefined); +} + +/** + * The navigation, in order: the built-ins the instance shows, then the consumer's pages, with + * `order` moving any of them by id (a custom page's id is its `path`). A custom page given a + * built-in's id REPLACES it, in its place — `applyOrder`'s rule, applied before the order is. + */ +export function resolveOrganizationProfilePages( + builtIn: readonly OrganizationProfilePageId[], + customPages: readonly CustomProfilePage[] = [], + order?: readonly string[], +): OrganizationProfileNavEntry[] { + const customById = new Map(customPages.map(page => [page.path, page])); + const builtInIds = new Set(builtIn); + const entries: OrganizationProfileNavEntry[] = [ + ...builtIn.map((id): OrganizationProfileNavEntry => { + const custom = customById.get(id); + return custom ? { id, custom } : { id }; + }), + ...customPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })), + ]; + return applyOrder(order, entries, entry => entry.id); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts new file mode 100644 index 00000000000..8ca7024875c --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts @@ -0,0 +1,67 @@ +export const organizationProfileMessages = { + /** Names the surface: its navigation landmark, and the dialog it opens in. */ + label: 'Organization', + pages: { + general: 'General', + members: 'Members', + security: 'Security', + billing: 'Billing', + apiKeys: 'API Keys', + }, + general: { + detailsTitle: 'Workspace details', + edit: 'Edit', + cancel: 'Cancel', + save: 'Save changes', + errors: { + generic: 'Something went wrong. Please try again.', + }, + logo: { + label: 'Logo', + description: 'Recommended size 1:1, up to 10MB.', + upload: 'Upload', + manage: 'Manage logo', + change: 'Change logo', + remove: 'Remove logo', + errors: { + accept: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + size: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + overflow: 'Only one file can be uploaded at a time.', + }, + }, + name: { + label: 'Name', + dialogTitle: 'Edit workspace name', + }, + slug: { + label: 'Workspace slug', + empty: 'Not set', + copy: 'Copy', + copied: 'Copied', + copyFailed: 'Couldn’t copy', + dialogTitle: 'Edit workspace slug', + dialogDescription: 'A unique identifier used in workspace URLs. Changing it may break existing links.', + }, + danger: { + title: 'Danger zone', + fieldLabel: 'Type “{phrase}” below to continue', + leave: { + label: 'Leave workspace', + description: 'You will lose access to this workspace and its applications.', + dialogTitle: 'Leave workspace', + dialogDescription: + 'Are you sure you want to leave this workspace? You will lose access to it and its applications.', + }, + delete: { + label: 'Delete workspace', + description: 'Permanently delete this workspace and all its data. This cannot be undone.', + dialogTitle: 'Delete workspace', + dialogDescription: { + one: 'Are you sure you want to delete {name}? This removes 1 member and permanently deletes all workspace data. This can’t be undone.', + other: + 'Are you sure you want to delete {name}? This removes {count} members and permanently deletes all workspace data. This can’t be undone.', + }, + }, + }, + }, +} as const; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts new file mode 100644 index 00000000000..45209c1d757 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts @@ -0,0 +1,19 @@ +import type { OrganizationProfileApiKeysPanelViewProps } from './organization-profile-api-keys-panel.view'; +import type { OrganizationProfileBillingPanelViewProps } from './organization-profile-billing-panel.view'; +import type { OrganizationProfileGeneralPanelViewProps } from './organization-profile-general-panel.view'; +import type { OrganizationProfileMembersPanelViewProps } from './organization-profile-members-panel.view'; +import type { OrganizationProfileSecurityPanelViewProps } from './organization-profile-security-panel.view'; + +export type { CustomProfileItem, CustomProfileLink, CustomProfilePage } from '../user-profile/user-profile.types'; + +/** A page the OrganizationProfile brings itself, named by the id its navigation knows it as. */ +export type OrganizationProfilePageId = 'general' | 'members' | 'security' | 'billing' | 'apiKeys'; + +/** The built-in pages an instance shows: `general` always, the rest as the environment allows. */ +export interface OrganizationProfilePages { + general: OrganizationProfileGeneralPanelViewProps; + members?: OrganizationProfileMembersPanelViewProps; + security?: OrganizationProfileSecurityPanelViewProps; + billing?: OrganizationProfileBillingPanelViewProps; + apiKeys?: OrganizationProfileApiKeysPanelViewProps; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx new file mode 100644 index 00000000000..4dd2a41961b --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -0,0 +1,133 @@ +import React from 'react'; + +import { Badge } from '../../components/badge'; +import { Icon } from '../../components/icon'; +import type { ProfileRootProps } from '../../components/profile'; +import { Profile } from '../../components/profile'; +import { useMessages } from '../../localization'; +import { + getAvailableOrganizationProfilePages, + ORGANIZATION_PROFILE_PAGE_ICONS, + resolveOrganizationProfilePages, +} from './organization-profile.layout'; +import type { + CustomProfilePage, + OrganizationProfilePageId, + OrganizationProfilePages, +} from './organization-profile.types'; +import { OrganizationProfileApiKeysPanelView } from './organization-profile-api-keys-panel.view'; +import { OrganizationProfileBillingPanelView } from './organization-profile-billing-panel.view'; +import { OrganizationProfileGeneralPanelView } from './organization-profile-general-panel.view'; +import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; +import { OrganizationProfileSecurityPanelView } from './organization-profile-security-panel.view'; + +export interface OrganizationProfileViewProps extends Omit { + /** Names the surface, and the dialog it opens in. Defaults to the `organizationProfile.label` message. */ + label?: string; + /** The open page: a built-in page's id, or a custom page's `path`. */ + activePage: OrganizationProfilePageId | (string & {}); + pages: OrganizationProfilePages; + /** Pages of the consumer's own, added to the navigation after the built-ins. */ + customPages?: readonly CustomProfilePage[]; + /** + * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids + * left out keep their default place behind the ones named. + */ + pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[]; + onPageChange: (page: OrganizationProfilePageId | (string & {})) => void; +} + +function BuiltInPage({ + id, + pages, +}: { + id: OrganizationProfilePageId; + pages: OrganizationProfilePages; +}): React.ReactElement | null { + switch (id) { + case 'general': + return ; + case 'members': + return pages.members ? : null; + case 'security': + return pages.security ? : null; + case 'billing': + return pages.billing ? : null; + case 'apiKeys': + return pages.apiKeys ? : null; + } +} + +/** + * The organization profile as a `Profile`: the built-in pages the instance has content for, the + * consumer's own pages after them, in the order asked for. An `activePage` the navigation does + * not list falls back to the first one, so a page turned off by the environment cannot leave the + * surface blank. + */ +export const OrganizationProfileView = React.forwardRef( + function OrganizationProfileView({ activePage, pages, customPages, pageOrder, onPageChange, label, ...rest }, ref) { + const m = useMessages('organizationProfile'); + const entries = resolveOrganizationProfilePages( + getAvailableOrganizationProfilePages(pages), + customPages, + pageOrder, + ); + const resolvedPage = entries.some(entry => entry.id === activePage) ? activePage : entries[0].id; + // Mirrors the count on the Members page's own Requests tab, so pending requests are visible from + // the navigation. + const pendingRequestCount = pages.members?.requests?.length ?? 0; + + return ( + + {label ?? m.label} + + {entries.map(entry => ( + + ) + } + badge={ + !entry.custom && entry.id === 'members' && pendingRequestCount > 0 ? ( + {pendingRequestCount} + ) : undefined + } + > + {entry.custom ? entry.custom.label : m.pages[entry.id]} + + ))} + + + {entries.map(entry => ( + + {entry.custom ? ( + entry.custom.content + ) : ( + + )} + + ))} + + + ); + }, +); diff --git a/packages/mosaic/src/icons/glyphs/user-2.tsx b/packages/mosaic/src/icons/glyphs/user-2.tsx new file mode 100644 index 00000000000..9a13a79fee4 --- /dev/null +++ b/packages/mosaic/src/icons/glyphs/user-2.tsx @@ -0,0 +1,18 @@ +import { glyph } from '../glyph'; + +export const User2 = glyph( + <> + + + , +); diff --git a/packages/mosaic/src/icons/glyphs/user.tsx b/packages/mosaic/src/icons/glyphs/user.tsx new file mode 100644 index 00000000000..63a5b94c78c --- /dev/null +++ b/packages/mosaic/src/icons/glyphs/user.tsx @@ -0,0 +1,18 @@ +import { glyph } from '../glyph'; + +export const User = glyph( + <> + + + , +); diff --git a/packages/mosaic/src/icons/registry.tsx b/packages/mosaic/src/icons/registry.tsx index f1a0f803a80..23844ab8c8c 100644 --- a/packages/mosaic/src/icons/registry.tsx +++ b/packages/mosaic/src/icons/registry.tsx @@ -81,6 +81,8 @@ import { Sidebar } from './glyphs/sidebar'; import { Spinner } from './glyphs/spinner'; import { Support } from './glyphs/support'; import { Trash } from './glyphs/trash'; +import { User } from './glyphs/user'; +import { User2 } from './glyphs/user-2'; import { UserCircle } from './glyphs/user-circle'; import { UserCirclePlus } from './glyphs/user-circle-plus'; import { Users } from './glyphs/users'; @@ -310,6 +312,8 @@ export const iconRegistry = { spinner: Spinner, support: Support, trash: Trash, + user: User, + 'user-2': User2, 'user-circle': UserCircle, 'user-circle-plus': UserCirclePlus, users: Users, diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 6c37b74b67b..4701105c461 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -1,3 +1,4 @@ +import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages'; import { reverificationMessages } from '../features/reverification/reverification.messages'; import { userButtonMessages } from '../features/user-button/user-button.messages'; import { userProfileMessages } from '../features/user-profile/user-profile.messages'; @@ -19,6 +20,7 @@ import { userProfilePasswordSectionMessages } from '../features/user-profile/use import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; export const mosaicMessages = { + organizationProfile: organizationProfileMessages, reverification: reverificationMessages, userButton: userButtonMessages, userProfile: userProfileMessages, diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 9e88acd2a53..79c575e2544 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -3,6 +3,13 @@ // StyleX rollup plugin can walk this graph and extract one static `styles.css`. export type { MosaicComponentProps, MosaicElementProps } from '../props'; +export { ActionBar } from '../components/action-bar'; +export type { + ActionBarCountProps, + ActionBarDismissProps, + ActionBarRootProps, + ActionBarSeparatorProps, +} from '../components/action-bar'; export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from '../components/avatar'; export { Badge } from '../components/badge'; @@ -112,6 +119,15 @@ export type { TableSelectCellProps, TableSort, } from '../components/table'; +export { Tabs } from '../components/tabs'; +export type { + TabsIndicatorProps, + TabsListProps, + TabsPanelProps, + TabsRootProps, + TabsTabProps, + TabsTriggerProps, +} from '../components/tabs'; export { Section } from '../components/section'; export type { SectionActionsProps, @@ -211,3 +227,4 @@ export type TypeScaleVarName = keyof typeof typeScaleVars; export { mergeStyleProps, themeProps } from '../props'; export { UserProfileMfaSectionView } from '../features/user-profile/user-profile-mfa-section.view'; export { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-setup.view'; +export { OrganizationProfileView } from '../features/organization-profile/organization-profile.view'; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index f4cadf172f1..61703b98a71 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -12,6 +12,18 @@ const docModules: Record> = { 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), }, + 'organization-profile': { + 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), + 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), + 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), + 'organization-profile-security-panel': dynamic(() => import('../stories/organization-profile-security-panel.mdx')), + 'organization-profile-billing-panel': dynamic(() => import('../stories/organization-profile-billing-panel.mdx')), + 'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')), + 'organization-profile-invitations-tab': dynamic( + () => import('../stories/organization-profile-invitations-tab.mdx'), + ), + 'organization-profile-requests-tab': dynamic(() => import('../stories/organization-profile-requests-tab.mdx')), + }, 'user-profile': { 'user-profile': dynamic(() => import('../stories/user-profile.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), @@ -47,6 +59,7 @@ const docModules: Record> = { destructive: dynamic(() => import('../stories/destructive.mdx')), }, components: { + 'action-bar': dynamic(() => import('../stories/action-bar.mdx')), avatar: dynamic(() => import('../stories/avatar.mdx')), badge: dynamic(() => import('../stories/badge.mdx')), banner: dynamic(() => import('../stories/banner.mdx')), @@ -73,6 +86,7 @@ const docModules: Record> = { section: dynamic(() => import('../stories/section.mdx')), select: dynamic(() => import('../stories/select.component.mdx')), table: dynamic(() => import('../stories/table.mdx')), + tabs: dynamic(() => import('../stories/tabs.mdx')), toast: dynamic(() => import('../stories/toast.mdx')), text: dynamic(() => import('../stories/text.mdx')), tooltip: dynamic(() => import('../stories/tooltip.component.mdx')), @@ -94,7 +108,6 @@ const docModules: Record> = { otp: dynamic(() => import('../stories/otp.mdx')), popover: dynamic(() => import('../stories/popover.mdx')), select: dynamic(() => import('../stories/select.mdx')), - tabs: dynamic(() => import('../stories/tabs.mdx')), tooltip: dynamic(() => import('../stories/tooltip.mdx')), }, styles: { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index eadd81c7f4c..02751b74f0e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,5 +1,6 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; +import { Default as ActionBarDefault, meta as actionBarMeta } from '../stories/action-bar.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Fallback as AvatarFallbackStory, @@ -124,6 +125,42 @@ import { } from '../stories/localization.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; +import { + Default as OrganizationProfileDefault, + meta as organizationProfileMeta, + Overlay as OrganizationProfileOverlay, +} from '../stories/organization-profile.stories'; +import { + Default as OrganizationProfileApiKeysPanelDefault, + meta as organizationProfileApiKeysPanelMeta, +} from '../stories/organization-profile-api-keys-panel.stories'; +import { + Default as OrganizationProfileBillingPanelDefault, + meta as organizationProfileBillingPanelMeta, +} from '../stories/organization-profile-billing-panel.stories'; +import { + Default as OrganizationProfileGeneralPanelDefault, + meta as organizationProfileGeneralPanelMeta, +} from '../stories/organization-profile-general-panel.stories'; +import { + Default as OrganizationProfileInvitationsTabDefault, + meta as organizationProfileInvitationsTabMeta, +} from '../stories/organization-profile-invitations-tab.stories'; +import { + Default as OrganizationProfileMembersPanelDefault, + EmptyInvitations as OrganizationProfileMembersPanelEmptyInvitations, + EmptyMembers as OrganizationProfileMembersPanelEmptyMembers, + EmptyRequests as OrganizationProfileMembersPanelEmptyRequests, + meta as organizationProfileMembersPanelMeta, +} from '../stories/organization-profile-members-panel.stories'; +import { + Default as OrganizationProfileRequestsTabDefault, + meta as organizationProfileRequestsTabMeta, +} from '../stories/organization-profile-requests-tab.stories'; +import { + Default as OrganizationProfileSecurityPanelDefault, + meta as organizationProfileSecurityPanelMeta, +} from '../stories/organization-profile-security-panel.stories'; import { Default as OtpComponentDefault, Disabled as OtpComponentDisabled, @@ -136,8 +173,8 @@ import { Disabled as PaginationDisabled, FirstLast as PaginationFirstLast, meta as paginationMeta, + MiddlePage as PaginationMiddlePage, Primary as PaginationPrimary, - Siblings as PaginationSiblings, SinglePage as PaginationSinglePage, } from '../stories/pagination.stories'; import { @@ -396,6 +433,11 @@ const comboboxModule: StoryModule = { Scrolling: ComboboxScrolling, }; +const actionBarModule: StoryModule = { + meta: actionBarMeta, + Default: ActionBarDefault, +}; + const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, @@ -443,7 +485,7 @@ const paginationModule: StoryModule = { meta: paginationMeta, Primary: PaginationPrimary, FirstLast: PaginationFirstLast, - Siblings: PaginationSiblings, + MiddlePage: PaginationMiddlePage, SinglePage: PaginationSinglePage, Disabled: PaginationDisabled, }; @@ -619,6 +661,42 @@ const userProfileApiKeysPanelModule: StoryModule = { Default: UserProfileApiKeysPanelDefault, Empty: UserProfileApiKeysPanelEmpty, }; +const organizationProfileModule: StoryModule = { + meta: organizationProfileMeta, + Default: OrganizationProfileDefault, + Overlay: OrganizationProfileOverlay, +}; +const organizationProfileGeneralPanelModule: StoryModule = { + meta: organizationProfileGeneralPanelMeta, + Default: OrganizationProfileGeneralPanelDefault, +}; +const organizationProfileMembersPanelModule: StoryModule = { + meta: organizationProfileMembersPanelMeta, + Default: OrganizationProfileMembersPanelDefault, + EmptyMembers: OrganizationProfileMembersPanelEmptyMembers, + EmptyInvitations: OrganizationProfileMembersPanelEmptyInvitations, + EmptyRequests: OrganizationProfileMembersPanelEmptyRequests, +}; +const organizationProfileSecurityPanelModule: StoryModule = { + meta: organizationProfileSecurityPanelMeta, + Default: OrganizationProfileSecurityPanelDefault, +}; +const organizationProfileBillingPanelModule: StoryModule = { + meta: organizationProfileBillingPanelMeta, + Default: OrganizationProfileBillingPanelDefault, +}; +const organizationProfileApiKeysPanelModule: StoryModule = { + meta: organizationProfileApiKeysPanelMeta, + Default: OrganizationProfileApiKeysPanelDefault, +}; +const organizationProfileInvitationsTabModule: StoryModule = { + meta: organizationProfileInvitationsTabMeta, + Default: OrganizationProfileInvitationsTabDefault, +}; +const organizationProfileRequestsTabModule: StoryModule = { + meta: organizationProfileRequestsTabMeta, + Default: OrganizationProfileRequestsTabDefault, +}; const userProfileModule: StoryModule = { meta: userProfileMeta, Default: UserProfileDefault, @@ -783,12 +861,24 @@ export const registry: StoryModule[] = [ userProfileEnterpriseAccountsSectionModule, userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, + // Organization Profile + organizationProfileModule, + // Organization Profile · Panels + organizationProfileGeneralPanelModule, + organizationProfileMembersPanelModule, + organizationProfileSecurityPanelModule, + organizationProfileBillingPanelModule, + organizationProfileApiKeysPanelModule, + // Organization Profile · Sections + organizationProfileInvitationsTabModule, + organizationProfileRequestsTabModule, // Reverification reverificationModule, // Blocks — flows assembled from components, wired by the caller's machine. confirmationModule, destructiveModule, // Components + actionBarModule, avatarModule, badgeModule, bannerModule, diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx new file mode 100644 index 00000000000..a1ae780716f --- /dev/null +++ b/packages/swingset/src/stories/action-bar.mdx @@ -0,0 +1,49 @@ +import * as ActionBarStories from './action-bar.stories'; + +# ActionBar + +A floating bar of actions for a current selection. It sits at the foot of its nearest positioned +ancestor — give that ancestor `position: relative` and the bar centers over its bottom — and +animates in when opened. While closed it is inert and click-through, so the surface +underneath stays usable. + +## Example + + + +## Usage + +```tsx + 0}> + {selectedCount} selected + + {/* Change role */} + + + + +``` + +## Parts + +| Part | Element | Description | +| --------------------- | -------- | -------------------------------------------------------------------------- | +| `ActionBar.Root` | `div` | The positioner + floating surface. Takes `open`; renders `role='toolbar'`. | +| `ActionBar.Count` | `div` | The leading count of what is selected. | +| `ActionBar.Separator` | `div` | A vertical divider between groups. | +| `ActionBar.Dismiss` | `button` | Clears the selection. A ghost icon button; labelled `Clear selection`. | + +## Styling + +`ActionBar.Root` reflects `data-open` (`true` / `false`) on its surface, so the entrance +transition and the closed (click-through) state are both selectable. The actions themselves are +whatever children you place between the parts. diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx new file mode 100644 index 00000000000..b099c00cbb3 --- /dev/null +++ b/packages/swingset/src/stories/action-bar.stories.tsx @@ -0,0 +1,80 @@ +import { ActionBar } from '@clerk/mosaic/components/action-bar'; +import { Button } from '@clerk/mosaic/components/button'; +import { Icon } from '@clerk/mosaic/components/icon'; +import { Menu } from '@clerk/mosaic/components/menu'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './action-bar.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'ActionBar', + status: 'wip', + layout: 'wide', + source: 'packages/mosaic/src/components/action-bar/action-bar.tsx', +}; + +/** + * The bar sits at the foot of its nearest positioned ancestor. The example gives that ancestor a + * height and `position: relative` so the bar floats over its bottom; toggle the selection to see + * it animate in and out. + */ +export function Default() { + const [count, setCount] = useState(3); + return ( +
+ + 0} + aria-label='Bulk actions' + > + {count} selected + + + + } + > + Change role + + + + + Admin + + + Member + + + + + + + setCount(0)} /> + +
+ ); +} diff --git a/packages/swingset/src/stories/fixtures/organization-profile.test.tsx b/packages/swingset/src/stories/fixtures/organization-profile.test.tsx new file mode 100644 index 00000000000..f6fe08bc0f2 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/organization-profile.test.tsx @@ -0,0 +1,32 @@ +import '@testing-library/jest-dom/vitest'; + +import { OrganizationProfileMembersPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel.view'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { useOrganizationMembersFixture } from './organization-profile'; + +function EmptyInvitationsExample() { + const props = useOrganizationMembersFixture({ invitations: [] }); + return ( + + + + ); +} + +describe('organization profile fixture', () => { + it('adds a pending invitation from the invitations empty state', async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByText('No pending invitations')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Invite member' })); + expect(await screen.findByText('new.member@clerk.dev')).toBeInTheDocument(); + expect(screen.queryByText('No pending invitations')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/swingset/src/stories/fixtures/organization-profile.ts b/packages/swingset/src/stories/fixtures/organization-profile.ts new file mode 100644 index 00000000000..a1f8aa6ed9a --- /dev/null +++ b/packages/swingset/src/stories/fixtures/organization-profile.ts @@ -0,0 +1,202 @@ +import type { OrganizationProfileViewProps } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; +import type { OrganizationProfileGeneralPanelViewProps } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel.view'; +import type { + OrganizationInvitation, + OrganizationMember, + OrganizationProfileMembersPanelViewProps, + OrganizationRequest, + OrganizationRole, +} from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel.view'; +import { useMemo, useRef, useState } from 'react'; + +const roles: OrganizationRole[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, +]; + +const seedNames: Array<[string, string]> = [ + ['Kyle Mac', 'kyle'], + ['Austin Calvelage', 'austin'], + ['Colin Sidoti', 'colin'], + ['Max Yinger', 'max'], + ['Preston Booth', 'preston'], + ['Richard Hendricks', 'richard'], + ['Nadia Khan', 'nadia'], + ['Tomas Reyes', 'tomas'], + ['Wei Chen', 'wei'], + ['Farah Osei', 'farah'], + ['Diego Alvarez', 'diego'], + ['Priya Nair', 'priya'], + ['Sam Whitfield', 'sam'], + ['Ingrid Larsson', 'ingrid'], + ['Omar Haddad', 'omar'], + ['Lena Fischer', 'lena'], + ['Yuki Tanaka', 'yuki'], + ['Steve Hayes', 'steve'], +]; + +// Padded past a page so the pagination affordance appears (it only shows when truly paginated). +const extraNames: Array<[string, string]> = Array.from({ length: 26 }, (_, i) => [ + `Member ${i + 19}`, + `member${i + 19}`, +]); + +const seedMembers: OrganizationMember[] = [...seedNames, ...extraNames].map(([name, handle], index) => ({ + id: `member-${index}`, + name, + email: `${handle}@clerk.dev`, + role: index === 2 ? 'admin' : 'member', + addedLabel: new Date(Date.UTC(2026, 4, 25 - index)).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + addedAt: Date.UTC(2026, 4, 25 - index), +})); + +/** The viewer's own row — "Preston Booth" — cannot be selected and cannot change its own role. */ +const currentUserId = 'member-4'; + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); +} + +const seedInvitations: OrganizationInvitation[] = [ + ['ksearle', 'member', Date.UTC(2026, 5, 14)], + ['rhendricks', 'member', Date.UTC(2026, 5, 14)], + ['dwyer', 'admin', Date.UTC(2026, 4, 4)], +].map(([handle, role, invitedAt], index) => ({ + id: `invitation-${index}`, + email: `${handle as string}@clerk.dev`, + role: role as string, + invitedLabel: formatDate(invitedAt as number), + invitedAt: invitedAt as number, +})); + +const seedRequests: OrganizationRequest[] = [ + ['Jared Dunn', 'jared', Date.UTC(2026, 4, 5)], + ['Nelson Bighetti', 'bighead', Date.UTC(2026, 4, 5)], +].map(([name, handle, requestedAt], index) => ({ + id: `request-${index}`, + name: name as string, + email: `${handle as string}@clerk.dev`, + role: 'member', + requestedLabel: formatDate(requestedAt as number), + requestedAt: requestedAt as number, +})); + +/** + * The Members page, backed by local state so changing roles, removing members, revoking + * invitations, and answering requests all do something. Shared by the whole-profile fixture and by + * the Members panel and its section stories, so each renders the same live surface. + */ +interface OrganizationMembersFixtureOptions { + members?: OrganizationMember[]; + invitations?: OrganizationInvitation[]; + requests?: OrganizationRequest[]; +} + +export function useOrganizationMembersFixture( + options: OrganizationMembersFixtureOptions = {}, +): OrganizationProfileMembersPanelViewProps { + const [members, setMembers] = useState(options.members ?? seedMembers); + const [invitations, setInvitations] = useState(options.invitations ?? seedInvitations); + const [requests, setRequests] = useState(options.requests ?? seedRequests); + const invitationSequence = useRef(0); + + return useMemo( + () => ({ + members, + invitations, + requests, + roles, + currentUserId, + onChangeRole: (memberIds, role) => + setMembers(current => current.map(member => (memberIds.includes(member.id) ? { ...member, role } : member))), + onRemoveMembers: memberIds => setMembers(current => current.filter(member => !memberIds.includes(member.id))), + onChangeInvitationRole: (invitationIds, role) => + setInvitations(current => + current.map(invitation => (invitationIds.includes(invitation.id) ? { ...invitation, role } : invitation)), + ), + onRevokeInvitation: invitationIds => + setInvitations(current => current.filter(invitation => !invitationIds.includes(invitation.id))), + onChangeRequestRole: (requestIds, role) => + setRequests(current => + current.map(request => (requestIds.includes(request.id) ? { ...request, role } : request)), + ), + onAcceptRequest: requestId => setRequests(current => current.filter(request => request.id !== requestId)), + onDeclineRequest: requestIds => + setRequests(current => current.filter(request => !requestIds.includes(request.id))), + onInvite: () => { + const sequence = invitationSequence.current++; + setInvitations(current => [ + ...current, + { + id: `invitation-created-${sequence}`, + email: sequence === 0 ? 'new.member@clerk.dev' : `new.member+${sequence}@clerk.dev`, + role: 'member', + invitedLabel: 'Just now', + invitedAt: Date.UTC(2026, 8, 21), + }, + ]); + }, + }), + [members, invitations, requests], + ); +} + +export function useOrganizationGeneralFixture(): OrganizationProfileGeneralPanelViewProps { + const [name, setName] = useState('Clerk'); + const [slug, setSlug] = useState('clerk-workspace'); + const [imageUrl, setImageUrl] = useState(); + const [hasImage, setHasImage] = useState(false); + + return useMemo( + () => ({ + name, + slug, + imageUrl, + hasImage, + membersCount: 20, + onLogoChange: (file: File) => { + setImageUrl(URL.createObjectURL(file)); + setHasImage(true); + return Promise.resolve(); + }, + onRemoveLogo: () => { + setImageUrl(undefined); + setHasImage(false); + return Promise.resolve(); + }, + onSubmitName: (value: string) => { + setName(value); + return Promise.resolve(); + }, + onSubmitSlug: (value: string) => { + setSlug(value); + return Promise.resolve(); + }, + onCopySlug: (value: string) => navigator.clipboard.writeText(value), + onLeave: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }), + [hasImage, imageUrl, name, slug], + ); +} + +/** + * Every page of the organization profile. The Members page is a working prototype; the other pages + * are placeholders. + */ +export function useOrganizationProfileFixture() { + const [activePage, setActivePage] = useState('members'); + const general = useOrganizationGeneralFixture(); + const members = useOrganizationMembersFixture(); + + const pages = useMemo( + () => ({ general, members, security: {}, billing: {}, apiKeys: {} }), + [general, members], + ); + + return { activePage, setActivePage, pages }; +} diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx new file mode 100644 index 00000000000..7b5826e4670 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx @@ -0,0 +1,10 @@ +import * as OrganizationProfileApiKeysPanelStories from './organization-profile-api-keys-panel.stories'; + +# OrganizationProfileApiKeysPanel + +The API Keys page of the organization profile — a placeholder while the surface is scaffolded. + + diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx new file mode 100644 index 00000000000..febafb6b8e6 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx @@ -0,0 +1,19 @@ +import { OrganizationProfileApiKeysPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-api-keys-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-api-keys-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs design', + title: 'OrganizationProfileApiKeysPanel', + label: 'API Keys panel', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx', +}; + +export function Default() { + return ; +} diff --git a/packages/swingset/src/stories/organization-profile-billing-panel.mdx b/packages/swingset/src/stories/organization-profile-billing-panel.mdx new file mode 100644 index 00000000000..a581fe13329 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-billing-panel.mdx @@ -0,0 +1,10 @@ +import * as OrganizationProfileBillingPanelStories from './organization-profile-billing-panel.stories'; + +# OrganizationProfileBillingPanel + +The Billing page of the organization profile — a placeholder while the surface is scaffolded. + + diff --git a/packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx new file mode 100644 index 00000000000..75944012722 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx @@ -0,0 +1,19 @@ +import { OrganizationProfileBillingPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-billing-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-billing-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs design', + title: 'OrganizationProfileBillingPanel', + label: 'Billing panel', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx', +}; + +export function Default() { + return ; +} diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx new file mode 100644 index 00000000000..f4ce296fafa --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -0,0 +1,10 @@ +import * as OrganizationProfileGeneralPanelStories from './organization-profile-general-panel.stories'; + +# OrganizationProfileGeneralPanel + +The General page of the organization profile, with editable workspace details and destructive workspace actions. + + diff --git a/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx new file mode 100644 index 00000000000..4f58cefef9d --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx @@ -0,0 +1,23 @@ +import { OrganizationProfileGeneralPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationGeneralFixture } from './fixtures/organization-profile'; + +export { default as __source } from './organization-profile-general-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'OrganizationProfileGeneralPanel', + label: 'General panel', + layout: 'wide', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx', +}; + +export function Default() { + const props = useOrganizationGeneralFixture(); + return ; +} diff --git a/packages/swingset/src/stories/organization-profile-invitations-tab.mdx b/packages/swingset/src/stories/organization-profile-invitations-tab.mdx new file mode 100644 index 00000000000..4aa4d7eb670 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-invitations-tab.mdx @@ -0,0 +1,18 @@ +import * as OrganizationProfileInvitationsTabStories from './organization-profile-invitations-tab.stories'; + +# OrganizationProfileInvitationsTab + +Pending invitations as a sortable `Table`: the invitee, their role, when they were invited, and a +per-row revoke. Shown on the Members page's Invitations tab; it takes the shared search and role +filter as props. + + diff --git a/packages/swingset/src/stories/organization-profile-invitations-tab.stories.tsx b/packages/swingset/src/stories/organization-profile-invitations-tab.stories.tsx new file mode 100644 index 00000000000..6253516a350 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-invitations-tab.stories.tsx @@ -0,0 +1,33 @@ +import { OrganizationProfileInvitationsTabView } from '@clerk/mosaic/features/organization-profile/organization-profile-invitations-tab.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationMembersFixture } from './fixtures/organization-profile'; + +export { default as __source } from './organization-profile-invitations-tab.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'OrganizationProfileInvitationsTab', + label: 'Invitations', + navigation: { category: 'Sections' }, + layout: 'wide', + source: 'packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx', +}; + +export function Default() { + const { invitations = [], roles, onChangeInvitationRole, onRevokeInvitation } = useOrganizationMembersFixture(); + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx new file mode 100644 index 00000000000..eeb6abc4305 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -0,0 +1,45 @@ +import * as OrganizationProfileMembersPanelStories from './organization-profile-members-panel.stories'; + +# OrganizationProfileMembersPanel + +The Members page: three tabs — Members, Invitations, and Requests — over a shared toolbar (search, a +role filter, and an invite button). Members are a sortable, paginated `Table` with inline role +changes, a per-row remove that confirms first, and a bulk `ActionBar` for changing roles or removing +a selection. The viewer's own row cannot be selected and their role cannot be changed. The +Invitations and Requests tabs are documented as their own sections. + + + +## Empty states + +Each page state opens the relevant tab directly. The Members and Invitations states retain their +invite actions, while Requests explains where future requests will appear. + + + + + + diff --git a/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx new file mode 100644 index 00000000000..a93dc36b11f --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx @@ -0,0 +1,53 @@ +import { OrganizationProfileMembersPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationMembersFixture } from './fixtures/organization-profile'; + +export { default as __source } from './organization-profile-members-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'OrganizationProfileMembersPanel', + label: 'Members panel', + navigation: { category: 'Panels' }, + layout: 'wide', + source: 'packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx', +}; + +export function Default() { + const props = useOrganizationMembersFixture(); + return ; +} + +export function EmptyMembers() { + const props = useOrganizationMembersFixture({ members: [] }); + return ( + + ); +} + +export function EmptyInvitations() { + const props = useOrganizationMembersFixture({ invitations: [] }); + return ( + + ); +} + +export function EmptyRequests() { + const props = useOrganizationMembersFixture({ requests: [] }); + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile-requests-tab.mdx b/packages/swingset/src/stories/organization-profile-requests-tab.mdx new file mode 100644 index 00000000000..f9ff2a3dea3 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-requests-tab.mdx @@ -0,0 +1,18 @@ +import * as OrganizationProfileRequestsTabStories from './organization-profile-requests-tab.stories'; + +# OrganizationProfileRequestsTab + +Membership requests as a sortable `Table`: the requester, the role they asked for, when they +requested, and per-row accept and decline actions. Shown on the Members page's Requests tab; it +takes the shared search and role filter as props. + + diff --git a/packages/swingset/src/stories/organization-profile-requests-tab.stories.tsx b/packages/swingset/src/stories/organization-profile-requests-tab.stories.tsx new file mode 100644 index 00000000000..eab5f20810a --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-requests-tab.stories.tsx @@ -0,0 +1,40 @@ +import { OrganizationProfileRequestsTabView } from '@clerk/mosaic/features/organization-profile/organization-profile-requests-tab.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationMembersFixture } from './fixtures/organization-profile'; + +export { default as __source } from './organization-profile-requests-tab.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'OrganizationProfileRequestsTab', + label: 'Requests', + navigation: { category: 'Sections' }, + layout: 'wide', + source: 'packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx', +}; + +export function Default() { + const { + requests = [], + roles, + onChangeRequestRole, + onAcceptRequest, + onDeclineRequest, + } = useOrganizationMembersFixture(); + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile-security-panel.mdx b/packages/swingset/src/stories/organization-profile-security-panel.mdx new file mode 100644 index 00000000000..9128803a212 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-security-panel.mdx @@ -0,0 +1,10 @@ +import * as OrganizationProfileSecurityPanelStories from './organization-profile-security-panel.stories'; + +# OrganizationProfileSecurityPanel + +The Security page of the organization profile — a placeholder while the surface is scaffolded. + + diff --git a/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx new file mode 100644 index 00000000000..e20d9fcfb5e --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx @@ -0,0 +1,19 @@ +import { OrganizationProfileSecurityPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-security-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-security-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs design', + title: 'OrganizationProfileSecurityPanel', + label: 'Security panel', + navigation: { category: 'Panels' }, + source: 'packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx', +}; + +export function Default() { + return ; +} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx new file mode 100644 index 00000000000..cf252a823a7 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -0,0 +1,41 @@ +import * as OrganizationProfileStories from './organization-profile.stories'; + +# OrganizationProfile + +The organization profile surface: a `Profile` whose navigation lists the General, Members, Security, +Billing, and API Keys pages, then any pages of the consumer's own, in the order asked for. Below it +is a page's content — `elevation='flush'` — unframed, flush with the page, scrolling with it. +Rendered over the page instead, it names the dialog, carries its dismiss, and fills its height. + +The Members page holds three tabs — Members, Invitations, and Requests — over a shared toolbar +(search, a role filter, and an invite button). Members are a sortable, paginated `Table` with inline +role changes, a per-row remove that confirms first, and a bulk `ActionBar` for changing roles or +removing a selection; invitations can be revoked and requests accepted or declined. + + + +## As an overlay + +The same view inside a `profile` dialog, opened from a trigger on the page. The dialog positions +and animates the popup; the profile paints it, names it, and carries the dismiss. + + diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx new file mode 100644 index 00000000000..7035be4753a --- /dev/null +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -0,0 +1,56 @@ +import { Button } from '@clerk/mosaic/components/button'; +import { Dialog } from '@clerk/mosaic/components/dialog'; +import { OrganizationProfileView } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationProfileFixture } from './fixtures/organization-profile'; + +export { default as __source } from './organization-profile.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + status: 'wip', + substatus: 'needs wire-up', + title: 'OrganizationProfile', + label: 'Organization profile', + layout: 'wide', + source: 'packages/mosaic/src/features/organization-profile/organization-profile.view.tsx', +}; + +/** + * The profile as a page's content — `elevation='flush'`, the way a `Card` chooses its elevation: + * unframed, flush with its host, scrolling with the page. The Members page is interactive; the + * other pages are works in progress. + */ +export function Default() { + const { activePage, setActivePage, pages } = useOrganizationProfileFixture(); + return ( + + ); +} + +/** + * The same profile as an overlay: opened from a trigger on the page into a `profile` dialog, which + * positions it while the profile paints itself, names the dialog, and carries its dismiss. + */ +export function Overlay() { + const { activePage, setActivePage, pages } = useOrganizationProfileFixture(); + return ( + + }>Manage organization + + + + + ); +} diff --git a/packages/swingset/src/stories/pagination.mdx b/packages/swingset/src/stories/pagination.mdx index 3edae7ddb0f..790b443b80c 100644 --- a/packages/swingset/src/stories/pagination.mdx +++ b/packages/swingset/src/stories/pagination.mdx @@ -2,7 +2,9 @@ import * as PaginationStories from './pagination.stories'; # Pagination -Page navigation for a paged list. Renders a labelled `nav` with previous and next controls, the page numbers around the current one, and a results-per-page control. Pages are 1-based and the component is controlled: `onChange` receives the page the user asked for, and `onPageSizeChange` receives the size chosen from the results-per-page `Select`. +Page navigation for a paged list. It shows the visible item range, a results-per-page control, +first and previous controls, the current page count, and next and last controls. Pages are 1-based +and controlled. ## Example @@ -20,17 +22,28 @@ Page navigation for a paged list. Renders a labelled `nav` with previous and nex { name: 'totalItems', type: 'number', default: '—', description: 'Total number of items across every page.' }, { name: 'pageSize', type: 'number', default: '—', description: 'Items per page. The page count is derived from this and `totalItems`.' }, { name: 'onChange', type: '(page: number) => void', default: '—', description: 'Called with the page the user navigated to.' }, - { name: 'pageSizeOptions', type: 'number[]', default: '[10, 25, 50, 100]', description: 'Sizes offered by the results-per-page select. The current `pageSize` is always included.' }, + { name: 'pageSizeOptions', type: 'number[]', default: '[10, 15, 20, 100]', description: 'Sizes offered by the results-per-page select. The current `pageSize` is always included.' }, { name: 'onPageSizeChange', type: '(pageSize: number) => void', default: '—', description: 'Called with the page size the user chose.' }, { name: 'step', type: 'number', default: '1', description: 'How many pages the previous and next controls move.' }, - { name: 'siblingCount', type: 'number', default: '1', description: 'Pages shown on each side of the current one.' }, { name: 'label', type: 'string', default: "'Pagination'", description: 'Accessible name of the `nav`.' }, + { + name: 'rangeLabel', + type: 'string', + default: "'{start}–{end} of {total}'", + description: 'The item range, with `{start}`, `{end}` and `{total}` filled in.', + }, { name: 'pageSizeLabel', type: 'string', default: "'Results per page'", description: 'Text of the label beside the page size select.', }, + { + name: 'pageSizeLabelCompact', + type: 'string', + default: "'Show'", + description: 'Stands in for `pageSizeLabel` when the pagination is too narrow to carry it.', + }, { name: 'firstPageLabel', type: 'string', default: "'First page'", description: 'Accessible name of the first page control.' }, { name: 'previousPageLabel', @@ -62,10 +75,10 @@ Page navigation for a paged list. Renders a labelled `nav` with previous and nex storyModule={PaginationStories} /> -### Sibling count +### Middle page diff --git a/packages/swingset/src/stories/pagination.stories.tsx b/packages/swingset/src/stories/pagination.stories.tsx index b8a2755d379..dd370ecf088 100644 --- a/packages/swingset/src/stories/pagination.stories.tsx +++ b/packages/swingset/src/stories/pagination.stories.tsx @@ -13,7 +13,6 @@ export { default as __source } from './pagination.stories?raw'; export const meta: StoryMeta = { group: 'Components', status: 'wip', - substatus: 'needs design', title: 'Pagination', source: 'packages/mosaic/src/components/pagination/pagination.tsx', styles: { @@ -22,7 +21,7 @@ export const meta: StoryMeta = { disabled: { true: {}, false: {} }, }, _defaultVariants: { - hasFirstLast: false, + hasFirstLast: true, disabled: false, }, }, @@ -66,7 +65,7 @@ export function FirstLast(props: Record) { ); } -export function Siblings(props: Record) { +export function MiddlePage(props: Record) { const [page, setPage] = useState(10); return ( ) { totalItems={200} pageSize={10} onChange={setPage} - siblingCount={2} /> ); } diff --git a/packages/swingset/src/stories/tabs.mdx b/packages/swingset/src/stories/tabs.mdx index 16e9e8464ba..9493356cce5 100644 --- a/packages/swingset/src/stories/tabs.mdx +++ b/packages/swingset/src/stories/tabs.mdx @@ -2,17 +2,12 @@ import * as TabsStories from './tabs.stories'; # Tabs -A set of layered sections where one panel is shown at a time, selected from a row of tabs, -from `@clerk/headless`. It is a **headless** primitive: it supplies behavior, active-tab -state, roving-tabindex keyboard navigation, an optional position-tracking indicator, and -ARIA wiring, but ships **no styles** — you bring your own CSS by targeting the `data-*` -attributes each part emits. +A set of layered sections where one panel is shown at a time. Tabs include keyboard navigation, +ARIA wiring, and an indicator that slides between selections. ## Example -The demo below is intentionally unstyled — it renders the raw primitive so you can see its -behavior and ARIA wiring. Click a tab (or focus one and use the arrow keys) to swap the -visible panel. +Click a tab, or focus one and use the arrow keys, to swap the visible panel. @@ -59,8 +54,8 @@ const [value, setValue] = useState('tab1'); | `Tabs.Panel` | `
` | `role="tabpanel"`; hidden via the `hidden` attribute when inactive | | `Tabs.Indicator` | `` | Decorative element that tracks the active tab's position and size | -All rendered parts accept a `render` prop for polymorphic rendering and standard HTML -attributes for their default element. `Tabs.Root` renders no element of its own. +All rendered parts accept a `render` prop for polymorphic rendering, standard HTML attributes, +and `xstyle`. `Tabs.Root` renders no element of its own. ## Props @@ -91,7 +86,7 @@ attributes for their default element. `Tabs.Root` renders no element of its own. `Tabs.List` and `Tabs.Indicator` take no additional props beyond standard HTML attributes for their default element. -## Styling +## State Each part emits `data-*` attributes you can target with any CSS solution: @@ -105,8 +100,8 @@ Each part emits `data-*` attributes you can target with any CSS solution: | `data-starting-style` | Panel | Enter-animation first frame (force-mounted panels only) | | `data-ending-style` | Panel | Exit-animation frame (force-mounted panels only) | -`Tabs.Indicator` is absolutely positioned and exposes the active tab's geometry as CSS -variables (give `Tabs.List` `position: relative`): +`Tabs.Indicator` is positioned against `Tabs.List` and exposes the active tab's geometry as CSS +variables: | Variable | Description | | ----------------- | --------------------------------------- | @@ -116,13 +111,4 @@ variables (give `Tabs.List` `position: relative`): | `--cl-tab-height` | Active tab height | A force-mounted `Tabs.Panel` also exposes `--cl-tab-transition-direction` (`1` when moving -to a later tab, `-1` to an earlier one) for directional slide animations. - -```css -.cl-tabs-indicator { - position: absolute; - left: var(--cl-tab-left); - width: var(--cl-tab-width); - transition: left 200ms ease, width 200ms ease; -} -``` +to a later tab, `-1` to an earlier one) for directional panel transitions. diff --git a/packages/swingset/src/stories/tabs.stories.tsx b/packages/swingset/src/stories/tabs.stories.tsx index 63b6e7c7b9b..b82eeff0eb4 100644 --- a/packages/swingset/src/stories/tabs.stories.tsx +++ b/packages/swingset/src/stories/tabs.stories.tsx @@ -1,19 +1,12 @@ -import { Tabs } from '@clerk/headless/tabs'; +import { Tabs } from '@clerk/mosaic/components/tabs'; import type { StoryMeta } from '@/lib/types'; -// Headless primitives ship no styles. This single demo renders the primitive raw — -// unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// and ARIA wiring via the `data-*` attributes each part emits, with zero appearance. -// It is embedded once into the overview via `` in the MDX (the one thing prose -// can't convey: that selecting a tab swaps the panel). There is no interactive knob canvas -// for headless primitives. - export const meta: StoryMeta = { - group: 'Primitives', + group: 'Components', status: 'stable', title: 'Tabs', - source: 'packages/headless/src/primitives/tabs/index.ts', + source: 'packages/mosaic/src/components/tabs/tabs.tsx', }; export function Default() { @@ -23,6 +16,7 @@ export function Default() { Account Security Notifications + Manage your account details. Update your password and sessions. From 6c246709ca3f79012beb903403ee2e5af9d8e975 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Mon, 21 Sep 2026 12:03:38 -0600 Subject: [PATCH 2/6] ci(mosaic): raise the styles.css bundlewatch budget to 14KB The organization profile's styles push dist/styles.css to 12.62KB gzip, over the 12KB budget. main was already at 12287 of the 12288-byte budget, so the budget has had no headroom for some time; 14KB restores a margin. Co-Authored-By: Claude Opus 5 (1M context) --- packages/mosaic/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/bundlewatch.config.json b/packages/mosaic/bundlewatch.config.json index b3f91a18a8b..c4b17c00eb7 100644 --- a/packages/mosaic/bundlewatch.config.json +++ b/packages/mosaic/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ { "path": "./dist/index.js", "maxSize": "125KB" }, - { "path": "./dist/styles.css", "maxSize": "12KB" } + { "path": "./dist/styles.css", "maxSize": "14KB" } ] } From dff326fba3ec57fee6a432ee29f1e2327df26c52 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Mon, 21 Sep 2026 13:24:28 -0600 Subject: [PATCH 3/6] fix(mosaic): address review findings on the organization profile Scope bulk-action selection to rows the current filters still show, so a row hidden by a search is no longer acted on, with a regression test. Keep the focus-visible ring on a non-fading Table viewport, which is focusable via tabIndex but lost the ring the scroll-area viewport supplies. Make a closed ActionBar click-through rather than relying on `inert` alone, and read the Security, Billing, and API Keys page titles from the message catalog instead of hardcoding them. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/action-bar/action-bar.styles.ts | 5 ++++- packages/mosaic/src/components/table/table.tsx | 7 ++++++- ...rganization-profile-members-panel.view.test.tsx | 14 ++++++++++++++ .../organization-profile-api-keys-panel.view.tsx | 5 ++++- .../organization-profile-billing-panel.view.tsx | 5 ++++- .../organization-profile-invitations-tab.view.tsx | 5 ++++- .../organization-profile-members-panel.view.tsx | 5 ++++- .../organization-profile-requests-tab.view.tsx | 5 ++++- .../organization-profile-security-panel.view.tsx | 5 ++++- packages/swingset/src/stories/tabs.mdx | 4 ++-- 10 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts index 22f12b80d74..a96738d8a0f 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.styles.ts +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -45,7 +45,10 @@ export const styles = stylex.create({ default: 1, ':is([data-open="false"])': 0, }, - pointerEvents: 'auto', + pointerEvents: { + default: 'auto', + ':is([data-open="false"])': 'none', + }, transform: { default: 'translateY(0)', ':is([data-open="false"])': 'translateY(0.25rem)', diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index 928dfd40c0d..fc910895a6c 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; import { Button } from '../button'; import type { CheckboxProps } from '../checkbox'; @@ -42,7 +43,11 @@ const Root = React.forwardRef(function MosaicTable tabIndex={0} {...mergeStyleProps( themeProps('table-viewport'), - stylex.props(reset.base, fade ? scrollAreaViewport('auto', 'inline') : styles.plainViewport, styles.viewport), + stylex.props( + reset.base, + ...(fade ? scrollAreaViewport('auto', 'inline') : [styles.plainViewport, focusOutline.visible]), + styles.viewport, + ), )} >
{ expect(props.onRemoveMembers).toHaveBeenCalledWith(['m1', 'm2']); }); + it('drops rows the search hides from the bulk selection', async () => { + const user = userEvent.setup(); + const props = renderPanel(); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle Mac' })); + await user.click(screen.getByRole('checkbox', { name: 'Select Colin Sidoti' })); + // Narrowing the list to Colin leaves Kyle selected but hidden; the bulk bar must forget him. + await user.type(screen.getByRole('textbox', { name: 'Search members' }), 'colin'); + const toolbar = screen.getByRole('toolbar', { name: 'Member bulk actions' }); + expect(within(toolbar).getByText('1 selected')).toBeInTheDocument(); + await user.click(within(toolbar).getByRole('button', { name: /Change role/ })); + await user.click(screen.getByRole('menuitem', { name: 'Admin' })); + expect(props.onChangeRole).toHaveBeenCalledWith(['m2'], 'admin'); + }); + it('changes roles and removes selected invitations in bulk', async () => { const user = userEvent.setup(); const props = renderPanel(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx index 18097b1ccf3..85921b86daa 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx @@ -2,15 +2,18 @@ import * as stylex from '@stylexjs/stylex'; import type { ReactElement } from 'react'; import { Profile } from '../../components/profile'; +import { useMessages } from '../../localization'; import { mergeStyleProps, themeProps } from '../../props'; import { styles } from './organization-profile-api-keys-panel.styles'; export type OrganizationProfileApiKeysPanelViewProps = Record; export function OrganizationProfileApiKeysPanelView(_props: OrganizationProfileApiKeysPanelViewProps): ReactElement { + const m = useMessages('organizationProfile'); + return (
- API Keys + {m.pages.apiKeys}
); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx index e782f22ba71..91b65f8bfe4 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx @@ -2,15 +2,18 @@ import * as stylex from '@stylexjs/stylex'; import type { ReactElement } from 'react'; import { Profile } from '../../components/profile'; +import { useMessages } from '../../localization'; import { mergeStyleProps, themeProps } from '../../props'; import { styles } from './organization-profile-billing-panel.styles'; export type OrganizationProfileBillingPanelViewProps = Record; export function OrganizationProfileBillingPanelView(_props: OrganizationProfileBillingPanelViewProps): ReactElement { + const m = useMessages('organizationProfile'); + return (
- Billing + {m.pages.billing}
); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx index f74938b6edb..f4541c0f338 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx @@ -132,8 +132,11 @@ export function OrganizationProfileInvitationsTabView({ } table.setRowSelection(next); }; + // Selection survives paging, but not filtering: a row the current filters hide is not something + // a bulk action should reach. + const visibleIds = React.useMemo(() => new Set(filtered.map(invitation => invitation.id)), [filtered]); const selectedIds = Object.entries(table.rowSelection) - .filter(([, selected]) => selected) + .filter(([id, selected]) => selected && visibleIds.has(id)) .map(([id]) => id); const selectedCount = selectedIds.length; const clearSelection = () => table.setRowSelection({}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx index 2e2fe295419..32358819b34 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -206,8 +206,11 @@ export function OrganizationProfileMembersPanelView({ table.setRowSelection(next); }; + // Selection survives paging, but not filtering: a row the current filters hide is not something + // a bulk action should reach. + const visibleIds = React.useMemo(() => new Set(filteredMembers.map(member => member.id)), [filteredMembers]); const selectedIds = Object.entries(table.rowSelection) - .filter(([id, selected]) => selected && id !== currentUserId) + .filter(([id, selected]) => selected && id !== currentUserId && visibleIds.has(id)) .map(([id]) => id); const selectedCount = selectedIds.length; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx index c3eed4e209e..f9b2dee1f7f 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx @@ -138,8 +138,11 @@ export function OrganizationProfileRequestsTabView({ } table.setRowSelection(next); }; + // Selection survives paging, but not filtering: a row the current filters hide is not something + // a bulk action should reach. + const visibleIds = React.useMemo(() => new Set(filtered.map(request => request.id)), [filtered]); const selectedIds = Object.entries(table.rowSelection) - .filter(([, selected]) => selected) + .filter(([id, selected]) => selected && visibleIds.has(id)) .map(([id]) => id); const selectedCount = selectedIds.length; const clearSelection = () => table.setRowSelection({}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx index 76e3ddb5260..fb575da5769 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx @@ -2,15 +2,18 @@ import * as stylex from '@stylexjs/stylex'; import type { ReactElement } from 'react'; import { Profile } from '../../components/profile'; +import { useMessages } from '../../localization'; import { mergeStyleProps, themeProps } from '../../props'; import { styles } from './organization-profile-security-panel.styles'; export type OrganizationProfileSecurityPanelViewProps = Record; export function OrganizationProfileSecurityPanelView(_props: OrganizationProfileSecurityPanelViewProps): ReactElement { + const m = useMessages('organizationProfile'); + return (
- Security + {m.pages.security}
); } diff --git a/packages/swingset/src/stories/tabs.mdx b/packages/swingset/src/stories/tabs.mdx index 9493356cce5..22d19b613a4 100644 --- a/packages/swingset/src/stories/tabs.mdx +++ b/packages/swingset/src/stories/tabs.mdx @@ -83,8 +83,8 @@ and `xstyle`. `Tabs.Root` renders no element of its own. | value | string | — (required) | Must match a `Tabs.Tab` value | | shouldForceMount | boolean | — | Keep the panel in layout flow when inactive (enables enter/exit transitions) | -`Tabs.List` and `Tabs.Indicator` take no additional props beyond standard HTML -attributes for their default element. +`Tabs.List` and `Tabs.Indicator` take no additional props beyond `xstyle` and standard +HTML attributes for their default element. ## State From ca3cd45ab9a830e985dea13485bd04304fb66fb8 Mon Sep 17 00:00:00 2001 From: Preston Booth Date: Mon, 21 Sep 2026 13:43:08 -0600 Subject: [PATCH 4/6] fix(mosaic): drop the bulk-bar inset on the embedded organization profile The members lists reserve room at the foot of the scrolling column so the last rows clear a bulk bar pinned to the profile's frame. The embedded profile fixes that bar to the window instead, over the page rather than the column, so the inset only added dead space under the table. Co-Authored-By: Claude Opus 5 (1M context) --- ...zation-profile-members-panel.view.test.tsx | 24 +++++++++++++++++++ ...anization-profile-invitations-tab.view.tsx | 2 +- ...ganization-profile-members-panel.styles.ts | 3 ++- ...rganization-profile-members-panel.view.tsx | 10 +++++++- ...organization-profile-requests-tab.view.tsx | 2 +- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx index db6a566ab5b..226583ca330 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx @@ -290,6 +290,30 @@ describe('OrganizationProfileMembersPanelView', () => { expect(props.onChangeRole).toHaveBeenCalledWith(['m2'], 'admin'); }); + it.each([ + { mode: 'frame', position: 'relative', inset: true }, + { mode: 'viewport', position: 'static', inset: false }, + ])('$mode: insets the rows for the bulk bar = $inset', async ({ position, inset }) => { + const user = userEvent.setup(); + const content = document.createElement('div'); + content.className = 'cl-profile-content'; + content.style.position = position; + document.body.appendChild(content); + const props: OrganizationProfileMembersPanelViewProps = { members, roles, onChangeRole: vi.fn() }; + render( + + + , + { container: content }, + ); + + const panel = document.querySelector('.cl-organization-profile-members-panel') as HTMLElement; + const insetClass = stylex.props(styles.rootWithBulkBar).className as string; + expect(panel).not.toHaveClass(insetClass); + await user.click(screen.getByRole('checkbox', { name: 'Select Kyle Mac' })); + expect(panel.className.includes(insetClass)).toBe(inset); + }); + it('changes roles and removes selected invitations in bulk', async () => { const user = userEvent.setup(); const props = renderPanel(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx index f4541c0f338..b94c9b2f731 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx @@ -303,7 +303,7 @@ export function OrganizationProfileInvitationsTabView({ /> ) : null} - {selectedCount > 0 ?
: null} + {selectedCount > 0 && overlay?.mode !== 'viewport' ?
: null} 0 && styles.rootWithBulkBar), + stylex.props( + styles.root, + activeTab === 'members' && selectedCount > 0 && needsBulkBarInset && styles.rootWithBulkBar, + ), )} > Members diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx index f9b2dee1f7f..77c98f287eb 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx @@ -289,7 +289,7 @@ export function OrganizationProfileRequestsTabView({ /> ) : null} - {selectedCount > 0 ?
: null} + {selectedCount > 0 && overlay?.mode !== 'viewport' ?
: null} Date: Tue, 22 Sep 2026 07:45:21 -0600 Subject: [PATCH 5/6] refactor(mosaic): separate organization profile prerequisites --- .changeset/organization-profile-scaffold.md | 4 +- .../components/pagination/pagination.test.tsx | 12 +++++ .../src/components/pagination/pagination.tsx | 5 +- .../src/components/table/table.styles.ts | 8 ---- .../mosaic/src/components/table/table.tsx | 25 ++-------- ...anization-profile-invitations-tab.view.tsx | 2 +- ...rganization-profile-members-panel.view.tsx | 2 +- ...organization-profile-requests-tab.view.tsx | 2 +- .../swingset/src/components/DocsViewer.tsx | 3 +- packages/swingset/src/lib/registry.ts | 7 +++ packages/swingset/src/stories/pagination.mdx | 1 + .../swingset/src/stories/tabs.component.mdx | 48 +++++++++++++++++++ .../src/stories/tabs.component.stories.tsx | 28 +++++++++++ packages/swingset/src/stories/tabs.mdx | 38 ++++++++++----- .../swingset/src/stories/tabs.stories.tsx | 14 ++++-- 15 files changed, 146 insertions(+), 53 deletions(-) create mode 100644 packages/swingset/src/stories/tabs.component.mdx create mode 100644 packages/swingset/src/stories/tabs.component.stories.tsx diff --git a/.changeset/organization-profile-scaffold.md b/.changeset/organization-profile-scaffold.md index eb372a75819..ed7b13bcea5 100644 --- a/.changeset/organization-profile-scaffold.md +++ b/.changeset/organization-profile-scaffold.md @@ -4,6 +4,4 @@ Add an initial `OrganizationProfileView`: an organization profile surface built on the `Profile` shell, with a sidebar of General, Members, Security, Billing, and API Keys pages. The General page provides workspace detail editing and guarded leave and delete actions. The Members page is a first pass at a responsive members table with avatars, sortable columns, row selection, bulk actions, and empty and filtered-no-results states for members, invitations, and requests. New `organizationProfile` localization keys ship with it. -Also add styled `Tabs` and `ActionBar` components. Tabs provide an animated selection indicator, and ActionBar provides the floating bulk-action layout used by organization lists. - -`Pagination` is reworked into the compact table-footer control used by the members table: it now shows the visible item range, the results-per-page control, first/previous/next/last controls, and the current page over the total, instead of a row of page-number buttons. `hasFirstLast` defaults to `true`, the default `pageSizeOptions` are now `[10, 15, 20, 100]`, and the `siblingCount` prop is removed because there are no longer page numbers to window. New `rangeLabel` and `pageSizeLabelCompact` props make the remaining visible strings translatable. +Also add `ActionBar`, the floating bulk-action layout used by organization lists. diff --git a/packages/mosaic/src/components/pagination/pagination.test.tsx b/packages/mosaic/src/components/pagination/pagination.test.tsx index a3e04e07ca1..06f6bc94ce1 100644 --- a/packages/mosaic/src/components/pagination/pagination.test.tsx +++ b/packages/mosaic/src/components/pagination/pagination.test.tsx @@ -47,6 +47,18 @@ describe('Mosaic Pagination', () => { expect(screen.getByRole('navigation', { name: 'Invoices' })).toBeInTheDocument(); }); + it('retains the sibling count styling contract', () => { + render( + , + ); + expect(screen.getByRole('navigation')).toHaveAttribute('data-sibling-count', '2'); + }); + it('moves with the first, previous, next, and last controls', async () => { const onChange = vi.fn(); render( diff --git a/packages/mosaic/src/components/pagination/pagination.tsx b/packages/mosaic/src/components/pagination/pagination.tsx index f4b7a94a779..b383cf94bb0 100644 --- a/packages/mosaic/src/components/pagination/pagination.tsx +++ b/packages/mosaic/src/components/pagination/pagination.tsx @@ -20,6 +20,7 @@ export interface PaginationProps extends Omit, 'onChan onPageSizeChange?: (pageSize: number) => void; hasFirstLast?: boolean; step?: number; + siblingCount?: number; disabled?: boolean; label?: string; /** The item range, with `{start}`, `{end}` and `{total}` filled in. */ @@ -57,6 +58,7 @@ export const Pagination = React.forwardRef(functio onPageSizeChange, hasFirstLast = true, step = 1, + siblingCount = 1, disabled = false, label = 'Pagination', rangeLabel = '{start}–{end} of {total}', @@ -73,6 +75,7 @@ export const Pagination = React.forwardRef(functio ) { const itemsPerPage = atLeast(pageSize, 1); const pageStep = atLeast(step, 1); + const siblings = atLeast(siblingCount, 0); const itemCount = atLeast(totalItems, 0); const pageCount = Math.max(1, Math.ceil(itemCount / itemsPerPage)); const current = Math.min(atLeast(page, 1), pageCount); @@ -93,7 +96,7 @@ export const Pagination = React.forwardRef(functio ref={ref} aria-label={label} {...mergeStyleProps( - themeProps('pagination', { disabled, hasFirstLast }), + themeProps('pagination', { disabled, hasFirstLast, siblingCount: siblings }), stylex.props(reset.base, styles.root, xstyle), rest, )} diff --git a/packages/mosaic/src/components/table/table.styles.ts b/packages/mosaic/src/components/table/table.styles.ts index fda6b01cdc0..db4483a3174 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -18,14 +18,6 @@ export const styles = stylex.create({ viewport: { overscrollBehaviorX: 'none', }, - // The viewport without the scroll-area's edge fade: it still scrolls horizontally when the - // columns overflow, but shows no left/right gradient. For a table whose columns are meant to fit. - plainViewport: { - minWidth: 0, - overflowX: 'auto', - overflowY: 'hidden', - overscrollBehaviorX: 'contain', - }, table: { borderCollapse: 'collapse', borderSpacing: 0, diff --git a/packages/mosaic/src/components/table/table.tsx b/packages/mosaic/src/components/table/table.tsx index fc910895a6c..61f23b1c05e 100644 --- a/packages/mosaic/src/components/table/table.tsx +++ b/packages/mosaic/src/components/table/table.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; -import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; import { Button } from '../button'; import type { CheckboxProps } from '../checkbox'; @@ -21,21 +20,9 @@ export type TableAlign = keyof typeof aligns; export type TableSort = 'ascending' | 'descending' | 'none'; -export type TableProps = MosaicElementProps<'table'> & { - /** - * Whether a left/right gradient fades the edges while the table scrolls horizontally. On by - * default; the table still scrolls when off, it just shows no fade. Turn it off for a table - * whose columns are meant to fit, so an edge gradient never shows when there is nothing to scroll. - * - * @default true - */ - fade?: boolean; -}; - -const Root = React.forwardRef(function MosaicTable( - { xstyle, fade = true, ...rest }, - ref, -) { +export type TableProps = MosaicElementProps<'table'>; + +const Root = React.forwardRef(function MosaicTable({ xstyle, ...rest }, ref) { return (
(function MosaicTable tabIndex={0} {...mergeStyleProps( themeProps('table-viewport'), - stylex.props( - reset.base, - ...(fade ? scrollAreaViewport('auto', 'inline') : [styles.plainViewport, focusOutline.visible]), - styles.viewport, - ), + stylex.props(reset.base, scrollAreaViewport('auto', 'inline'), styles.viewport), )} >
- + - + - + > = { section: dynamic(() => import('../stories/section.mdx')), select: dynamic(() => import('../stories/select.component.mdx')), table: dynamic(() => import('../stories/table.mdx')), - tabs: dynamic(() => import('../stories/tabs.mdx')), + tabs: dynamic(() => import('../stories/tabs.component.mdx')), toast: dynamic(() => import('../stories/toast.mdx')), text: dynamic(() => import('../stories/text.mdx')), tooltip: dynamic(() => import('../stories/tooltip.component.mdx')), @@ -108,6 +108,7 @@ const docModules: Record> = { otp: dynamic(() => import('../stories/otp.mdx')), popover: dynamic(() => import('../stories/popover.mdx')), select: dynamic(() => import('../stories/select.mdx')), + tabs: dynamic(() => import('../stories/tabs.mdx')), tooltip: dynamic(() => import('../stories/tooltip.mdx')), }, styles: { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 02751b74f0e..85c6f79b2c9 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -255,6 +255,7 @@ import { Selection as TableSelection, Sorting as TableSorting, } from '../stories/table.stories'; +import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { Colors as TextColors, @@ -653,6 +654,11 @@ const tableModule: StoryModule = { Overflow: TableOverflow, }; +const tabsComponentModule: StoryModule = { + meta: tabsComponentMeta, + Default: TabsComponentDefault, +}; + // Planned but not yet implemented; the entry reserves its sidebar slot with a todo dot. const toastModule: StoryModule = { meta: toastMeta }; @@ -906,6 +912,7 @@ export const registry: StoryModule[] = [ sectionModule, selectComponentModule, tableModule, + tabsComponentModule, textModule, toastModule, tooltipComponentModule, diff --git a/packages/swingset/src/stories/pagination.mdx b/packages/swingset/src/stories/pagination.mdx index 790b443b80c..394a1a8a6a0 100644 --- a/packages/swingset/src/stories/pagination.mdx +++ b/packages/swingset/src/stories/pagination.mdx @@ -25,6 +25,7 @@ and controlled. { name: 'pageSizeOptions', type: 'number[]', default: '[10, 15, 20, 100]', description: 'Sizes offered by the results-per-page select. The current `pageSize` is always included.' }, { name: 'onPageSizeChange', type: '(pageSize: number) => void', default: '—', description: 'Called with the page size the user chose.' }, { name: 'step', type: 'number', default: '1', description: 'How many pages the previous and next controls move.' }, + { name: 'siblingCount', type: 'number', default: '1', description: 'Retained for compatibility and reflected as `data-sibling-count`.' }, { name: 'label', type: 'string', default: "'Pagination'", description: 'Accessible name of the `nav`.' }, { name: 'rangeLabel', diff --git a/packages/swingset/src/stories/tabs.component.mdx b/packages/swingset/src/stories/tabs.component.mdx new file mode 100644 index 00000000000..3fc5b56cc2b --- /dev/null +++ b/packages/swingset/src/stories/tabs.component.mdx @@ -0,0 +1,48 @@ +import * as TabsStories from './tabs.component.stories'; + +# Tabs + +A set of layered sections where one panel is shown at a time. Tabs include keyboard navigation, +ARIA wiring, and an indicator that slides between selections. + +## Example + +Click a tab, or focus one and use the arrow keys, to swap the visible panel. + + + +## Usage + +```tsx +import { Tabs } from '@clerk/mosaic/components/tabs'; + + + + Account + Security + + + Account content + Security content +; +``` + +## Parts + +| Part | Element | Description | +| ---------------- | ---------- | ----------------------------------------------------------------- | +| `Tabs.Root` | none | Provides active-value state, orientation, and activation mode | +| `Tabs.List` | `
` | `role="tablist"` container with roving-tabindex navigation | +| `Tabs.Tab` | `