Skip to content
Open
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/mosaic-combobox-clear-and-trigger-interactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
25 changes: 25 additions & 0 deletions packages/mosaic/src/components/combobox/combobox.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
space,
typeScaleVars,
} from '../../tokens.stylex';
import { inputGroupScope } from '../input-group/input-group.markers.stylex';

const popupRadius = radiusVars['--cl-radius-xl'];
const inset = space['1.5'];
Expand Down Expand Up @@ -122,3 +123,27 @@ export const styles = stylex.create({
width: space['4'],
},
});

export const trigger = stylex.create({
base: {
backgroundColor: 'transparent',
color: {
default: colorVars['--cl-color-input-placeholder'],
'@media (hover: hover)': {
default: null,
[stylex.when.ancestor(':hover', inputGroupScope)]: colorVars['--cl-color-foreground'],
},
},
},
clear: {
color: {
default: colorVars['--cl-color-input-placeholder'],
'@media (hover: hover)': {
default: null,
':not([data-disabled]):hover': colorVars['--cl-color-foreground'],
},
},
marginInlineEnd: `calc(-1 * ${space['2']})`,
width: space['5.5'],
},
});
79 changes: 79 additions & 0 deletions packages/mosaic/src/components/combobox/combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ describe('Mosaic Combobox', () => {
expect(screen.getByRole('option', { name: 'Apple' }).closest('.cl-combobox-viewport')).toBe(viewport);
});

it('opens its options when the input is clicked', async () => {
const user = userEvent.setup();
render(<FloatingCombobox />);

await user.click(screen.getByRole('combobox', { name: 'Fruit' }));

expect(screen.getByRole('listbox')).toBeInTheDocument();
});

