diff --git a/apps/www/src/components/chip-input-demo.tsx b/apps/www/src/components/chip-input-demo.tsx index d9d2b1550..a6315d6b8 100644 --- a/apps/www/src/components/chip-input-demo.tsx +++ b/apps/www/src/components/chip-input-demo.tsx @@ -1,4 +1,4 @@ -import { Input } from '@raystack/apsara'; +import { Chip, Input } from '@raystack/apsara'; import { useState } from 'react'; export default function ChipInputDemo() { @@ -23,10 +23,14 @@ export default function ChipInputDemo() { setInput(''); } }} - chips={chips.map((c, i) => ({ - label: c.label, - onRemove: () => setChips(prev => prev.filter((_, j) => j !== i)) - }))} + chips={chips.map((c, i) => ( + + {c.label} + setChips(prev => prev.filter((_, j) => j !== i))} + /> + + ))} maxChipsVisible={4} /> ); diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index 473258d9b..af6d04182 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -628,10 +628,9 @@ function SelectionBar() { size='large' color='neutral' leadingIcon={} - isDismissible - onDismiss={() => table.resetRowSelection()} > {selectedCount} selected + table.resetRowSelection()} /> @@ -36,9 +36,9 @@ export const inlineDemo = { size="large" color="accent" leadingIcon={} - isDismissible > 2 selected + @@ -76,9 +76,9 @@ export const variantsDemo = { size="large" color="accent" leadingIcon={} - isDismissible > 2 selected + @@ -95,9 +95,9 @@ export const variantsDemo = { size="large" color="accent" leadingIcon={} - isDismissible > 2 selected + @@ -117,9 +117,9 @@ export const bulkActionsDemo = { size="large" color="accent" leadingIcon={} - isDismissible > 5 selected + @@ -174,9 +174,9 @@ export const scrollingDemo = { size="large" color="accent" leadingIcon={} - isDismissible > 2 selected + @@ -211,9 +211,9 @@ const placementWrapper = (props: string) => size="large" color="accent" leadingIcon={} - isDismissible > 2 selected + @@ -247,9 +247,9 @@ export const groupDemo = { size="large" color="accent" leadingIcon={} - isDismissible > 3 selected + diff --git a/apps/www/src/content/docs/components/floating-actions/index.mdx b/apps/www/src/content/docs/components/floating-actions/index.mdx index fb53014b2..dca864a50 100644 --- a/apps/www/src/content/docs/components/floating-actions/index.mdx +++ b/apps/www/src/content/docs/components/floating-actions/index.mdx @@ -16,7 +16,10 @@ Import and assemble the component: import { FloatingActions, Chip, Button } from "@raystack/apsara"; - 2 selected + + 2 selected + + diff --git a/apps/www/src/content/docs/components/input/demo.ts b/apps/www/src/content/docs/components/input/demo.ts index 5becc9c14..22b3c2417 100644 --- a/apps/www/src/content/docs/components/input/demo.ts +++ b/apps/www/src/content/docs/components/input/demo.ts @@ -75,10 +75,7 @@ export const disabledChipsDemo = { code: ` console.log("Remove Tag1") }, - { label: "Tag2", onRemove: () => console.log("Remove Tag2") } - ]} + chips={[{ children: "Tag1" }, { children: "Tag2" }]} />` }; @@ -100,18 +97,12 @@ export const sizeChipDemo = { code: ` console.log("Remove A") }, - { label: "B", onRemove: () => console.log("Remove B") } - ]} + chips={[{ children: "A" }, { children: "B" }]} /> console.log("Remove A") }, - { label: "B", onRemove: () => console.log("Remove B") } - ]} + chips={[{ children: "A" }, { children: "B" }]} /> ` }; @@ -165,10 +156,12 @@ export const interactiveChipDemo = { setInput(""); } }} - chips={chips.map((c, i) => ({ - label: c.label, - onRemove: () => setChips(chips.filter((_, j) => j !== i)) - }))} + chips={chips.map((c, i) => ( + + {c.label} + setChips(chips.filter((_, j) => j !== i))} /> + + ))} maxChipsVisible={4} />` } diff --git a/apps/www/src/content/docs/components/input/index.mdx b/apps/www/src/content/docs/components/input/index.mdx index 2b971dae4..fa8fe8fd3 100644 --- a/apps/www/src/content/docs/components/input/index.mdx +++ b/apps/www/src/content/docs/components/input/index.mdx @@ -70,25 +70,24 @@ A leading icon names the field at a glance; a trailing icon usually holds an act ### With chips -Input that can display and manage chips/tags. +`chips` shows chips before the text. Each item is either [Chip](/docs/components/chip) props, which the input renders as a `Chip`, or a `Chip` element, which renders as given. ### Interactive chips -Accepts an array of chips, each with a `label` and an optional `onRemove` callback. +To make a chip removable, pass a `Chip` element with a `Chip.Remove` child and handle the removal in its `onClick`. `maxChipsVisible` caps how many are rendered before collapsing the rest into an overflow counter. ```tsx -chips?: Array<{ label: string; onRemove?: () => void }>; -maxChipsVisible?: number; // defaults to 2 +chips?: Array; +maxChipsVisible?: number; // defaults to 2 ``` -This is purely a presentation layer and does not manage chip state. The wrapper component owns the array and decides when items are added or removed. +The input does not manage chip state. The parent component owns the array and decides when items are added or removed. -The example below - appends a chip on `Enter`, removes one when its dismiss button is clicked. +The example below appends a chip on `Enter` and removes one when its remove button is clicked. @@ -112,7 +111,7 @@ Use `onValueChange` for a callback that receives only the new string value, or ` ### Disabled with chips -When disabled, chips become non-interactive and their dismiss buttons are hidden. +When disabled, chips passed as props are disabled too, and so is their `Chip.Remove`. Chip elements render as given, so pass `disabled` to them yourself. @@ -143,3 +142,4 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test - Use with [Field](/docs/components/field) for automatic label association and error linking - Required state is communicated via `aria-required` - Supports all native `` attributes +- The leading icon is hidden from screen readers. The trailing icon is not, so a button in it stays reachable. Set `aria-hidden` on decorative content in `trailingIcon` that is not an icon, for example a text glyph. diff --git a/apps/www/src/content/docs/components/input/props.ts b/apps/www/src/content/docs/components/input/props.ts index cd5a5dba4..2e282b2d4 100644 --- a/apps/www/src/content/docs/components/input/props.ts +++ b/apps/www/src/content/docs/components/input/props.ts @@ -1,3 +1,5 @@ +import type { ChipProps } from '../chip/props'; + export interface InputProps { /** * Size variant of the input. @@ -5,7 +7,7 @@ export interface InputProps { */ size?: 'small' | 'large'; - /** Whether the input is disabled. When true, chips are also disabled and their dismiss buttons are hidden. */ + /** Whether the input is disabled. When true, chips passed as props are also disabled. */ disabled?: boolean; /** Icon element to display at the start of input. */ @@ -20,10 +22,8 @@ export interface InputProps { /** Text or symbol to show after input value. */ suffix?: string; - /** - * Array of chip objects with label and optional onRemove function. - */ - chips?: { label: string; onRemove?: () => void }[]; + /** Chips shown before the text. Elements render as given; props render a `Chip`. */ + chips?: Array; /** * Maximum number of chips to show before displaying overflow count. diff --git a/apps/www/src/content/docs/dataview/demo.ts b/apps/www/src/content/docs/dataview/demo.ts index f5d51bbb1..446cf8aef 100644 --- a/apps/www/src/content/docs/dataview/demo.ts +++ b/apps/www/src/content/docs/dataview/demo.ts @@ -387,10 +387,9 @@ function SelectionBar() { size="large" color="neutral" leadingIcon={} - isDismissible - onDismiss={() => table.resetRowSelection()} > {selectedCount} selected + table.resetRowSelection()} /> diff --git a/packages/react/components/chip/__tests__/chip.test.tsx b/packages/react/components/chip/__tests__/chip.test.tsx index b5e9ad76a..5eadd0879 100644 --- a/packages/react/components/chip/__tests__/chip.test.tsx +++ b/packages/react/components/chip/__tests__/chip.test.tsx @@ -138,11 +138,12 @@ describe('Chip', () => { expect(ref.current?.tagName).toBe('BUTTON'); }); - it('stays a span when dismissible, even with onClick', () => { + it('stays a span with Chip.Remove, even with onClick', () => { const ref = createRef(); render( - + Test Chip + ); @@ -150,35 +151,62 @@ describe('Chip', () => { }); }); - describe('Dismissible Behavior', () => { - it('renders dismiss button when isDismissible is true', () => { - render(Dismissible Chip); + describe('Render', () => { + it('renders as the render element with the chip props', () => { + render(}>Test Chip); - const dismissButton = screen.getByRole('button', { - name: 'Remove Dismissible Chip' + const chip = screen.getByTestId('custom'); + expect(chip.tagName).toBe('DIV'); + expect(chip).toHaveClass(styles.chip); + expect(chip).toHaveAttribute('data-slot', 'chip'); + expect(chip).toHaveTextContent('Test Chip'); + }); + }); + + describe('Chip.Remove', () => { + it('renders a remove button named after the chip', () => { + render( + + Removable Chip + + + ); + + const removeButton = screen.getByRole('button', { + name: 'Remove Removable Chip' }); - expect(dismissButton).toBeInTheDocument(); - expect(dismissButton).toHaveClass(styles['dismiss-button']); - expect(dismissButton).toHaveAttribute('type', 'button'); + expect(removeButton).toHaveClass(styles.remove); + expect(removeButton).toHaveAttribute('type', 'button'); + expect(screen.getByRole('status')).toHaveAttribute( + 'aria-label', + 'Removable Chip' + ); }); - it('uses the shared XIcon registry icon for the dismiss button', () => { - const { container } = render(Dismissible Chip); + it('uses the shared XIcon registry icon', () => { + const { container } = render( + + Removable Chip + + + ); const icon = container.querySelector('[data-icon="XIcon"]'); - expect(icon).toBeInTheDocument(); - expect(icon).toHaveAttribute('data-slot', 'chip-dismiss-icon'); + expect(icon).toHaveAttribute('data-slot', 'chip-remove-icon'); expect(icon).toHaveAttribute('width', '12'); expect(icon).toHaveAttribute('height', '12'); }); - it('passes the dismiss icon size to an XIcon override', () => { + it('passes the icon size to an XIcon override', () => { const CustomX = (props: IconProps) => ( ); render( - Dismissible Chip + + Removable Chip + + ); @@ -188,39 +216,48 @@ describe('Chip', () => { expect(icon).toHaveAttribute('height', '12'); }); - it('calls onDismiss when dismiss button is clicked', () => { - const onDismiss = vi.fn(); + it('calls onClick without triggering the chip onClick', () => { + const onClick = vi.fn(); + const onRemove = vi.fn(); render( - - Dismissible Chip + + Removable Chip + ); - const dismissButton = screen.getByRole('button', { - name: 'Remove Dismissible Chip' - }); - fireEvent.click(dismissButton); + fireEvent.click( + screen.getByRole('button', { name: 'Remove Removable Chip' }) + ); - expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onRemove).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); }); - it('stops propagation when dismiss button is clicked', () => { - const onClick = vi.fn(); - const onDismiss = vi.fn(); - + it('is disabled when the chip is disabled', () => { render( - - Dismissible Chip + + Removable Chip + ); - const dismissButton = screen.getByRole('button', { - name: 'Remove Dismissible Chip' - }); - fireEvent.click(dismissButton); + expect( + screen.getByRole('button', { name: 'Remove Removable Chip' }) + ).toBeDisabled(); + }); - expect(onDismiss).toHaveBeenCalledTimes(1); - expect(onClick).not.toHaveBeenCalled(); + it('renders as the render element', () => { + render( + + Removable Chip + } /> + + ); + + const remove = screen.getByTestId('custom-remove'); + expect(remove.tagName).toBe('SPAN'); + expect(remove).toHaveAttribute('aria-label', 'Remove Removable Chip'); }); }); diff --git a/packages/react/components/chip/__tests__/data-slots.test.tsx b/packages/react/components/chip/__tests__/data-slots.test.tsx index 0fdc85dc2..43df3b543 100644 --- a/packages/react/components/chip/__tests__/data-slots.test.tsx +++ b/packages/react/components/chip/__tests__/data-slots.test.tsx @@ -13,14 +13,14 @@ describe('Chip data-slot contract', () => { expectSlots(container, ['chip', 'chip-leading-icon', 'chip-trailing-icon']); }); - it('exposes dismiss slots when dismissible', () => { + it('exposes remove slots with Chip.Remove', () => { const { container } = render( - + Label + ); - expectSlots(container, ['chip', 'chip-dismiss', 'chip-dismiss-icon']); - expect(getSlot(container, 'chip-trailing-icon')).toBeNull(); + expectSlots(container, ['chip', 'chip-remove', 'chip-remove-icon']); }); it('keeps the root slot when rendered as an interactive button', () => { @@ -32,7 +32,7 @@ describe('Chip data-slot contract', () => { const { container } = render(Label); expect(getSlot(container, 'chip-leading-icon')).toBeNull(); expect(getSlot(container, 'chip-trailing-icon')).toBeNull(); - expect(getSlot(container, 'chip-dismiss')).toBeNull(); + expect(getSlot(container, 'chip-remove')).toBeNull(); }); it('lets callers override the root slot via props', () => { diff --git a/packages/react/components/chip/chip.module.css b/packages/react/components/chip/chip.module.css index b87320265..1dc9137d5 100644 --- a/packages/react/components/chip/chip.module.css +++ b/packages/react/components/chip/chip.module.css @@ -144,7 +144,7 @@ opacity: 0.5; } -.dismiss-button { +.remove { background: none; border: none; padding: 0; @@ -161,27 +161,27 @@ font-family: inherit; } -.chip-interactive:focus, -.dismiss-button:focus { +.chip:focus, +.remove:focus { outline: none; } -.chip-interactive:focus-visible { +.chip:focus-visible { outline: var(--rs-focus-ring); } -.dismiss-button:focus-visible { +.remove:focus-visible { outline: var(--rs-focus-ring); border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); } -.dismiss-button:active, +.remove:active, .chip-interactive:active { transform: scale(var(--rs-scale-pressed-strong)); } @media (prefers-reduced-motion: no-preference) { - .dismiss-button { + .remove { transition: transform var(--rs-duration-press) var(--rs-ease-out); } } diff --git a/packages/react/components/chip/chip.tsx b/packages/react/components/chip/chip.tsx index cd0b11a7c..c27a0dead 100644 --- a/packages/react/components/chip/chip.tsx +++ b/packages/react/components/chip/chip.tsx @@ -1,7 +1,15 @@ 'use client'; +import { mergeProps, useRender } from '@base-ui/react'; import { cva, cx, type VariantProps } from 'class-variance-authority'; -import { ComponentProps, ReactNode } from 'react'; +import { + Children, + ComponentProps, + createContext, + isValidElement, + ReactNode, + useContext +} from 'react'; import { XIcon } from '~/icons'; import { radiusVariants } from '../../shared/radius'; import styles from './chip.module.css'; @@ -32,42 +40,51 @@ const chip = cva(styles.chip, { } }); -export type ChipProps = ComponentProps<'span'> & +const ChipContext = createContext<{ label?: string; disabled?: boolean }>({}); + +export type ChipProps = useRender.ComponentProps<'span'> & VariantProps & { trailingIcon?: ReactNode; leadingIcon?: ReactNode; - isDismissible?: boolean; - onDismiss?: () => void; disabled?: boolean; }; -export const Chip = ({ +export type ChipRemoveProps = useRender.ComponentProps<'button'>; + +function getTextLabel(children: ReactNode) { + const text = Children.toArray(children) + .filter(child => typeof child === 'string' || typeof child === 'number') + .join('') + .trim(); + return text || undefined; +} + +const ChipRoot = ({ variant, radius, size, color, trailingIcon, leadingIcon, - isDismissible, children, className, - onDismiss, onClick, role, disabled, 'aria-label': ariaLabel, + render, + ref, ...props }: ChipProps) => { - const handleDismiss = (e: React.MouseEvent) => { - e.stopPropagation(); - onDismiss?.(); - }; - - const isInteractive = !!onClick && !isDismissible; + const label = ariaLabel ?? getTextLabel(children); + // A chip with a remove button stays a span, so the buttons do not nest. + const hasRemove = Children.toArray(children).some( + child => isValidElement(child) && child.type === ChipRemove + ); + const isInteractive = !!onClick && !hasRemove; const sharedProps = { - 'aria-label': - ariaLabel ?? (typeof children === 'string' ? children : undefined), + 'aria-label': label, 'data-disabled': disabled || undefined }; @@ -84,25 +101,7 @@ export const Chip = ({ )} {children} - {isDismissible ? ( - - ) : trailingIcon ? ( + {trailingIcon && ( - ) : null} + )} ); - if (isInteractive) { - return ( - - ); - } + }), + onClick + } + : { + className: chip({ variant, size, color, radius, className }), + role: role ?? 'status', + onClick: disabled ? undefined : onClick + }; - return ( - - {content} - - ); + const element = useRender({ + defaultTagName: isInteractive ? 'button' : 'span', + render, + ref, + props: mergeProps<'span'>( + { 'data-slot': 'chip' } as useRender.ComponentProps<'span'>, + props, + { + ...sharedProps, + ...(ownProps as ComponentProps<'span'>), + children: content + } + ) + }); + + return {element}; +}; +ChipRoot.displayName = 'Chip'; + +const ChipRemove = ({ render, ref, ...props }: ChipRemoveProps) => { + const { label, disabled } = useContext(ChipContext); + + return useRender({ + defaultTagName: 'button', + render, + ref, + props: mergeProps<'button'>( + { + type: 'button', + className: styles.remove, + 'aria-label': `Remove ${label ?? 'item'}`, + disabled: disabled || undefined, + 'data-slot': 'chip-remove', + onClick: event => event.stopPropagation(), + children: ( +