diff --git a/.changeset/organization-profile-scaffold.md b/.changeset/organization-profile-scaffold.md new file mode 100644 index 00000000000..2957d2a669f --- /dev/null +++ b/.changeset/organization-profile-scaffold.md @@ -0,0 +1,5 @@ +--- +'@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. 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" } ] } 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..a96738d8a0f --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts @@ -0,0 +1,82 @@ +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: { + default: 'auto', + ':is([data-open="false"])': 'none', + }, + 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..7e420719505 --- /dev/null +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -0,0 +1,148 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { useRender } from '../../primitives/utils'; +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..06f6bc94ce1 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,39 @@ 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(); + it('retains the sibling count styling contract', () => { render( , ); - await userEvent.click(screen.getByRole('button', { name: '2' })); - expect(onChange).toHaveBeenCalledWith(2); + expect(screen.getByRole('navigation')).toHaveAttribute('data-sibling-count', '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 +117,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 +139,6 @@ describe('Mosaic Pagination', () => { page={5} totalItems={100} pageSize={10} - hasFirstLast disabled />, ); @@ -214,18 +149,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); + 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 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(); - }); - - 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 +197,6 @@ describe('Mosaic Pagination', () => { page={2} totalItems={100} pageSize={10} - hasFirstLast />, ); const mirror = stylex.props(rtl.mirror).className ?? ''; @@ -287,120 +205,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..b383cf94bb0 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'> { @@ -25,7 +23,11 @@ export interface PaginationProps extends Omit, 'onChan 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 +38,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 +56,14 @@ 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', @@ -76,9 +76,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 +89,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 47a39eb77d5..c6bd0138eba 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..db4483a3174 100644 --- a/packages/mosaic/src/components/table/table.styles.ts +++ b/packages/mosaic/src/components/table/table.styles.ts @@ -10,6 +10,9 @@ export const styles = stylex.create({ borderWidth: '1px', overflow: 'hidden', backgroundColor: colorVars['--cl-color-background'], + containerType: 'inline-size', + maxWidth: '100%', + minWidth: 0, width: '100%', }, viewport: { diff --git a/packages/mosaic/src/components/tabs/index.ts b/packages/mosaic/src/components/tabs/index.ts new file mode 100644 index 00000000000..7eca1d23e1b --- /dev/null +++ b/packages/mosaic/src/components/tabs/index.ts @@ -0,0 +1,9 @@ +export { Tabs } from './tabs'; +export type { + TabsIndicatorProps, + TabsListProps, + TabsPanelProps, + TabsRootProps, + TabsTabProps, + TabsTriggerProps, +} from './tabs'; diff --git a/packages/mosaic/src/components/tabs/tabs.styles.ts b/packages/mosaic/src/components/tabs/tabs.styles.ts new file mode 100644 index 00000000000..1121e5af5e1 --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.styles.ts @@ -0,0 +1,50 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars, space, typeScaleVars } from '../../tokens.stylex'; + +const reduceMotion = '@media (prefers-reduced-motion: reduce)' as const; + +export const styles = stylex.create({ + list: { + gap: space['5'], + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + position: 'relative', + borderBottomColor: colorVars['--cl-color-border-subtle'], + borderBottomStyle: 'solid', + borderBottomWidth: '1px', + }, + tab: { + borderStyle: 'none', + gap: space['2'], + paddingBlock: space['2.5'], + alignItems: 'center', + backgroundColor: 'transparent', + color: { + default: colorVars['--cl-color-foreground-secondary'], + ':where([data-selected])': colorVars['--cl-color-foreground'], + '@media (hover: hover)': { + default: null, + ':hover:not([data-selected])': colorVars['--cl-color-foreground'], + }, + }, + cursor: 'pointer', + display: 'inline-flex', + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + indicator: { + backgroundColor: colorVars['--cl-color-foreground'], + pointerEvents: 'none', + position: 'absolute', + transitionDuration: { + default: durationVars['--cl-duration-base'], + [reduceMotion]: durationVars['--cl-duration-instant'], + }, + transitionProperty: 'left, width', + transitionTimingFunction: easingVars['--cl-ease-enter'], + bottom: '-1px', + height: '2px', + }, +}); diff --git a/packages/mosaic/src/components/tabs/tabs.test.tsx b/packages/mosaic/src/components/tabs/tabs.test.tsx new file mode 100644 index 00000000000..89581d9579c --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Tabs } from './tabs'; + +describe('Mosaic Tabs', () => { + 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..f5f01ccfb76 --- /dev/null +++ b/packages/mosaic/src/components/tabs/tabs.tsx @@ -0,0 +1,80 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { + TabsIndicatorProps as HeadlessTabsIndicatorProps, + TabsListProps as HeadlessTabsListProps, + TabsPanelProps as HeadlessTabsPanelProps, + TabsProps as HeadlessTabsRootProps, + TabsTabProps as HeadlessTabsTabProps, + TabsTriggerProps as HeadlessTabsTriggerProps, +} from '../../primitives/tabs'; +import { Tabs as Primitive } from '../../primitives/tabs'; +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..e4f40a87d78 --- /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('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: '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..226583ca330 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx @@ -0,0 +1,372 @@ +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('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.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(); + 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..85921b86daa --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-api-keys-panel.view.tsx @@ -0,0 +1,19 @@ +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 ( +
+ {m.pages.apiKeys} +
+ ); +} 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..91b65f8bfe4 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-billing-panel.view.tsx @@ -0,0 +1,19 @@ +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 ( +
+ {m.pages.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..329f4617c31 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx @@ -0,0 +1,646 @@ +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 type { FileRejection } from '../../primitives/file-upload'; +import { FileUpload } from '../../primitives/file-upload'; +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..8a48456cb54 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invitations-tab.view.tsx @@ -0,0 +1,334 @@ +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 type { OnChangeFn, SortingState } from '../../primitives/hooks'; +import { useDataTable } from '../../primitives/hooks'; +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); + }; + // 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(([id, selected]) => selected && visibleIds.has(id)) + .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 && overlay?.mode !== 'viewport' ?
: 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..3e4f44fe61f --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.styles.ts @@ -0,0 +1,142 @@ +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 a bulk bar pinned to the frame — only while it is + // showing, and not when the bar is fixed to the window instead. + 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..337c9dcefb9 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.view.tsx @@ -0,0 +1,533 @@ +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 type { OnChangeFn, SortingState } from '../../primitives/hooks'; +import { useDataTable } from '../../primitives/hooks'; +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); + }; + + // 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 && visibleIds.has(id)) + .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; + + // A bar pinned to the frame overlaps the foot of the scrolling column, so the rows need room to + // clear it. The embedded profile's bar is fixed to the window instead, over the page rather than + // the column, so the same inset would only add dead space under the table. + const needsBulkBarInset = overlay?.mode !== 'viewport'; + + return ( +
0 && needsBulkBarInset && 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..adefc63ddc1 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-requests-tab.view.tsx @@ -0,0 +1,320 @@ +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 { OnChangeFn, SortingState } from '../../primitives/hooks'; +import { useDataTable } from '../../primitives/hooks'; +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); + }; + // 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(([id, selected]) => selected && visibleIds.has(id)) + .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 && overlay?.mode !== 'viewport' ?
: 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..fb575da5769 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-security-panel.view.tsx @@ -0,0 +1,19 @@ +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 ( +
+ {m.pages.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..5942922cbea --- /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: 'building', + 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..b69e61839b4 --- /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: '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..883e29c5d91 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.component.mdx')), toast: dynamic(() => import('../stories/toast.mdx')), text: dynamic(() => import('../stories/text.mdx')), tooltip: dynamic(() => import('../stories/tooltip.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index eadd81c7f4c..85c6f79b2c9 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 { @@ -218,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, @@ -396,6 +434,11 @@ const comboboxModule: StoryModule = { Scrolling: ComboboxScrolling, }; +const actionBarModule: StoryModule = { + meta: actionBarMeta, + Default: ActionBarDefault, +}; + const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, @@ -443,7 +486,7 @@ const paginationModule: StoryModule = { meta: paginationMeta, Primary: PaginationPrimary, FirstLast: PaginationFirstLast, - Siblings: PaginationSiblings, + MiddlePage: PaginationMiddlePage, SinglePage: PaginationSinglePage, Disabled: PaginationDisabled, }; @@ -611,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 }; @@ -619,6 +667,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 +867,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, @@ -816,6 +912,7 @@ export const registry: StoryModule[] = [ sectionModule, selectComponentModule, tableModule, + tabsComponentModule, textModule, toastModule, tooltipComponentModule, 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..394a1a8a6a0 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,29 @@ 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: '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', + 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 +76,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.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` | `