it('selects an option and restores its label to the input', async () => {
const onValueChange = vi.fn();
const user = userEvent.setup();
Expand Down Expand Up @@ -346,4 +355,74 @@ describe('Mosaic Combobox', () => {

expect(screen.getByText('No matches')).toHaveClass('cl-combobox-empty');
});

function ClearableCombobox(props?: { onValueChange?: (value: string | null) => void }) {
return (
<Combobox.Root
defaultValue='apple'
defaultInputValue='Apple'
onValueChange={props?.onValueChange}
>
<InputGroup.Root>
<Combobox.Input aria-label='Fruit' />
<InputGroup.End>
<Combobox.Clear />
<Combobox.Trigger aria-label='Toggle fruit options' />
</InputGroup.End>
</InputGroup.Root>
<Combobox.Popup>
<Combobox.Option
value='apple'
label='Apple'
>
Apple
</Combobox.Option>
</Combobox.Popup>
</Combobox.Root>
);
}

it('clears the input and selection from the Clear button and keeps focus on the input', async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(<ClearableCombobox onValueChange={onValueChange} />);

await user.click(screen.getByRole('button', { name: 'Clear' }));

expect(onValueChange).toHaveBeenCalledWith(null);
expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveValue('');
expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveFocus();
});

it('renders Clear only while there is an input value or a selection', async () => {
const user = userEvent.setup();
render(
<Combobox.Root>
<InputGroup.Root>
<Combobox.Input aria-label='Fruit' />
<InputGroup.End>
<Combobox.Clear />
</InputGroup.End>
</InputGroup.Root>
</Combobox.Root>,
);

expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument();
await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a');
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
});

it('keeps the trigger toggling the list with its own name while there is text', async () => {
const user = userEvent.setup();
render(<ClearableCombobox />);

const trigger = screen.getByRole('button', { name: 'Toggle fruit options' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');

await user.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');

await user.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
});
58 changes: 54 additions & 4 deletions packages/mosaic/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,50 +4,99 @@ import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { Checkmark } from '../../icons/glyphs/checkmark';
import { ChevronDown } from '../../icons/glyphs/chevron-down';
import { X } from '../../icons/glyphs/x';
import type { ComboboxProps } from '../../primitives/combobox';
import { Combobox as HeadlessCombobox } from '../../primitives/combobox';
import { useRender } from '../../primitives/utils';
import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { Button } from '../button';
import { Icon } from '../icon';
import { Input, type InputVariant } from '../input';
import { useOptionalInputGroupContext } from '../input-group/input-group.context';
import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area';
import { styles } from './combobox.styles';
import { styles, trigger } from './combobox.styles';

export type ComboboxRootProps = ComboboxProps;
export type ComboboxSize = 'sm' | 'md' | 'lg';
export type ComboboxTriggerProps = MosaicComponentProps<'button'>;
export type ComboboxClearProps = MosaicComponentProps<'button'>;
export const ComboboxCollection = HeadlessCombobox.Collection;

const ComboboxAnchorContext = React.createContext<{
anchor: HTMLElement | null;
setAnchor: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
} | null>(null);

export function ComboboxRoot({ sideOffset = 8, ...props }: ComboboxRootProps) {
export function ComboboxRoot({ sideOffset = 8, openOnInputClick = true, ...props }: ComboboxRootProps) {
const [anchor, setAnchor] = React.useState<HTMLElement | null>(null);
const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]);
return (
<ComboboxAnchorContext.Provider value={context}>
<HeadlessCombobox.Root
sideOffset={sideOffset}
openOnInputClick={openOnInputClick}
{...props}
/>
</ComboboxAnchorContext.Provider>
);
}

export const ComboboxTrigger = React.forwardRef<HTMLButtonElement, ComboboxTriggerProps>(function MosaicComboboxTrigger(
{ xstyle, ...rest },
{ xstyle, render, children, ...rest },
ref,
) {
return (
<HeadlessCombobox.Trigger
ref={ref}
render={
render ??
(props => (
<Button
xstyle={trigger.base}
{...props}
/>
))
}
{...mergeStyleProps(themeProps('combobox-trigger'), stylex.props(xstyle), rest)}
/>
>
{children ?? (
<Icon
glyph={ChevronDown}
size='md'
/>
)}
</HeadlessCombobox.Trigger>
);
});

export const ComboboxClear = React.forwardRef<HTMLButtonElement, ComboboxClearProps>(function MosaicComboboxClear(
{ xstyle, render, children, ...rest },
ref,
) {
return (
<HeadlessCombobox.Clear
ref={ref}
render={
render ??
(props => (
<Button
xstyle={[trigger.base, trigger.clear]}
{...props}
/>
))
}
{...mergeStyleProps(themeProps('combobox-clear'), stylex.props(xstyle), rest)}
>
{children ?? (
<Icon
glyph={X}
size='md'
/>
)}
</HeadlessCombobox.Clear>
);
});

Expand Down Expand Up @@ -205,6 +254,7 @@ export const Combobox = {
Collection: ComboboxCollection,
Input: ComboboxInput,
Trigger: ComboboxTrigger,
Clear: ComboboxClear,
Popup: ComboboxPopup,
List: ComboboxList,
Option: ComboboxOption,
Expand Down
2 changes: 2 additions & 0 deletions packages/mosaic/src/components/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
Combobox,
ComboboxClear,
ComboboxCollection,
ComboboxEmpty,
ComboboxInput,
Expand All @@ -11,6 +12,7 @@ export {
ComboboxTrigger,
} from './combobox';
export type {
ComboboxClearProps,
ComboboxEmptyProps,
ComboboxInputProps,
ComboboxListProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as stylex from '@stylexjs/stylex';

export const inputGroupScope = stylex.defineMarker();
11 changes: 10 additions & 1 deletion packages/mosaic/src/components/input-group/input-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useOptionalFieldContext } from '../field/field.context';
import { Input } from '../input';
import type { InputGroupSize } from './input-group.context';
import { InputGroupContext, useInputGroupContext } from './input-group.context';
import { inputGroupScope } from './input-group.markers.stylex';
import { sizes, styles, textSizes } from './input-group.styles';

export interface InputGroupRootProps extends MosaicComponentProps<'div'> {
Expand Down Expand Up @@ -44,7 +45,15 @@ const Root = React.forwardRef<HTMLDivElement, InputGroupRootProps>(function Mosa
props: {
...mergeStyleProps(
themeProps('input-group', { size, disabled, invalid }),
stylex.props(reset.base, inputStyles.group, styles.root, sizes[size], disabled && inputStyles.disabled, xstyle),
stylex.props(
reset.base,
inputGroupScope,
inputStyles.group,
styles.root,
sizes[size],
disabled && inputStyles.disabled,
xstyle,
),
otherProps,
),
onClick: (event: React.MouseEvent<HTMLDivElement>) => {
Expand Down
17 changes: 12 additions & 5 deletions packages/mosaic/src/components/phone-input/phone-input.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ const optionRadius = `max(0px, calc(${popupRadius} - ${listInset}))`;

export const styles = stylex.create({
triggerStart: {
paddingInlineStart: 'var(--_cl-input-group-inset)',
paddingInlineStart: 0,
},
trigger: {
'--_cl-icon-color': colorVars['--cl-color-foreground-secondary'],
borderRadius: 'max(0px, calc(var(--_cl-input-group-radius) - var(--_cl-input-group-inset)))',
'--_cl-icon-color': {
default: colorVars['--cl-color-input-placeholder'],
':not([data-disabled])[aria-expanded="true"]': colorVars['--cl-color-foreground'],
'@media (hover: hover)': {
default: null,
':not([data-disabled]):hover': colorVars['--cl-color-foreground'],
},
},
borderRadius: 'max(0px, calc(var(--_cl-input-group-radius) - 1px))',
borderWidth: 0,
alignItems: 'center',
alignSelf: 'stretch',
appearance: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
Expand All @@ -31,9 +39,8 @@ export const styles = stylex.create({
justifyContent: 'center',
outlineOffset: { default: null, ':focus-visible': 0 },
paddingInlineEnd: 0,
paddingInlineStart: space['2'],
paddingInlineStart: `calc(${space['2']} + var(--_cl-input-group-inset))`,
userSelect: 'none',
height: 'calc(var(--_cl-input-group-height) - 2 * var(--_cl-input-group-inset))',
},
triggerContent: {
gap: space['0.5'],
Expand Down
14 changes: 14 additions & 0 deletions packages/mosaic/src/components/select/select.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const optionRadius = `max(0px, calc(${popupRadius} - ${inset}))`;
// popup's 1px border, the viewport's 5px padding, and its own 6px padding, on both the text and
// the icon's edge, at the same 32px height.

const hoverBorderColor = 'light-dark(#bebebe, #525252)';

export const trigger = stylex.create({
base: {
'--_cl-icon-color': {
Expand All @@ -33,6 +35,18 @@ export const trigger = stylex.create({
':not([data-disabled]):hover': colorVars['--cl-color-foreground'],
},
},
borderColor: {
default: colorVars['--cl-color-border'],
'@media (hover: hover)': {
default: null,
':not([data-disabled]):hover:not(:active):not([data-open])': hoverBorderColor,
},
},
backgroundColor: {
default: 'transparent',
':not([data-disabled]):active': colorVars['--cl-color-neutral-alpha-200'],
':not([data-disabled])[data-open]': colorVars['--cl-color-neutral-alpha-200'],
},
paddingInlineEnd: `calc(${space['3']} - 1px)`,
paddingInlineStart: `calc(${space['3']} - 1px)`,
},
Expand Down
36 changes: 36 additions & 0 deletions packages/mosaic/src/primitives/combobox/combobox-clear.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';

import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils';
import { useComboboxContext } from './combobox-context';

export type ComboboxClearProps = ComponentProps<'button'>;

export const ComboboxClear = React.forwardRef<HTMLButtonElement, ComboboxClearProps>(
function ComboboxClear(props, ref) {
const { render, ...otherProps } = props;
const { inputValue, selectedValue, clear } = useComboboxContext();

const defaultProps = {
type: 'button',
tabIndex: -1,
'aria-label': 'Clear',
onPointerDown(event: React.PointerEvent<HTMLButtonElement>) {
event.preventDefault();
},
onClick() {
clear();
},
} satisfies DefaultProps<'button'>;

const element = useRender({
defaultTagName: 'button',
render,
ref,
props: mergeProps<'button'>(defaultProps, otherProps),
});

return inputValue !== '' || selectedValue !== null ? element : null;
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { TransitionProps } from '../hooks/use-transition';

export interface ComboboxContextValue {
open: boolean;
clear: () => void;
inputValue: string;
filterQuery: string;
selectedValue: string | null;
Expand Down
Loading
Loading