diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index cb2a58a230..06220747fc 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [3.29.0](https://github.com/mondaycom/vibe/compare/@vibe/core@3.28.2...@vibe/core@3.29.0) (2025-02-24) + + +### Features + +* **List:** add role props ([#2771](https://github.com/mondaycom/vibe/issues/2771)) ([a079690](https://github.com/mondaycom/vibe/commit/a07969084be9088801263eac29d65aa344adf040)) + + + + + ## [3.28.2](https://github.com/mondaycom/vibe/compare/@vibe/core@3.28.1...@vibe/core@3.28.2) (2025-02-19) diff --git a/packages/core/package.json b/packages/core/package.json index 2a45f4908b..7c853035f9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@vibe/core", - "version": "3.28.2", + "version": "3.29.0", "description": "Official monday.com UI resources for application development in React.js", "repository": { "type": "git", diff --git a/packages/core/src/components/Avatar/__stories__/Avatar.stories.tsx b/packages/core/src/components/Avatar/__stories__/Avatar.stories.tsx index ed07503119..1c7c4e3882 100644 --- a/packages/core/src/components/Avatar/__stories__/Avatar.stories.tsx +++ b/packages/core/src/components/Avatar/__stories__/Avatar.stories.tsx @@ -69,7 +69,6 @@ export default { export const Overview = { render: avatarTemplate.bind({}), name: "Overview", - args: { size: "large", src: window.location.origin + "/" + person1, @@ -87,54 +86,54 @@ export const Overview = { export const Size = { render: () => ( - <> + - + ) }; export const Disable = { render: () => ( - <> + - + ) }; export const AvatarWithText = { render: () => ( - <> + - + ) }; export const SquareAvatar = { render: () => ( - <> + - + ) }; export const AvatarWithRightBadge = { render: () => ( - <> + - + ), parameters: { docs: { @@ -147,11 +146,10 @@ export const AvatarWithRightBadge = { export const AvatarWithLeftBadge = { render: () => ( - <> - {" "} + - + ), parameters: { docs: { @@ -211,9 +209,11 @@ export const ClickableAvatar = { }, []); return ( - - - + + + + + ); } diff --git a/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.module.scss b/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.module.scss deleted file mode 100644 index 58a87d780b..0000000000 --- a/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.module.scss +++ /dev/null @@ -1,8 +0,0 @@ -.sliderCountContainer { - width: 100%; - align-content: start; -} - -.lastClickedAvatarIdText { - margin-left: var(--sb-spacing-medium); -} diff --git a/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.tsx b/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.tsx index 8a82fdea2c..2347705c1d 100644 --- a/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.tsx +++ b/packages/core/src/components/AvatarGroup/__stories__/AvatarGroup.stories.tsx @@ -15,7 +15,6 @@ import TableHeaderCell from "../../Table/TableHeaderCell/TableHeaderCell"; import TableBody from "../../Table/TableBody/TableBody"; import TableRow from "../../Table/TableRow/TableRow"; import TableCell from "../../Table/TableCell/TableCell"; -import styles from "./AvatarGroup.stories.module.scss"; const metaSettings = createStoryMetaSettingsDecorator({ component: AvatarGroup @@ -48,7 +47,7 @@ export default { parameters: { docs: { liveEdit: { - scope: { styles, StoryDescription, person1, person2, person3 } + scope: { StoryDescription, person1, person2, person3, person4 } } } } @@ -89,6 +88,9 @@ export const Overview: StoryObj = { person4: window.location.origin + "/" + person4 } }, + argTypes: { + persons: { table: { disable: true } } + }, parameters: { docs: { liveEdit: { @@ -239,7 +241,7 @@ export const MaxAvatarsToDisplay: Story = { const [max, setMax] = useState(4); return ( - + = forwardRef( + ( + { + label, + size = "medium", + selected, + disabled, + startElement, + endElement, + highlighted, + tooltipProps = {}, + className, + rtl = false, + id, + role = "option", + ...rest + }: BaseListItemProps, + ref + ) => { + const listItemClassNames = useMemo( + () => + cx( + styles.wrapper, + { + [styles.selected]: selected, + [styles.disabled]: disabled, + [styles.highlighted]: highlighted + }, + getStyle(styles, size), + className + ), + [selected, disabled, highlighted, size, className] + ); + + const textVariant: TextType = size === "small" ? "text2" : "text1"; + + return ( + +
  • + {startElement && renderSideElement(startElement, disabled, textVariant)} + + {label} + + {endElement && ( +
    {renderSideElement(endElement, disabled, textVariant)}
    + )} +
  • +
    + ); + } +); + +export default BaseListItem; diff --git a/packages/core/src/components/BaseListItem/BaseListItem.types.ts b/packages/core/src/components/BaseListItem/BaseListItem.types.ts new file mode 100644 index 0000000000..efa9f453fa --- /dev/null +++ b/packages/core/src/components/BaseListItem/BaseListItem.types.ts @@ -0,0 +1,63 @@ +import React, { ReactNode, AriaRole } from "react"; +import { SubIcon, VibeComponentProps } from "../../types"; +import { TooltipProps } from "../Tooltip"; + +export interface BaseListItemProps extends React.LiHTMLAttributes, VibeComponentProps { + value?: string; + /** + * Primary text content of the list item + */ + label: string; + /** + * Size of the list item. Will influence the padding and font size. + */ + size?: BaseListItemSizes; + /** + * Indicates whether the list item is selected. + */ + selected?: boolean; + /** + * Indicates whether the list item is disabled. + */ + disabled?: boolean; + /** + * Element to render at the start of the list item. + * Can be an avatar, icon, inset or a custom rendered element. + */ + startElement?: StartElement; + /** + * Element to render at the end of the list item. + * Can be an icon, suffix, or a custom rendered element. + */ + endElement?: EndElement; + /** + * Whether item should have highlight styling + */ + highlighted?: boolean; + /** + * Use when there's a need to display a tooltip on the list item (e.g., explain why disabled). + */ + tooltipProps?: Partial; + /** + * determines the position of the tooltip according to the direction. + */ + rtl?: boolean; + /** + * ARIA role for the list item. + */ + role?: AriaRole; + index?: number; +} + +export type BaseListItemSizes = "small" | "medium" | "large"; + +export type SideElement = + | { type: "avatar"; value: string; square?: boolean } + | { type: "icon"; value: SubIcon } + | { type: "indent" } + | { type: "suffix"; value: string } + | { type: "custom"; render: () => ReactNode }; + +export type StartElement = Extract; + +export type EndElement = Extract; diff --git a/packages/core/src/components/BaseListItem/__stories__/BaseListItem.stories.tsx b/packages/core/src/components/BaseListItem/__stories__/BaseListItem.stories.tsx new file mode 100644 index 0000000000..3e20815e96 --- /dev/null +++ b/packages/core/src/components/BaseListItem/__stories__/BaseListItem.stories.tsx @@ -0,0 +1,32 @@ +import { createStoryMetaSettingsDecorator } from "../../../storybook"; +import { createComponentTemplate } from "vibe-storybook-components"; +import BaseListItem from "../BaseListItem"; +import { Meta, StoryObj } from "@storybook/react"; + +type Story = StoryObj; + +const metaSettings = createStoryMetaSettingsDecorator({ + component: BaseListItem +}); + +export default { + title: "Internal/BaseListItem", + component: BaseListItem, + argTypes: metaSettings.argTypes, + decorators: metaSettings.decorators, + tags: ["internal"] +} satisfies Meta; + +const baseListItemTemplate = createComponentTemplate(BaseListItem); +import { Email } from "@vibe/icons"; +import person1 from "./person1.png"; + +export const Overview: Story = { + render: baseListItemTemplate.bind({}), + args: { + label: "This is a list item", + startElement: { type: "avatar", value: person1 }, + endElement: { type: "icon", value: Email }, + tooltipProps: { content: "tooltip content" } + } +}; diff --git a/packages/core/src/components/BaseListItem/__stories__/person1.png b/packages/core/src/components/BaseListItem/__stories__/person1.png new file mode 100644 index 0000000000..e25a6bfa1c Binary files /dev/null and b/packages/core/src/components/BaseListItem/__stories__/person1.png differ diff --git a/packages/core/src/components/BaseListItem/__tests__/BaseListItem.test.tsx b/packages/core/src/components/BaseListItem/__tests__/BaseListItem.test.tsx new file mode 100644 index 0000000000..a22f1c4c3a --- /dev/null +++ b/packages/core/src/components/BaseListItem/__tests__/BaseListItem.test.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import BaseListItem from "../BaseListItem"; +import { BaseListItemProps, StartElement, EndElement } from "../BaseListItem.types"; + +const startElement: StartElement = { + type: "avatar", + value: "avatar.png", + square: true +}; + +const endElement: EndElement = { + type: "icon", + value: "check" +}; + +function renderBaseListItem(props?: BaseListItemProps) { + return render(); +} + +describe("BaseListItem", () => { + const label = "Default Item"; + + it("should render correctly with all props", () => { + const { getByText } = renderBaseListItem({ + label, + value: "item1", + size: "large", + selected: true, + disabled: false, + highlighted: true, + startElement, + endElement, + className: "custom-wrapper" + }); + + expect(getByText("Default Item").parentNode).toHaveClass("large"); + expect(getByText("Default Item").parentNode).toHaveClass("selected"); + expect(getByText("Default Item").parentNode).toHaveClass("highlighted"); + expect(getByText("Default Item").parentNode).toHaveClass("custom-wrapper"); + }); + + describe("with declared props", () => { + it("should apply the size class", () => { + const { getByText } = renderBaseListItem({ label, size: "large" }); + expect(getByText("Default Item").parentNode).toHaveClass("large"); + }); + + it("should show start and end elements when provided", () => { + const { getByTestId } = renderBaseListItem({ + label, + startElement, + endElement + }); + + expect(getByTestId("avatar-content")).toBeInTheDocument(); + expect(getByTestId("icon")).toBeInTheDocument(); + }); + + it("should apply the selected class", () => { + const { getByText } = renderBaseListItem({ label, selected: true }); + expect(getByText("Default Item").parentNode).toHaveClass("selected"); + }); + + it("should apply the disabled class", () => { + const { getByText } = renderBaseListItem({ label, disabled: true }); + expect(getByText("Default Item").parentNode).toHaveClass("disabled"); + }); + + it("should apply the highlighted class", () => { + const { getByText } = renderBaseListItem({ label, highlighted: true }); + expect(getByText("Default Item").parentNode).toHaveClass("highlighted"); + }); + + it("should have role option", () => { + const { getByRole } = renderBaseListItem({ label }); + expect(getByRole("option")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core/src/components/BaseListItem/index.ts b/packages/core/src/components/BaseListItem/index.ts new file mode 100644 index 0000000000..7bbee6ea48 --- /dev/null +++ b/packages/core/src/components/BaseListItem/index.ts @@ -0,0 +1,2 @@ +export { default as BaseListItem } from "./BaseListItem"; +export * from "./BaseListItem.types"; diff --git a/packages/core/src/components/BaseListItem/utils.tsx b/packages/core/src/components/BaseListItem/utils.tsx new file mode 100644 index 0000000000..fde32ffde9 --- /dev/null +++ b/packages/core/src/components/BaseListItem/utils.tsx @@ -0,0 +1,44 @@ +import { EndElement, StartElement } from "./BaseListItem.types"; +import { TextType } from "../Text"; +import React from "react"; +import Avatar from "../Avatar/Avatar"; +import styles from "./BaseListItem.module.scss"; +import Icon from "../Icon/Icon"; +import Text from "../Text/Text"; + +export function renderSideElement( + element: StartElement | EndElement, + disabled: boolean, + textVariant: TextType +): React.ReactNode { + switch (element.type) { + case "avatar": + return ( + + ); + + case "icon": + return ; + + case "indent": + return
    ; + + case "suffix": + return ( + + {element.value} + + ); + + case "custom": + return element.render(); + } +} diff --git a/packages/core/src/components/Divider/Divider.tsx b/packages/core/src/components/Divider/Divider.tsx index 2d74040e61..03e7fd0a4a 100644 --- a/packages/core/src/components/Divider/Divider.tsx +++ b/packages/core/src/components/Divider/Divider.tsx @@ -8,7 +8,13 @@ import { withStaticProps, VibeComponentProps } from "../../types"; import styles from "./Divider.module.scss"; export interface DividerProps extends VibeComponentProps { + /** + * The direction of the divider 'horizontal' or 'vertical'. + */ direction?: DividerDirection; + /** + * Removes margin from the divider. + */ withoutMargin?: boolean; } diff --git a/packages/core/src/components/Divider/__stories__/Divider.mdx b/packages/core/src/components/Divider/__stories__/Divider.mdx index 1d78e2c585..daf30d0770 100644 --- a/packages/core/src/components/Divider/__stories__/Divider.mdx +++ b/packages/core/src/components/Divider/__stories__/Divider.mdx @@ -1,4 +1,4 @@ -import { Canvas, Meta } from "@storybook/blocks"; +import { Meta } from "@storybook/blocks"; import { CHIP, ICONS, LABEL } from "../../../storybook/components/related-components/component-description-map"; import * as DividerStories from "./Divider.stories"; diff --git a/packages/core/src/components/Divider/__stories__/Divider.stories.module.scss b/packages/core/src/components/Divider/__stories__/Divider.stories.module.scss deleted file mode 100644 index 49e57933ca..0000000000 --- a/packages/core/src/components/Divider/__stories__/Divider.stories.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.divider-description { - &-container { - display: flex; - align-items: center; - height: 200px; - } - - &-text { - margin-right: var(--sb-spacing-large); - align-self: center; - } - - &-horizontal { - margin: 0 auto !important; - } -} diff --git a/packages/core/src/components/Divider/__stories__/Divider.stories.tsx b/packages/core/src/components/Divider/__stories__/Divider.stories.tsx index 6bfd50252f..ea3dffbe13 100644 --- a/packages/core/src/components/Divider/__stories__/Divider.stories.tsx +++ b/packages/core/src/components/Divider/__stories__/Divider.stories.tsx @@ -1,7 +1,6 @@ import React from "react"; import Divider, { DividerProps } from "../Divider"; import { createStoryMetaSettingsDecorator } from "../../../storybook"; -import styles from "./Divider.stories.module.scss"; const metaSettings = createStoryMetaSettingsDecorator({ component: Divider @@ -22,7 +21,15 @@ export default { export const Overview = { render: dividerTemplate.bind({}), - name: "Overview" + name: "Overview", + args: {}, + parameters: { + docs: { + liveEdit: { + isEnabled: false + } + } + } }; export const Directions = { @@ -35,17 +42,38 @@ export const Directions = { }} >
    - Horizontal + + Horizontal +
    -
    - Vertical - +
    + + Vertical + +
    ), diff --git a/packages/core/src/components/List/List.tsx b/packages/core/src/components/List/List.tsx index 6710420c7a..29a0c2eb9f 100644 --- a/packages/core/src/components/List/List.tsx +++ b/packages/core/src/components/List/List.tsx @@ -1,6 +1,7 @@ import cx from "classnames"; import React, { AriaAttributes, + AriaRole, CSSProperties, forwardRef, ReactElement, @@ -52,6 +53,10 @@ export interface ListProps extends VibeComponentProps { */ renderOnlyVisibleItems?: boolean; style?: CSSProperties; + /** + * ARIA role for the list. + */ + role?: AriaRole; } const List: VibeComponent & { @@ -68,6 +73,7 @@ const List: VibeComponent & { "aria-controls": ariaControls, renderOnlyVisibleItems = false, style, + role = "listbox", "data-testid": dataTestId }: ListProps, ref @@ -139,7 +145,7 @@ const List: VibeComponent & { if (!React.isValidElement(child)) { return child; } - const id = (child.props as { id: string }).id || `${overrideId}-item-${index}`; + const id = (child.props as ListItemProps).id || `${overrideId}-item-${index}`; const currentRef = childrenRefs.current[index]; const isFocusableItem = currentRef === undefined || currentRef === null || isListItem(currentRef); return React.cloneElement(child, { @@ -147,7 +153,8 @@ const List: VibeComponent & { ref: ref => (childrenRefs.current[index] = ref), tabIndex: focusIndex === index && isFocusableItem ? 0 : -1, id, - component: getListItemComponentType(component) + component: getListItemComponentType(component), + role: (child.props as ListItemProps).role }); }); } @@ -167,7 +174,7 @@ const List: VibeComponent & { aria-describedby={ariaDescribedBy} aria-controls={ariaControls} tabIndex={-1} - role="listbox" + role={role} > {overrideChildren} diff --git a/packages/core/src/components/List/__tests__/List.test.js b/packages/core/src/components/List/__tests__/List.test.js index 4cfc31bc8e..086410ac11 100644 --- a/packages/core/src/components/List/__tests__/List.test.js +++ b/packages/core/src/components/List/__tests__/List.test.js @@ -102,4 +102,63 @@ describe("List", () => { expect(list).toHaveAttribute("aria-activedescendant", "list-item-1"); }); }); + + describe("custom roles", () => { + it("List render with a custom role", () => { + const { getByRole, getAllByRole } = render( + + 1 + 2 + + ); + expect(getByRole("list")).toBeInTheDocument(); + expect(getAllByRole("listitem")).toHaveLength(2); + }); + + it("selected ListItem should have aria-selected with custom roles", () => { + const { getByTestId } = render( + + + 1 + + + 2 + + + ); + expect(getByTestId("list-item-1")).toHaveAttribute("aria-selected", "true"); + expect(getByTestId("list-item-2")).not.toHaveAttribute("aria-selected"); + }); + + it("List should have aria-activedescendant with custom roles", () => { + const { getByRole } = render( + + + 1 + + + 2 + + + ); + expect(getByRole("list")).toHaveAttribute("aria-activedescendant", "list-item-2"); + }); + + it("List aria-activedescendant with custom roles", () => { + const { getByRole } = render( + + 1 + 2 + + 3 + + + ); + const list = getByRole("list"); + expect(list).toHaveAttribute("aria-activedescendant", "list-item-2"); + userEvent.tab(); + userEvent.keyboard("{arrowup}"); + expect(list).toHaveAttribute("aria-activedescendant", "list-item-1"); + }); + }); }); diff --git a/packages/core/src/components/List/utils/ListUtils.ts b/packages/core/src/components/List/utils/ListUtils.ts index e3560738f0..b329a2ad64 100644 --- a/packages/core/src/components/List/utils/ListUtils.ts +++ b/packages/core/src/components/List/utils/ListUtils.ts @@ -8,6 +8,8 @@ export const generateListId = () => { return `list-${listIdCounter++}`; }; +const VALID_ROLES = ["option", "listitem", "menuitem", "tab", "treeitem"]; + export const useListId = (id: string) => { const [listId, setListId] = useState(); useIsomorphicLayoutEffect(() => { @@ -37,7 +39,7 @@ export const getListItemComponentType = (listComponent: ListElement): ListItemEl }; export const isListItem = (element: HTMLElement) => { - return element && element instanceof HTMLElement && element.getAttribute("role") === "option"; + return element && element instanceof HTMLElement && VALID_ROLES.includes(element.getAttribute("role")); }; export const getNextListItemIndex = (currentIndex: number, childrenRefs: MutableRefObject) => { diff --git a/packages/core/src/components/ListItem/ListItem.tsx b/packages/core/src/components/ListItem/ListItem.tsx index 2e0c2a1c01..beb1337d3d 100644 --- a/packages/core/src/components/ListItem/ListItem.tsx +++ b/packages/core/src/components/ListItem/ListItem.tsx @@ -1,5 +1,5 @@ import cx from "classnames"; -import React, { AriaAttributes, forwardRef, useCallback, useContext, useEffect, useRef } from "react"; +import React, { AriaAttributes, AriaRole, forwardRef, useCallback, useContext, useEffect, useRef } from "react"; import { camelCase } from "lodash-es"; import { getStyle } from "../../helpers/typesciptCssModulesHelper"; import Text from "../Text/Text"; @@ -63,6 +63,10 @@ export interface ListItemProps extends VibeComponentProps { */ tabIndex?: number; "aria-current"?: AriaAttributes["aria-current"]; + /** + * ARIA role for the list item. + */ + role?: AriaRole; } const ListItem: VibeComponent & { sizes?: typeof SIZES; components?: typeof ListItemComponentTypeEnum } = @@ -80,7 +84,8 @@ const ListItem: VibeComponent & { sizes?: typeof SIZES; component tabIndex = 0, children, "aria-current": ariaCurrent, - "data-testid": dataTestId + "data-testid": dataTestId, + role = "option" }: ListItemProps, ref ) => { @@ -132,7 +137,7 @@ const ListItem: VibeComponent & { sizes?: typeof SIZES; component onClick={componentOnClick} onMouseEnter={componentOnHover} onFocus={componentOnHover} - role="option" + role={role} tabIndex={tabIndex} aria-current={ariaCurrent} >