Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/tidy-tables-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/mosaic/src/components/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export type {
TableHeaderProps,
TableProps,
TableRowProps,
TableSearchProps,
TableSelectAllCellProps,
TableSelectCellProps,
TableSort,
TableToolbarProps,
} from './table';
9 changes: 9 additions & 0 deletions packages/mosaic/src/components/table/table.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import * as stylex from '@stylexjs/stylex';
import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
toolbar: {
gap: space['4'],
alignItems: 'center',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
search: { maxWidth: '100%', width: '17rem' },
noWrap: { whiteSpace: 'nowrap' },
shell: {
borderColor: colorVars['--cl-color-border'],
borderRadius: radiusVars['--cl-radius-xl'],
Expand Down
53 changes: 53 additions & 0 deletions packages/mosaic/src/components/table/table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,40 @@ function renderTable(props: Partial<React.ComponentProps<typeof Table.Root>> = {
}

describe('Mosaic Table', () => {
it('reports search changes and returns focus to the input when cleared', async () => {
const user = userEvent.setup();
const ref = React.createRef<HTMLInputElement>();
const onValueChange = vi.fn();
function Example() {
const [value, setValue] = React.useState('');
return (
<Table.Toolbar>
<Table.Search
ref={ref}
label='Search members'
clearLabel='Clear member search'
value={value}
onValueChange={next => {
onValueChange(next);
setValue(next);
}}
/>
</Table.Toolbar>
);
}
render(<Example />);
const input = screen.getByRole('searchbox', { name: 'Search members' });
expect(ref.current).toBe(input);
expect(screen.queryByRole('button', { name: 'Clear member search' })).not.toBeInTheDocument();
await user.type(input, 'Ada');
expect(onValueChange).toHaveBeenLastCalledWith('Ada');
await user.click(screen.getByRole('button', { name: 'Clear member search' }));
expect(onValueChange).toHaveBeenLastCalledWith('');
expect(input).toHaveValue('');
expect(input).toHaveFocus();
expect(screen.queryByRole('button', { name: 'Clear member search' })).not.toBeInTheDocument();
});

it('renders a table inside a scrolling shell', () => {
renderTable();
const table = screen.getByRole('table');
Expand All @@ -40,6 +74,25 @@ describe('Mosaic Table', () => {
expect(table.parentElement?.parentElement).toHaveClass('cl-table-shell');
});

it('opts cells into no-wrap styling without changing neighboring cells', () => {
render(
<Table.Root>
<Table.Body>
<Table.Row>
<Table.Cell noWrap>September 23, 2026</Table.Cell>
<Table.Cell>A longer description can wrap</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>,
);
const date = screen.getByRole('cell', { name: 'September 23, 2026' });
const description = screen.getByRole('cell', { name: 'A longer description can wrap' });
expect(date).toHaveAttribute('data-no-wrap', '');
expect(date).toHaveClass(stylex.props(styles.noWrap).className ?? '');
expect(description).not.toHaveAttribute('data-no-wrap');
expect(description).not.toHaveClass(stylex.props(styles.noWrap).className ?? '');
});

it('lets the keyboard reach the scrolling viewport', () => {
renderTable();
expect(screen.getByRole('table').parentElement).toHaveAttribute('tabindex', '0');
Expand Down
80 changes: 76 additions & 4 deletions packages/mosaic/src/components/table/table.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMergeRefs } from '@floating-ui/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

Expand All @@ -9,6 +10,8 @@ import { Button } from '../button';
import type { CheckboxProps } from '../checkbox';
import { Checkbox } from '../checkbox';
import { Icon } from '../icon';
import type { InputProps } from '../input';
import { InputGroup } from '../input-group';
import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area';
import { aligns, styles } from './table.styles';

Expand All @@ -22,6 +25,74 @@ export type TableSort = 'ascending' | 'descending' | 'none';

export type TableProps = MosaicElementProps<'table'>;

export type TableToolbarProps = MosaicComponentProps<'div'>;

const Toolbar = React.forwardRef<HTMLDivElement, TableToolbarProps>(function MosaicTableToolbar(
{ render, xstyle, ...rest },
ref,
) {
return useRender({
defaultTagName: 'div',
render,
ref,
props: mergeStyleProps(themeProps('table-toolbar'), stylex.props(reset.base, styles.toolbar, xstyle), rest),
});
});

export interface TableSearchProps extends Omit<
InputProps,
'value' | 'defaultValue' | 'onChange' | 'type' | 'children' | 'size' | 'variant'
> {
label: string;
clearLabel: string;
value: string;
onValueChange: (value: string) => void;
}

const Search = React.forwardRef<HTMLInputElement, TableSearchProps>(function MosaicTableSearch(
{ label, clearLabel, value, onValueChange, disabled, readOnly, ...rest },
ref,
) {
const inputRef = React.useRef<HTMLInputElement>(null);
const mergedRef = useMergeRefs([ref, inputRef]);
return (
<InputGroup.Root
disabled={disabled}
{...mergeStyleProps(themeProps('table-search'), stylex.props(styles.search))}
>
<InputGroup.Start>
<Icon name='magnifying-glass' />
</InputGroup.Start>
<InputGroup.Input
ref={mergedRef}
autoComplete='off'
aria-label={label}
placeholder={label}
{...rest}
type='search'
value={value}
readOnly={readOnly}
onChange={event => onValueChange(event.currentTarget.value)}
/>
{value ? (
<InputGroup.End>
<Button
type='button'
aria-label={clearLabel}
disabled={disabled || readOnly}
onClick={() => {
onValueChange('');
inputRef.current?.focus();
}}
>
<Icon name='x' />
</Button>
</InputGroup.End>
) : null}
</InputGroup.Root>
);
});

const Root = React.forwardRef<HTMLTableElement, TableProps>(function MosaicTable({ xstyle, ...rest }, ref) {
return (
<div {...mergeStyleProps(themeProps('table-shell'), stylex.props(reset.base, scrollAreaRoot, styles.shell))}>
Expand Down Expand Up @@ -147,19 +218,20 @@ const HeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>(

export interface TableCellProps extends Omit<MosaicComponentProps<'td'>, 'align'> {
align?: TableAlign;
noWrap?: boolean;
}

const Cell = React.forwardRef<HTMLTableCellElement, TableCellProps>(function MosaicTableCell(
{ align = 'start', render, xstyle, ...rest },
{ align = 'start', noWrap = false, render, xstyle, ...rest },
ref,
) {
return useRender({
defaultTagName: 'td',
render,
ref,
props: mergeStyleProps(
themeProps('table-cell', { align }),
stylex.props(reset.base, styles.cell, aligns[align], xstyle),
themeProps('table-cell', { align, noWrap }),
stylex.props(reset.base, styles.cell, aligns[align], noWrap && styles.noWrap, xstyle),
rest,
),
});
Expand Down Expand Up @@ -227,4 +299,4 @@ const Empty = React.forwardRef<HTMLTableCellElement, TableEmptyProps>(function M
);
});

export const Table = { Root, Header, Body, Row, HeaderCell, Cell, SelectAllCell, SelectCell, Empty };
export const Table = { Root, Toolbar, Search, Header, Body, Row, HeaderCell, Cell, SelectAllCell, SelectCell, Empty };

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import { ActionMenu } from '../../components/action-menu';
import { Avatar } from '../../components/avatar';
import { Button } from '../../components/button';
import { EmptyState } from '../../components/empty-state';
import { Icon } from '../../components/icon';
import { InputGroup } from '../../components/input-group';
import { Item } from '../../components/item';
import { Pagination } from '../../components/pagination';
import { panelStyles } from '../../components/profile';
Expand All @@ -18,7 +16,6 @@ import { useListRemovalFocus } from '../../hooks/useListRemovalFocus';
import { fill, useMessages } from '../../localization';
import { useDataTable } from '../../primitives/hooks';
import { mergeStyleProps, themeProps } from '../../props';
import { styles } from './invitations-table-tab.styles';
import type {
InvitationsTableSort,
InvitationsTableTabViewProps,
Expand Down Expand Up @@ -107,37 +104,14 @@ export function InvitationsTableTabView({
return (
<>
<div {...mergeStyleProps(themeProps('invitations-table-tab'), stylex.props(panelStyles.root))}>
<div {...stylex.props(styles.toolbar)}>
<InputGroup.Root
size='md'
xstyle={styles.search}
>
<InputGroup.Start>
<Icon name='magnifying-glass' />
</InputGroup.Start>
<InputGroup.Input
ref={searchInput}
type='search'
autoComplete='off'
aria-label={m.search}
placeholder={m.search}
value={table.globalFilter}
onChange={event => table.setGlobalFilter(event.currentTarget.value)}
/>
{table.globalFilter ? (
<InputGroup.End>
<Button
aria-label={m.clearSearch}
onClick={() => {
table.setGlobalFilter('');
searchInput.current?.focus();
}}
>
<Icon name='x' />
</Button>
</InputGroup.End>
) : null}
</InputGroup.Root>
<Table.Toolbar>
<Table.Search
ref={searchInput}
label={m.search}
clearLabel={m.clearSearch}
value={table.globalFilter}
onValueChange={table.setGlobalFilter}
/>
{onInvite ? (
<Button
ref={inviteButton}
Expand All @@ -146,7 +120,7 @@ export function InvitationsTableTabView({
{m.invite}
</Button>
) : null}
</div>
</Table.Toolbar>
<Table.Root
aria-label={m.title}
aria-busy={isLoading || isFetching}
Expand Down Expand Up @@ -223,7 +197,7 @@ export function InvitationsTableTabView({
</Item.Content>
</Item.Root>
</Table.Cell>
<Table.Cell xstyle={styles.dateCell}>{row.original.invitedAtLabel}</Table.Cell>
<Table.Cell noWrap>{row.original.invitedAtLabel}</Table.Cell>
<Table.Cell>{row.original.roleLabel}</Table.Cell>
{onRevoke ? (
<Table.Cell align='end'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,5 @@ import * as stylex from '@stylexjs/stylex';
import { space } from '../../tokens.stylex';

export const styles = stylex.create({
toolbar: {
gap: space['4'],
alignItems: 'center',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
search: { maxWidth: '100%', width: '17rem' },
name: { gap: space['2'], alignItems: 'center', display: 'flex' },
dateCell: { whiteSpace: 'nowrap' },
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ 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 { Item } from '../../components/item';
import { Pagination } from '../../components/pagination';
import { panelStyles } from '../../components/profile';
Expand Down Expand Up @@ -116,37 +114,14 @@ export function MembersTableTabView({
return (
<>
<div {...mergeStyleProps(themeProps('members-table-tab'), stylex.props(panelStyles.root))}>
<div {...stylex.props(styles.toolbar)}>
<InputGroup.Root
size='md'
xstyle={styles.search}
>
<InputGroup.Start>
<Icon name='magnifying-glass' />
</InputGroup.Start>
<InputGroup.Input
ref={searchInput}
type='search'
autoComplete='off'
aria-label={m.search}
placeholder={m.search}
value={table.globalFilter}
onChange={event => table.setGlobalFilter(event.currentTarget.value)}
/>
{table.globalFilter ? (
<InputGroup.End>
<Button
aria-label={m.clearSearch}
onClick={() => {
table.setGlobalFilter('');
searchInput.current?.focus();
}}
>
<Icon name='x' />
</Button>
</InputGroup.End>
) : null}
</InputGroup.Root>
<Table.Toolbar>
<Table.Search
ref={searchInput}
label={m.search}
clearLabel={m.clearSearch}
value={table.globalFilter}
onValueChange={table.setGlobalFilter}
/>
{onInvite ? (
<Button
ref={inviteButton}
Expand All @@ -155,7 +130,7 @@ export function MembersTableTabView({
{m.invite}
</Button>
) : null}
</div>
</Table.Toolbar>
<Table.Root
aria-label={m.title}
aria-busy={isLoading || isFetching}
Expand Down Expand Up @@ -245,7 +220,7 @@ export function MembersTableTabView({
</Item.Content>
</Item.Root>
</Table.Cell>
<Table.Cell xstyle={styles.dateCell}>{member.joinedAtLabel}</Table.Cell>
<Table.Cell noWrap>{member.joinedAtLabel}</Table.Cell>
<Table.Cell>
{onChangeRole && !member.isDeprovisioned ? (
<Select.Root
Expand Down
Loading
Loading