diff --git a/apps/website/screens/components/chip/ChipPageLayout.tsx b/apps/website/screens/components/chip/ChipPageLayout.tsx index 41ad627f6f..94bef50828 100644 --- a/apps/website/screens/components/chip/ChipPageLayout.tsx +++ b/apps/website/screens/components/chip/ChipPageLayout.tsx @@ -16,8 +16,7 @@ const ChipPageHeading = ({ children }: { children: ReactNode }) => { - A chip is a compact, interactive UI element used to represent small pieces of information, actions, or - selections. + A chip is a compact element used to label, filter, or represent pieces of information within an interface. diff --git a/apps/website/screens/components/chip/code/ChipCodePage.tsx b/apps/website/screens/components/chip/code/ChipCodePage.tsx index 82dc0b732b..cf2572decb 100644 --- a/apps/website/screens/components/chip/code/ChipCodePage.tsx +++ b/apps/website/screens/components/chip/code/ChipCodePage.tsx @@ -2,19 +2,11 @@ import { DxcFlex, DxcLink, DxcTable } from "@dxc-technology/halstack-react"; import QuickNavContainer from "@/common/QuickNavContainer"; import DocFooter from "@/common/DocFooter"; import Example from "@/common/example/Example"; -import basicUsage from "./examples/basicUsage"; -import icons from "./examples/icons"; import Code, { ExtendedTableCode, TableCode } from "@/common/Code"; import StatusBadge from "@/common/StatusBadge"; -import avatar from "./examples/avatar"; import Link from "next/link"; - -const actionTypeString = `{ - icon?: string | (React.ReactNode - & React.SVGProps); - onClick: () => void; - title?: string; -}`; +import dismissible from "./examples/dismissible"; +import selectable from "./examples/selectable"; const prefixTypeString = `| string | SVG @@ -26,7 +18,7 @@ const avatarPropsString = `{ | 'error'; icon?: string | SVG; imgSrc?: string; - label?: string; + profileName?: string; };`; const sections = [ @@ -44,50 +36,67 @@ const sections = [ + disabled - - - action - + boolean - {actionTypeString} + If true, the component will be disabled. When mode is "dismissible", the chip cannot be + disabled. + + + false - Action to be displayed on the right side of the chip after the label. - - - disabled + label - boolean + string - If true, the component will be disabled. - false + Text to be placed on the chip. When using an avatar as prefix or when mode is "dismissible", + the label is required to ensure proper accessibility. + - - - label + + mode - string + "selectable" | "dismissible" + + + Determines the visual style and functionality of the chip. Available modes are: + + + + "selectable" - Text to be placed on the chip. - - - margin - 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + + + onClick + - Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and - 'right' properties in order to specify different margin sizes. + {"() => void"} + Function to be called when the chip is clicked or the dismiss action is triggered. - @@ -119,14 +128,30 @@ const sections = [ - + + + + + selected + + + + boolean + + + If true, the component will be selected. If undefined, the component manages its own internal state + (uncontrolled mode). This property is only applicable when the mode is "selectable". + + - + tabIndex number - Value of the tabindex attribute applied to both the component and the prefix and suffix icons - when a function is given. + Value of the tabindex attribute applied to the component when mode is{" "} + "selectable" and clear icon when mode is "dismissible". 0 @@ -140,22 +165,14 @@ const sections = [ title: "Examples", subSections: [ { - title: "Basic usage", - content: , - }, - { - title: "Icons", - content: ( - <> - - - ), + title: "Selectable", + content: , }, { - title: "Avatar", + title: "Dismissible", content: ( <> - + ), }, diff --git a/apps/website/screens/components/chip/code/examples/avatar.tsx b/apps/website/screens/components/chip/code/examples/avatar.tsx deleted file mode 100644 index 85d8dbd2af..0000000000 --- a/apps/website/screens/components/chip/code/examples/avatar.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { DxcChip, DxcFlex, DxcInset } from "@dxc-technology/halstack-react"; - -const code = `() => { - const icon = ( - - - - ); - - return ( - - - - console.log("action clicked") }} - /> - - - ); -}`; - -const scope = { - DxcChip, - DxcInset, - DxcFlex, -}; - -export default { code, scope }; diff --git a/apps/website/screens/components/chip/code/examples/basicUsage.tsx b/apps/website/screens/components/chip/code/examples/basicUsage.tsx deleted file mode 100644 index f6a79ac45c..0000000000 --- a/apps/website/screens/components/chip/code/examples/basicUsage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { DxcChip, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; - -const code = `() => { - return ( - - - - - - - - ); -}`; - -const scope = { - DxcChip, - DxcInset, - DxcFlex, -}; - -export default { code, scope }; diff --git a/apps/website/screens/components/chip/code/examples/dismissible.tsx b/apps/website/screens/components/chip/code/examples/dismissible.tsx new file mode 100644 index 0000000000..359a92cd79 --- /dev/null +++ b/apps/website/screens/components/chip/code/examples/dismissible.tsx @@ -0,0 +1,79 @@ +import { DxcChip, DxcFlex, DxcInset, DxcSelect } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +type Option = { label: string; value: string; icon: string }; + +const options: Option[] = [ + { label: "Electric Car", value: "car", icon: "electric_car" }, + { label: "Motorcycle", value: "motorcycle", icon: "Motorcycle" }, + { label: "Train", value: "train", icon: "train" }, + { label: "Bike", value: "bike", icon: "pedal_bike" }, +]; + +const findOptionByValue = (value: string): Option | null => options.find((opt) => opt.value === value) ?? null; + +type TransportSelectProps = { + selectedOptions: Option[]; + onChange: (selected: Option[]) => void; +}; + +const TransportSelect = ({ selectedOptions, onChange }: TransportSelectProps) => { + const handleSelectChange = ({ value }: { value: string | string[] }) => { + if (Array.isArray(value)) { + onChange(value.map((val) => findOptionByValue(val)).filter(Boolean) as Option[]); + } + }; + + return ( + opt.value)} + multiple + enableSelectAll + /> + ); +}; + +const code = `() => { + const [selectedOptions, setSelectedOptions] = useState([options[0], options[2]]); + + const handleDismiss = (valueToRemove) => { + setSelectedOptions(selectedOptions.filter(opt => opt.value !== valueToRemove)); + }; + + return ( + + + + + {selectedOptions.length > 0 && ( + + {selectedOptions.map((option) => ( + handleDismiss(option.value)} + /> + ))} + + )} + + + ); +}`; + +const scope = { + DxcChip, + DxcInset, + DxcFlex, + useState, + options, + TransportSelect, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/chip/code/examples/icons.tsx b/apps/website/screens/components/chip/code/examples/icons.tsx deleted file mode 100644 index 7a61774a73..0000000000 --- a/apps/website/screens/components/chip/code/examples/icons.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { DxcChip, DxcFlex, DxcInset } from "@dxc-technology/halstack-react"; - -const code = `() => { - const icon = ( - - - - ); - - return ( - - - - console.log("action clicked") }} - /> - - - ); -}`; - -const scope = { - DxcChip, - DxcInset, - DxcFlex, -}; - -export default { code, scope }; diff --git a/apps/website/screens/components/chip/code/examples/selectable.tsx b/apps/website/screens/components/chip/code/examples/selectable.tsx new file mode 100644 index 0000000000..ea4def25a6 --- /dev/null +++ b/apps/website/screens/components/chip/code/examples/selectable.tsx @@ -0,0 +1,120 @@ +import { DxcChip, DxcInset, DxcFlex, DxcTable } from "@dxc-technology/halstack-react"; +import { useState } from "react"; + +type Activity = { + id: number; + name: string; + category: string; + duration: string; + participants: number; +}; + +const allActivities: Activity[] = [ + { id: 1, name: "Morning Yoga", category: "sports", duration: "1h", participants: 12 }, + { id: 2, name: "Watercolor Workshop", category: "art", duration: "2h", participants: 8 }, + { id: 3, name: "Basketball Practice", category: "sports", duration: "1.5h", participants: 10 }, + { id: 4, name: "Guitar Lessons", category: "music", duration: "1h", participants: 5 }, + { id: 5, name: "Book Club Meeting", category: "reading", duration: "2h", participants: 15 }, + { id: 6, name: "Swimming Session", category: "sports", duration: "1h", participants: 8 }, + { id: 7, name: "Pottery Class", category: "art", duration: "2h", participants: 10 }, + { id: 8, name: "Piano Recital", category: "music", duration: "1.5h", participants: 20 }, + { id: 9, name: "Poetry Reading", category: "reading", duration: "1h", participants: 12 }, + { id: 10, name: "Jazz Band Rehearsal", category: "music", duration: "2h", participants: 6 }, +]; + +const columns = [ + { displayValue: "Activity" }, + { displayValue: "Category" }, + { displayValue: "Duration" }, + { displayValue: "Participants" }, +]; + +const ActivitiesTable = ({ selectedFilters }: { selectedFilters: Record }) => { + const activeFilters = Object.keys(selectedFilters).filter((key) => selectedFilters[key]); + const filteredActivities = + activeFilters.length === 0 ? [] : allActivities.filter((activity) => activeFilters.includes(activity.category)); + const rows = filteredActivities.map((activity) => [ + activity.name, + activity.category.charAt(0).toUpperCase() + activity.category.slice(1), + activity.duration, + activity.participants, + ]); + + return ( + + + + {columns.map((col, idx) => ( + {col.displayValue} + ))} + + + + {rows.length > 0 ? ( + rows.map((row, idx) => ( + + {row.map((cell, cellIdx) => ( + {cell} + ))} + + )) + ) : ( + + No activities found. Select at least one category. + + )} + + + ); +}; + +const code = `() => { + const [selectedFilters, setSelectedFilters] = useState({ + sports: true, + art: false, + music: false, + reading: false, + }); + + const filters = [ + { key: "sports", label: "Sports", icon: "fitness_center" }, + { key: "art", label: "Art", icon: "palette" }, + { key: "music", label: "Music", icon: "music_note" }, + { key: "reading", label: "Reading", icon: "menu_book" }, + ]; + + const handleToggle = (key) => { + setSelectedFilters((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + return ( + + + + {filters.map((filter) => ( + handleToggle(filter.key)} + /> + ))} + + + + + + ); +}`; + +const scope = { + DxcChip, + DxcInset, + DxcFlex, + useState, + allActivities, + ActivitiesTable, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/chip/overview/ChipOverviewPage.tsx b/apps/website/screens/components/chip/overview/ChipOverviewPage.tsx index df86c5436c..fdc65af775 100644 --- a/apps/website/screens/components/chip/overview/ChipOverviewPage.tsx +++ b/apps/website/screens/components/chip/overview/ChipOverviewPage.tsx @@ -1,14 +1,12 @@ -import { DxcBulletedList, DxcFlex, DxcLink, DxcParagraph, DxcTable } from "@dxc-technology/halstack-react"; +import { DxcBulletedList, DxcFlex, DxcParagraph } from "@dxc-technology/halstack-react"; import QuickNavContainer from "@/common/QuickNavContainer"; import DocFooter from "@/common/DocFooter"; -import anatomy from "./images/chip-anatomy.png"; -import categorization from "./images/chip-categorization.png"; -import searchFilter from "./images/chip-faceted-search-filter.png"; -import states from "./images/chip-states.png"; -import sizeVariants from "./images/chip-size.png"; -import spacing from "./images/chip-spacing.png"; import Image from "@/common/Image"; -import Link from "next/link"; +import anatomy from "./images/chip-anatomy.png"; +import dismissible from "./images/dismissible-chip.png"; +import selectable from "./images/selectable-chip.png"; +import selectableExample from "./images/selectable-example.png"; +import dismissibleExample from "./images/dismissible-example.png"; const sections = [ { @@ -16,14 +14,14 @@ const sections = [ content: ( <> - Chips are versatile UI components used to display and manage information in a compact, scannable format. They - commonly represent selected options, tags, filters, or contextual actions within an interface. + Chips help users quickly scan, organize, and interact with content by surfacing relevant items in a clear and + lightweight way. - Chip component supports multiple sizes, optional leading elements (icon or avatar), and an optional action - icon, while maintaining a consistent structure and interaction model. Clear states, keyboard accessibility, - and controlled label length ensure the component remains lightweight, reusable, and adaptable across products - such as filters, forms, and the chatbot experience. + They are commonly used to support filtering, categorization, and selection workflows, or to display values + generated by user input such as search terms, selected options, or attached items. Chips are typically + displayed in groups and are designed to work alongside other interface elements such as inputs, filters, lists + and data views, depending on the variant and use case. ), @@ -35,131 +33,148 @@ const sections = [ Chip anatomy - Container: the structural wrapper that holds the chip’s content and defines its visual - boundaries and spacing. It establishes the chip’s size and layout while remaining informational only. + Container: it's the area that holds the chip’s content, defining its shape, size, spacing + and interactive states (if applicable) of the component. - Left Element (Optional): a leading visual element that adds contextual meaning to - the chip and helps users quickly recognize its purpose. + Left Element: an optional visual element displayed before the label. It can be an icon or + an avatar used to provide additional context or help users quickly identify the content. - Label: the text content inside the chip that identifies and describes the associated item - or value. + Label: displays the textual content of the chip, conveying selection, user input or key + filtering data. - Action Icon (Optional): a trailing control that enables direct interaction with - the chip without affecting the container itself. + Dismiss action: an action control that allows users to remove the chip from the interface + and it’s only present in the dismissible variant of the component. ), }, { - title: "Using chips", + title: "Variants", + content: ( + + Depending on how it is used within an interface, the chip component can take two different forms:{" "} + selectable chip and dismissible chip. Each variant supports a different + interaction pattern and serves a distinct purpose in the interface. + + ), subSections: [ { - title: "Categorization", + title: "Selectable chip", content: ( <> + Selectable chip - Chips are used to organize and summarize related information such as topics, statuses, or attributes in a - compact and scannable way. They help users quickly understand key metadata without overwhelming the - interface. + The selectable chip is an interactive element that allows users to{" "} + activate or clear options directly within the interface. It is commonly used to filter + content, highlight categories or trigger lightweight actions without requiring additional controls such as + dropdowns or menus. Selectable chips are typically displayed in groups, allowing users to quickly adjust + the information shown on the screen or interact with specific items. + - With the redesigned Chip component, categorization chips support consistent sizing, optional leading - icons, and a clear visual structure while remaining informational and non-interactive. When using chips - for categorization, ensure labels are concise and relevant to the displayed content to maintain clarity - and usability. + Selectable chips can include an optional left element, which may be either an icon or an + avatar, used to visually reinforce the meaning of the chip. Depending on the context, the chip can be + displayed in different configurations: - Chip categorization - - ), - }, - { - title: "Faceted search filters", - content: ( - <> + + + + Icon / Avatar + label, when the left element helps reinforce the meaning of the label. + + + Icon only, when the icon is sufficiently descriptive on its own. + + + Label only, when additional visual context is not required. + + + - When used alongside selection or filter controls, chips act as filter facets that allow users to review, - apply, and remove selected attributes. This enables users to refine results efficiently and maintain - visibility of their current selections. + This flexibility allows selectable chips to adapt to different layouts and levels of visual density. This + variant of the chip is often used in situations where users need to quickly explore or organize + information without navigating away from the current view. - In the redesigned Chip component, faceted filter chips support dismissal through the action icon, which is - the primary interaction point. Clear visual states (hover, focus, active, disabled) help communicate - interactivity, while optional leading elements (icons or avatars, depending on size){" "} + Use cases: - Chip faceted search filters + + + + Filtering content within a page, such as refining results in a table, list or dashboard + + + Segmenting groups of content, for example switching between conversation categories in + a messaging interface + + + Highlighting attributes or tags that help organize large sets of information + + + Triggering contextual actions, such as marking an item as a favorite or indicating a + preference + + + Selectable chip example ), }, { - title: "Chip states", + title: "Dismissible chip", content: ( <> + Dismissible chip - Chip component, states are applied to the action icon only, including default, hover, focus, active, and - disabled. The container remains informational, ensuring interactions are clear, intentional, and - consistent across use cases. + The dismissible chip is used to{" "} + represent information generated by user input within an interface. It commonly appears + after a user performs an action, such as entering search terms, selecting values from another component or + attaching files. In this variant, the chip serves as a visual representation of the input + provided by the user. - Chip states - - ), - }, - { - title: "Chips vs. Badges", - content: ( - <> + - While{" "} - - badges - {" "} - and chips share a similar visual style, they serve different purposes in a user interface:{" "} - chips are interactive, while badges are static indicators. + Unlike selectable chips, dismissible chips are not interactive elements by themselves. + Their purpose is to display information that has already been applied or added. The only interactive + element in this variant is the dismiss action, which allows users to remove the chip and + clear the corresponding value or item. - - - - Component - Use case - - - - - - Chip - - - Chips help users categorize, filter, or organize information. They often include keywords or - metadata, providing quick access to related content and aiding navigation. - - - - - Badge - - - Badges function as visual indicators, displaying status or contextual information. They are - non-interactive and rely on color and text to communicate meaning. - - - - - - ), - }, - { - title: "Size variants", - content: ( - <> + - The Chip component is available in three size variants to support different interface densities and use - cases. Each size follows the same structural pattern while adjusting spacing and supported elements to - maintain clarity and usability. + Dismissible chips always include a label, which describes the value, term, or item being + represented. They also include a dismiss action, represented by a close icon, that + enables users to remove the chip from the interface. An optional left element, such as an + icon or avatar, can be included to provide additional context when needed. - Chip size variants + + + This variant is typically used in situations where users need to clearly see and manage the values they + have entered or selected. + + + + Use cases: + + + + + Displaying search terms entered by the user in a search field + + + Representing applied filters that originate from other components such as selects or + filter panels + + + Showing selected values from inputs such as multi-select fields + + + Displaying attached items, such as documents or files added to a form or message + + + + Dismissible chip example ), }, @@ -169,79 +184,70 @@ const sections = [ title: "Best practices", subSections: [ { - title: "Keep labels concise and meaningful", + title: "General", content: ( - Labels should be short and clear, ideally one or two words. + Use chips to represent lightweight interactions. Chips should simplify common tasks such + as filtering, categorizing or representing input without introducing additional complexity. - Avoid long text that may cause truncation or wrapping issues. + Display chips in groups when possible. Chips are easier to understand when presented + alongside related options or values. - Use sentence case for readability (e.g., "New York" instead of "NEW YORK"). - - - Ensure the most important information appears at the beginning of the label, since long labels are - automatically truncated. + Use consistent labeling across chips. When chips appear together, labels should follow + the same naming pattern to improve readability and comprehension. ), }, { - title: "Avoid overloading the UI with too many chips", + title: "Selectable chip", content: ( - <> - - - Use chips only when necessary to avoid clutter. - - - Group related chips logically and consider collapsible chip groups if the list becomes - too long. - - - Chip's spacing - - ), - }, - { - title: "Ensure icons are contextually relevant and avoid redundancy", - content: ( - <> - - Chip component may include one leading informational icon (or avatar) and{" "} - one action icon. Using multiple informational icons or multiple action icons within the - same chip is not supported. - - - - Informational icons should add value to the chip, such as status or category. - - - The action icon should clearly communicate its purpose (e.g., remove or clear). - - - Icons should be easy to recognize and not compete for attention. - - - Follow the guideline: one informational element + one action icon (if needed). - - - + + + Use selectable chips for lightweight filtering or categorization. They are most effective + when helping users quickly refine or organize visible content. + + + Group related chips together. Selectable chips should usually appear in clusters that + represent related categories or options. + + + Avoid using selectable chips for mutually exclusive options with strict selection rules. + In cases where one option must always remain selected, the correct component to use is our toggle group. + + + Use icons or avatars only when they add meaningful context. Decorative elements should + reinforce the meaning of the chip rather than duplicate the label. + + ), }, { - title: "Manage chip overflow gracefully", + title: "Dismissible chip", content: ( - When displaying many chips, consider wrapping or horizontal scrolling depending on layout - constraints. + Use dismissible chips to represent user-generated input. They should display values that + the user has entered, selected or added through other components. + + + Always provide a clear and accessible dismiss action. Users should be able to easily + remove chips when they want to clear a value or undo an action. + + + Ensure the label clearly represents the value or item. The label should make it easy for + users to understand what information the chip represents. + + + Place dismissible chips close to the input that generated them. This helps users + understand the relationship between the chip and the original action. - For dynamic or long lists, provide a "Show more" or similar mechanism to prevent visual - clutter. + Use icons or avatars only when they provide additional context. For example, icons can + help represent file types, categories or other recognizable items. ), diff --git a/apps/website/screens/components/chip/overview/images/chip-anatomy.png b/apps/website/screens/components/chip/overview/images/chip-anatomy.png index 17e34efd0c..0e9be2a22f 100644 Binary files a/apps/website/screens/components/chip/overview/images/chip-anatomy.png and b/apps/website/screens/components/chip/overview/images/chip-anatomy.png differ diff --git a/apps/website/screens/components/chip/overview/images/chip-categorization.png b/apps/website/screens/components/chip/overview/images/chip-categorization.png deleted file mode 100644 index 233f45c6b6..0000000000 Binary files a/apps/website/screens/components/chip/overview/images/chip-categorization.png and /dev/null differ diff --git a/apps/website/screens/components/chip/overview/images/chip-faceted-search-filter.png b/apps/website/screens/components/chip/overview/images/chip-faceted-search-filter.png deleted file mode 100644 index 043b3f6e31..0000000000 Binary files a/apps/website/screens/components/chip/overview/images/chip-faceted-search-filter.png and /dev/null differ diff --git a/apps/website/screens/components/chip/overview/images/chip-size.png b/apps/website/screens/components/chip/overview/images/chip-size.png deleted file mode 100644 index 7e0d8411d0..0000000000 Binary files a/apps/website/screens/components/chip/overview/images/chip-size.png and /dev/null differ diff --git a/apps/website/screens/components/chip/overview/images/chip-spacing.png b/apps/website/screens/components/chip/overview/images/chip-spacing.png deleted file mode 100644 index 1e1d4d5806..0000000000 Binary files a/apps/website/screens/components/chip/overview/images/chip-spacing.png and /dev/null differ diff --git a/apps/website/screens/components/chip/overview/images/chip-states.png b/apps/website/screens/components/chip/overview/images/chip-states.png deleted file mode 100644 index 80c102e0bc..0000000000 Binary files a/apps/website/screens/components/chip/overview/images/chip-states.png and /dev/null differ diff --git a/apps/website/screens/components/chip/overview/images/dismissible-chip.png b/apps/website/screens/components/chip/overview/images/dismissible-chip.png new file mode 100644 index 0000000000..3bd6db5f16 Binary files /dev/null and b/apps/website/screens/components/chip/overview/images/dismissible-chip.png differ diff --git a/apps/website/screens/components/chip/overview/images/dismissible-example.png b/apps/website/screens/components/chip/overview/images/dismissible-example.png new file mode 100644 index 0000000000..ea1988d337 Binary files /dev/null and b/apps/website/screens/components/chip/overview/images/dismissible-example.png differ diff --git a/apps/website/screens/components/chip/overview/images/selectable-chip.png b/apps/website/screens/components/chip/overview/images/selectable-chip.png new file mode 100644 index 0000000000..e966057d50 Binary files /dev/null and b/apps/website/screens/components/chip/overview/images/selectable-chip.png differ diff --git a/apps/website/screens/components/chip/overview/images/selectable-example.png b/apps/website/screens/components/chip/overview/images/selectable-example.png new file mode 100644 index 0000000000..1c9c2b2c31 Binary files /dev/null and b/apps/website/screens/components/chip/overview/images/selectable-example.png differ diff --git a/packages/lib/src/chip/Chip.accessibility.test.tsx b/packages/lib/src/chip/Chip.accessibility.test.tsx index eeb66ee5aa..c132a5832a 100644 --- a/packages/lib/src/chip/Chip.accessibility.test.tsx +++ b/packages/lib/src/chip/Chip.accessibility.test.tsx @@ -27,14 +27,22 @@ c-10.663,0-17.467,1.853-20.417,5.568c-2.949,3.711-4.428,10.23-4.428,19.558v31.11 describe("Chip component accessibility tests", () => { it("Should not have basic accessibility issues", async () => { - const { container } = render(); + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues when avatar is rendered", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues for dismissible mode", async () => { + const { container } = render(); const results = await axe(container); expect(results.violations).toHaveLength(0); }); it("Should not have basic accessibility issues for disabled mode", async () => { - const { container } = render( - - ); + const { container } = render(); const results = await axe(container); expect(results.violations).toHaveLength(0); }); diff --git a/packages/lib/src/chip/Chip.stories.tsx b/packages/lib/src/chip/Chip.stories.tsx index 9c3530d92b..f943636bab 100644 --- a/packages/lib/src/chip/Chip.stories.tsx +++ b/packages/lib/src/chip/Chip.stories.tsx @@ -3,7 +3,7 @@ import ExampleContainer from "../../.storybook/components/ExampleContainer"; import DxcChip from "./Chip"; import { Meta, StoryObj } from "@storybook/react-vite"; import { useEffect } from "react"; -import { userEvent, within } from "storybook/internal/test"; +import DxcFlex from "../flex/Flex"; export default { title: "Chip", @@ -23,29 +23,6 @@ export default { ], } satisfies Meta; -const iconSVG = ( - - - - - -); - const smallIconSVG = ( @@ -54,152 +31,218 @@ const smallIconSVG = ( const Chip = () => ( <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + + + + + + - - - - - - - - - console.log("action clicked") }} /> - - - - console.log("action clicked") }} - /> - - - - - - - - console.log("action clicked") }} - label="With ellipsis asdfasdf asdf asdfasdf asdf asdfasdf asdfasdf asdf asdf adfasrfasf afsdg afgasfg asdf asdf asdf asdf asdf asdf asdf afdg asfg asdfg asdf asdf asdf asdfasdf asd fas df asd asdf asdf asdfasd fgsss" - /> - - - - - - - - console.log("action clicked") }} - label="With ellipsis asdfasdf asdf asdfasdf asdf asdfasdf asdfasdf asdf asdf adfasrfasf afsdg afgasfg asdf asdf asdf asdf asdf asdf asdf afdg asfg asdfg asdf asdf asdf asdfasdf asd fas df asd asdf asdf asdfasdf" - /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - {} }} prefix={{ color: "primary" }} /> + + + + + + - - {} }} prefix={{ color: "primary" }} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - {} }} prefix={{ color: "primary" }} /> + + + + + console.log("Dismissible chip")} + /> + console.log("Dismissible chip")} + /> + console.log("Dismissible chip")} /> + - - - {} }} prefix={{ color: "primary" }} /> + + + + + + + + + + + + + + + + + + + + console.log("Dismissible chip")} + /> + console.log("Dismissible chip")} + /> + console.log("Dismissible chip")} + /> + - + + - {} }} - prefix={{ color: "primary" }} - disabled - /> +
+ +
- - - <Title title="Xxsmall margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="xxsmall" margin="xxsmall" /> + <div style={{ width: "200px" }}> + <DxcChip label="Chip label constrained by parent" prefix={smallIconSVG} /> + </div> </ExampleContainer> - <Title title="Xsmall margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="xsmall" margin="xsmall" /> + <div style={{ width: "200px" }}> + <DxcChip label="Chip label constrained by parent" prefix={{ color: "primary" }} /> + </div> </ExampleContainer> - <Title title="Small margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="small" margin="small" /> + <div style={{ width: "200px" }}> + <DxcChip label="Chip label constrained by parent" selected /> + </div> </ExampleContainer> - <Title title="Medium margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="medium" margin="medium" /> + <div style={{ width: "200px" }}> + <DxcChip label="Chip label constrained by parent" prefix={smallIconSVG} selected /> + </div> </ExampleContainer> - <Title title="Large margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="large" margin="large" /> + <div style={{ width: "200px" }}> + <DxcChip label="Chip label constrained by parent" prefix={{ color: "primary" }} selected /> + </div> </ExampleContainer> - <Title title="Xlarge margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="xlarge" margin="xlarge" /> + <div style={{ width: "200px" }}> + <DxcChip + mode="dismissible" + label="Chip label constrained by parent" + onClick={() => console.log("Dismissible chip")} + /> + </div> </ExampleContainer> - <Title title="Xxlarge margin" theme="light" level={4} /> <ExampleContainer> - <DxcChip label="xxlarge" margin="xxlarge" /> + <div style={{ width: "200px" }}> + <DxcChip + mode="dismissible" + label="Chip label constrained by parent" + prefix={smallIconSVG} + onClick={() => console.log("Dismissible chip")} + /> + </div> </ExampleContainer> - </> -); -const ChipTooltip = () => ( - <> - <Title title="Chip with Tooltip" theme="light" level={4} /> <ExampleContainer> - <DxcChip - label="Default with tooltip and very long text" - action={{ icon: "filled_delete", onClick: () => {}, title: "Delete" }} - prefix={{ color: "primary" }} - /> + <div style={{ width: "200px" }}> + <DxcChip + mode="dismissible" + label="Chip label constrained by parent" + prefix={{ color: "primary" }} + onClick={() => console.log("Dismissible chip")} + /> + </div> </ExampleContainer> </> ); @@ -209,16 +252,3 @@ type Story = StoryObj<typeof DxcChip>; export const Chromatic: Story = { render: Chip, }; - -export const Tooltip: Story = { - render: ChipTooltip, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const chipLabel = await canvas.findByText("Default with tooltip and very long text"); - await userEvent.hover(chipLabel); - - const documentCanvas = within(document.body); - await documentCanvas.findByRole("tooltip"); - }, - parameters: { chromatic: { delay: 5000 } }, -}; diff --git a/packages/lib/src/chip/Chip.test.tsx b/packages/lib/src/chip/Chip.test.tsx index c81230be54..a5d3f2178e 100644 --- a/packages/lib/src/chip/Chip.test.tsx +++ b/packages/lib/src/chip/Chip.test.tsx @@ -11,21 +11,72 @@ describe("Chip component tests", () => { const avatar = getByRole("img", { hidden: true }); expect(avatar).toBeTruthy(); }); + test("Chip renders correctly with custom SVG prefix", () => { + const customSVG = ( + <svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path d="M10 10" /> + </svg> + ); + const { getByRole } = render(<DxcChip label="Chip" prefix={customSVG} />); + expect(getByRole("img")).toBeTruthy(); + }); test("Chip renders correctly with avatar", () => { const { getByRole } = render(<DxcChip label="Chip" prefix={{ color: "primary" }} />); const avatar = getByRole("img", { hidden: true }); expect(avatar).toBeTruthy(); }); - test("Chip doesn't render avatar when size is small", () => { - const { queryByRole } = render(<DxcChip label="Chip" prefix={{ color: "primary" }} size="small" />); - const avatar = queryByRole("img", { hidden: true }); - expect(avatar).not.toBeTruthy(); + test("Chip renders correctly in dismissible mode", () => { + const onClick = jest.fn(); + const { getByText, getByRole } = render(<DxcChip label="Dismissible chip" mode="dismissible" onClick={onClick} />); + expect(getByText("Dismissible chip")).toBeTruthy(); + expect(getByRole("button", { name: "Clear" })).toBeTruthy(); + }); + test("Calls correct function when clicking on Chip", () => { + const onClick = jest.fn(); + const { getByRole } = render(<DxcChip label="Chip" onClick={onClick} />); + const chip = getByRole("button", { name: "Chip" }); + expect(chip).toBeTruthy(); + fireEvent.click(chip); + expect(onClick).toHaveBeenCalled(); }); test("Calls correct function when clicking on action icon", () => { const onClick = jest.fn(); - const { getByText, getByRole } = render(<DxcChip label="Chip" action={{ icon: "nutrition", onClick: onClick }} />); - expect(getByText("Chip")).toBeTruthy(); - fireEvent.click(getByRole("button")); + const { getByText, getByRole } = render(<DxcChip label="Chip" onClick={onClick} mode="dismissible" />); + const chipText = getByText("Chip"); + expect(chipText).toBeTruthy(); + fireEvent.click(chipText); + expect(onClick).not.toHaveBeenCalled(); + fireEvent.click(getByRole("button", { name: "Clear" })); expect(onClick).toHaveBeenCalled(); }); + test("Selectable chip has correct aria-pressed when not selected", () => { + const { getByRole } = render(<DxcChip label="Test Chip" mode="selectable" />); + const chip = getByRole("button", { name: "Test Chip" }); + expect(chip.getAttribute("aria-pressed")).toBe("false"); + }); + test("Selectable chip has correct aria-pressed when selected", () => { + const { getByRole } = render(<DxcChip label="Test Chip" mode="selectable" selected={true} />); + const chip = getByRole("button", { name: "Test Chip" }); + expect(chip.getAttribute("aria-pressed")).toBe("true"); + }); + test("Selectable chip has correct aria-label", () => { + const { getByRole } = render(<DxcChip label="My Chip" mode="selectable" />); + const chip = getByRole("button", { name: "My Chip" }); + expect(chip.getAttribute("aria-label")).toBe("My Chip"); + }); + test("Selectable chip without label has default aria-label", () => { + const { getByRole } = render(<DxcChip mode="selectable" prefix="home" />); + const chip = getByRole("button", { name: "Chip" }); + expect(chip.getAttribute("aria-label")).toBe("Chip"); + }); + test("Dismissible chip does not have aria-pressed", () => { + const { getByText } = render(<DxcChip label="Dismissible Chip" mode="dismissible" />); + const chip = getByText("Dismissible Chip").parentElement?.parentElement; + expect(chip?.getAttribute("aria-pressed")).toBeNull(); + }); + test("Dismissible chip has correct aria-label", () => { + const { getByText } = render(<DxcChip label="Dismissible Chip" mode="dismissible" />); + const chip = getByText("Dismissible Chip").parentElement?.parentElement; + expect(chip?.getAttribute("aria-label")).toBe("Dismissible Chip"); + }); }); diff --git a/packages/lib/src/chip/Chip.tsx b/packages/lib/src/chip/Chip.tsx index 67ecabfdb6..dc758ab540 100644 --- a/packages/lib/src/chip/Chip.tsx +++ b/packages/lib/src/chip/Chip.tsx @@ -1,124 +1,142 @@ import styled from "@emotion/styled"; -import { spaces } from "../common/variables"; import DxcIcon from "../icon/Icon"; import ChipPropsType, { ChipAvatarType } from "./types"; import DxcActionIcon from "../action-icon/ActionIcon"; import DxcAvatar from "../avatar/Avatar"; -import { isValidElement, useEffect, useRef, useState } from "react"; -import { Tooltip } from "../tooltip/Tooltip"; +import { isValidElement, ReactNode, useState } from "react"; import { SVG } from "../common/utils"; +import { getChipStyles } from "./utils"; const Chip = styled.div<{ - margin: ChipPropsType["margin"]; - size: ChipPropsType["size"]; + disabled: ChipPropsType["disabled"]; + mode: ChipPropsType["mode"]; + selected: ChipPropsType["selected"]; + isAvatar?: boolean; + type?: string; }>` - height: ${({ size }) => - size === "small" ? "var(--height-s)" : size === "large" ? "var(--height-xl)" : "var(--height-m)"}; - min-width: ${({ size }) => (size === "small" ? "60px" : "80px")}; - max-width: 172px; + max-width: min(100%, 320px); + height: var(--height-m); box-sizing: border-box; display: inline-flex; align-items: center; - justify-content: center; - gap: var(--spacing-gap-xs); - background-color: var(--color-bg-primary-lightest); + gap: var(--spacing-gap-xxs); + padding: ${({ isAvatar }) => + isAvatar + ? "var(--spacing-padding-none) var(--spacing-padding-xs) var(--spacing-padding-none) var(--spacing-padding-xxs)" + : "var(--spacing-padding-none) var(--spacing-padding-xs)"}; + cursor: ${({ mode }) => (mode === "selectable" ? "pointer" : "default")}; border-radius: var(--border-radius-xl); - padding: ${({ size }) => - size === "large" ? "var(--spacing-padding-xs)" : "var(--spacing-padding-xxs) var(--spacing-padding-xs)"}; - margin: ${(props) => (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px")}; - margin-top: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.top ? spaces[props.margin.top] : ""}; - margin-right: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.right ? spaces[props.margin.right] : ""}; - margin-bottom: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.bottom ? spaces[props.margin.bottom] : ""}; - margin-left: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.left ? spaces[props.margin.left] : ""}; + border-width: var(--border-width-s); + border-style: var(--border-style-default); + + ${({ mode, selected }) => getChipStyles(mode, selected)} +`; + +const ContentWrapper = styled.div<{ mode: ChipPropsType["mode"] }>` + max-width: ${({ mode }) => (mode === "dismissible" ? "calc(100% - var(--spacing-gap-xxs) - 24px)" : "100%")}; + display: inline-flex; + align-items: center; + gap: var(--spacing-gap-xs); `; -const LabelContainer = styled.span<{ disabled: ChipPropsType["disabled"] }>` +const LabelContainer = styled.span` font-size: var(--typography-label-s); font-family: var(--typography-font-family); font-weight: var(--typography-label-regular); - color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-lightest)" : "var(--color-fg-neutral-dark)")}; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; `; -const IconContainer = styled.div<{ - disabled: ChipPropsType["disabled"]; -}>` +const IconContainer = styled.div` display: flex; align-items: center; justify-content: center; - color: ${({ disabled }) => (disabled ? "var(--color-fg-neutral-medium)" : "var(--color-fg-neutral-dark)")}; font-size: var(--height-xxs); svg { height: var(--height-xxs); - width: var(--height-xxs); } `; -const isAvatarType = (value: string | SVG | ChipAvatarType): value is ChipAvatarType => { +const isAvatarType = (value: string | SVG | ChipAvatarType | undefined): value is ChipAvatarType => { return typeof value === "object" && value !== null && "color" in value; }; -const checkEllipsis = (element: HTMLElement | null): boolean => { - if (!element) return false; - return element.scrollWidth > element.clientWidth; +const renderPrefix = (prefix: string | SVG | ChipAvatarType | undefined, disabled: boolean): ReactNode | undefined => { + if (typeof prefix === "string") { + return ( + <IconContainer> + <DxcIcon icon={prefix} /> + </IconContainer> + ); + } + + if (isAvatarType(prefix)) { + return ( + <DxcAvatar + color={prefix.color} + label={prefix.profileName} + icon={prefix.icon} + imageSrc={prefix.imageSrc} + size="xsmall" + disabled={disabled} + /> + ); + } + + return isValidElement(prefix) ? <IconContainer>{prefix}</IconContainer> : undefined; }; -const DxcChip = ({ action, disabled = false, label, margin, prefix, size = "medium", tabIndex = 0 }: ChipPropsType) => { - const labelRef = useRef<HTMLSpanElement>(null); - const [isTruncated, setIsTruncated] = useState(true); +const DxcChip = ({ + disabled = false, + label, + mode = "selectable", + onClick, + prefix, + selected, + tabIndex = 0, +}: ChipPropsType) => { + const [innerSelected, setInnerSelected] = useState(false); + + if (mode === "selectable" && isAvatarType(prefix) && !label) { + return null; + } - useEffect(() => { - const handleEllipsisCheck = () => { - setIsTruncated(checkEllipsis(labelRef.current)); - }; + const handleSelectableClick = () => { + if (selected == null) { + setInnerSelected((prev) => !prev); + } + onClick?.(); + }; - handleEllipsisCheck(); - window.addEventListener("resize", handleEllipsisCheck); - return () => window.removeEventListener("resize", handleEllipsisCheck); - }, []); + const isSelected = selected ?? innerSelected; return ( - <Chip margin={margin} size={size}> - {prefix && - (typeof prefix === "string" ? ( - <IconContainer disabled={disabled}> - <DxcIcon icon={prefix} /> - </IconContainer> - ) : isAvatarType(prefix) && size !== "small" ? ( - <DxcAvatar - color={prefix.color} - label={prefix.profileName} - icon={prefix.icon} - imageSrc={prefix.imageSrc} - size="xsmall" - disabled={disabled} - /> - ) : ( - isValidElement(prefix) && <IconContainer disabled={disabled}>{prefix}</IconContainer> - ))} - - {label && ( - <Tooltip label={isTruncated ? label : undefined}> - <LabelContainer disabled={disabled} ref={labelRef}> - {label} - </LabelContainer> - </Tooltip> - )} + <Chip + as={mode === "selectable" ? "button" : "div"} + type={mode === "selectable" ? "button" : undefined} + aria-label={mode === "selectable" ? label || "Chip" : label} + aria-pressed={mode === "selectable" ? isSelected : undefined} + disabled={disabled} + isAvatar={isAvatarType(prefix)} + onClick={mode === "selectable" && !disabled ? handleSelectableClick : undefined} + selected={isSelected} + tabIndex={mode === "selectable" && !disabled ? tabIndex : -1} + mode={mode} + > + <ContentWrapper mode={mode}> + {prefix && renderPrefix(prefix, disabled)} + {label && <LabelContainer>{label}</LabelContainer>} + </ContentWrapper> - {action && ( + {mode === "dismissible" && ( <DxcActionIcon size="xsmall" disabled={disabled} - icon={action.icon} - onClick={action.onClick} + icon="clear" + onClick={onClick} tabIndex={tabIndex} - title={!disabled ? action.title : undefined} + title={!disabled ? "Clear" : undefined} /> )} </Chip> diff --git a/packages/lib/src/chip/types.ts b/packages/lib/src/chip/types.ts index 7400c2a308..24b9db08a2 100644 --- a/packages/lib/src/chip/types.ts +++ b/packages/lib/src/chip/types.ts @@ -1,61 +1,85 @@ -import { Margin, SVG, Space } from "../common/utils"; +import { SVG } from "../common/utils"; import AvatarProps from "../avatar/types"; -type Size = "small" | "medium" | "large"; export type ChipAvatarType = { color: AvatarProps["color"]; profileName?: AvatarProps["label"]; imageSrc?: AvatarProps["imageSrc"]; icon?: AvatarProps["icon"]; }; -type Action = { - /** - * Icon to be placed in the action. - */ - icon: string | SVG; + +type CommonProps = { /** - * This function will be called when the user clicks the action. + * Function to be called when the chip is clicked or the dismiss action is triggered. */ - onClick: () => void; + onClick?: () => void; /** - * Text representing advisory information related - * to the button's action. Under the hood, this prop also serves - * as an accessible label for the component. + * Value of the tabindex attribute. */ - title?: string; + tabIndex?: number; }; -type Props = { - /** - * Action to be displayed on the right side of the chip after the label. - */ - action?: Action; +type SelectableChipProps = CommonProps & { /** * If true, the component will be disabled. */ disabled?: boolean; /** - * Text to be placed on the chip. + * The whole chip is an interactive element that allows users to activate or clear options directly within the interface. */ - label: string; + mode?: "selectable"; /** - * Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge'). - * You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes. + * If true, the component will be selected. This property is only applicable when the mode is "selectable". + * If undefined, the component manages its own internal state (uncontrolled mode). */ - margin?: Space | Margin; + selected?: boolean; +} & ( + | { + /** + * Element, path or avatar used as icon to be placed before the chip label. + */ + prefix: ChipAvatarType; + /** + * Text to be placed on the chip. Required when using an avatar prefix. + */ + label: string; + } + | { + /** + * Element or path used as icon to be placed before the chip label. + */ + prefix: string | SVG; + /** + * Text to be placed on the chip. Optional when using an icon prefix. + */ + label?: string; + } + | { + prefix?: undefined; + /** + * Text to be placed on the chip. + */ + label: string; + } + ); + +type DismissibleChipProps = CommonProps & { /** - * Element, path or avatar used as icon to be placed before the chip label. + * Dismissible chip is used to represent information generated by user input within an interface. */ - prefix?: string | SVG | ChipAvatarType; + mode: "dismissible"; /** - * Size of the component. + * Text to be placed on the chip. */ - size?: Size; - + label: string; /** - * Value of the tabindex attribute. + * Element, path or avatar used as icon to be placed before the chip label. */ - tabIndex?: number; + prefix?: string | SVG | ChipAvatarType; + selected?: never; + disabled?: never; }; +type Props = SelectableChipProps | DismissibleChipProps; + export default Props; diff --git a/packages/lib/src/chip/utils.ts b/packages/lib/src/chip/utils.ts new file mode 100644 index 0000000000..adbb9804d4 --- /dev/null +++ b/packages/lib/src/chip/utils.ts @@ -0,0 +1,59 @@ +import ChipPropsType from "./types"; + +export const getChipStyles = (mode: ChipPropsType["mode"], selected?: boolean) => { + let enabled = ""; + let hover = ""; + let active = ""; + let disabled = ""; + + const commonStyles = ` + &:focus { + border-color: transparent; + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + }`; + + switch (mode) { + case "selectable": + if (selected) { + enabled = `background-color: var(--color-bg-primary-strong); + border-color: transparent; + color: var(--color-fg-neutral-bright);`; + hover = `background-color: var(--color-bg-primary-stronger);`; + active = `background-color: var(--color-bg-primary-stronger);`; + disabled = `background-color: var(--color-bg-neutral-lighter); + border-color: var(--border-color-neutral-medium); + color: var(--color-fg-neutral-medium); + cursor: not-allowed;`; + } else { + enabled = `background-color: var(--color-bg-primary-lightest); + border-color: var(--border-color-primary-stronger); + color: var(--color-fg-primary-strongest);`; + hover = `background-color: var(--color-bg-primary-strong); + border-color: transparent; + color: var(--color-fg-neutral-bright);`; + active = `background-color: var(--color-bg-primary-stronger); + border-color: transparent; + color: var(--color-fg-neutral-bright);`; + disabled = `background-color: var(--color-bg-neutral-lighter); + border-color: var(--border-color-neutral-medium); + color: var(--color-fg-neutral-medium); + cursor: not-allowed;`; + } + return `${commonStyles} + ${enabled} + &:hover:enabled { + ${hover} + } + &:active:enabled { + ${active} + } + &:disabled { + ${disabled} + }`; + case "dismissible": + enabled = `background-color: var(--color-bg-primary-lightest); + border-color: var(--border-color-neutral-lighter); + color: var(--color-fg-neutral-strongest);`; + return `${enabled}`; + } +};