From 931a039f749a57eb5a5a01fdfd338dac1c38d86b Mon Sep 17 00:00:00 2001 From: Sean Collings Date: Fri, 15 Nov 2024 14:35:50 -0700 Subject: [PATCH 01/11] feat(atomic-elements)!: Implemented ChipGroupField to support v3 Collections api The ChipGroupField component implements the Collection API from `@react-aria/collections`. Reimplements `ChipGroup` as a simplified wrapper around ChipGroupField Reimplements `Chip` as a leaf component using `createLeafComponent`. Note that it is still usable stand-alone like it was before BREAKING CHANGE: Chip no longer support `key` prop and instead uses the `id` prop when used within a collection. --- .changeset/dull-wolves-look.md | 5 + .../components/Chips/Chip/Chip.component.tsx | 150 ++++++++------ .../src/components/Chips/Chip/Chip.context.ts | 4 + .../src/components/Chips/Chip/Chip.spec.tsx | 11 +- .../components/Chips/Chip/Chip.stories.tsx | 5 + .../src/components/Chips/Chip/Chip.types.ts | 29 +++ .../Chip/__snapshots__/Chip.spec.tsx.snap | 2 +- .../src/components/Chips/Chip/index.tsx | 4 +- .../Chips/ChipGroup/ChipGroup.component.tsx | 110 ++--------- .../Chips/ChipGroup/ChipGroup.spec.tsx | 8 +- .../Chips/ChipGroup/ChipGroup.stories.tsx | 11 +- .../__snapshots__/ChipGroup.spec.tsx.snap | 8 +- .../ChipGroupField.component.tsx | 185 ++++++++++++++++++ .../ChipGroupField/ChipGroupField.context.ts | 15 ++ .../ChipGroupField/ChipGroupField.spec.tsx | 118 +++++++++++ .../ChipGroupField/ChipGroupField.stories.tsx | 27 +++ .../ChipGroupField/ChipGroupField.styles.ts} | 3 +- .../ChipGroupField.spec.tsx.snap | 69 +++++++ .../components/Fields/ChipGroupField/index.ts | 3 + .../atomic-elements/src/components/index.ts | 4 + 20 files changed, 604 insertions(+), 167 deletions(-) create mode 100644 .changeset/dull-wolves-look.md create mode 100644 packages/atomic-elements/src/components/Chips/Chip/Chip.context.ts create mode 100644 packages/atomic-elements/src/components/Chips/Chip/Chip.types.ts create mode 100644 packages/atomic-elements/src/components/Fields/ChipGroupField/ChipGroupField.component.tsx create mode 100644 packages/atomic-elements/src/components/Fields/ChipGroupField/ChipGroupField.context.ts create mode 100644 packages/atomic-elements/src/components/Fields/ChipGroupField/ChipGroupField.spec.tsx create mode 100644 packages/atomic-elements/src/components/Fields/ChipGroupField/ChipGroupField.stories.tsx rename packages/atomic-elements/src/components/{Chips/ChipGroup/ChipGroup.styles.ts => Fields/ChipGroupField/ChipGroupField.styles.ts} (78%) create mode 100644 packages/atomic-elements/src/components/Fields/ChipGroupField/__snapshots__/ChipGroupField.spec.tsx.snap create mode 100644 packages/atomic-elements/src/components/Fields/ChipGroupField/index.ts diff --git a/.changeset/dull-wolves-look.md b/.changeset/dull-wolves-look.md new file mode 100644 index 00000000..42abe38c --- /dev/null +++ b/.changeset/dull-wolves-look.md @@ -0,0 +1,5 @@ +--- +"@atomicjolt/atomic-elements": major +--- + +Implemented ChipGroupField using the new Collections API & re-implemented ChipGroup & Chip on top of that base diff --git a/packages/atomic-elements/src/components/Chips/Chip/Chip.component.tsx b/packages/atomic-elements/src/components/Chips/Chip/Chip.component.tsx index df8ee24d..48bd29e0 100644 --- a/packages/atomic-elements/src/components/Chips/Chip/Chip.component.tsx +++ b/packages/atomic-elements/src/components/Chips/Chip/Chip.component.tsx @@ -1,98 +1,130 @@ -import React from "react"; -import classNames from "classnames"; -import type { ItemProps } from "react-stately"; -import { PressEvent, PressProps } from "@react-aria/interactions"; -import { AriaButtonProps } from "@react-aria/button"; -import { mergeProps } from "@react-aria/utils"; +import React, { useContext } from "react"; +import { filterDOMProps, mergeProps, useObjectRef } from "@react-aria/utils"; +import { useTag } from "@react-aria/tag"; +import { createLeafComponent } from "@react-aria/collections"; -import { copyStaticProperties } from "@utils/clone"; -import { useVariantClass } from "@hooks/variants"; -import { Item } from "@components/Collection"; import { IconButton } from "@components/Buttons/IconButton"; import { useConditionalPress } from "@hooks/useConditionalPress"; +import { useFocusRing } from "@hooks/useFocusRing"; +import { useContextPropsV2 } from "@hooks/useContextProps"; +import { useRenderProps } from "@hooks"; +import { ChipGroupStateContext } from "@components/Fields/ChipGroupField/ChipGroupField.context"; + +import { ChipArgs, ChipGroupChipProps, ChipInternalProps } from "./Chip.types"; import { ChipContent, ChipWrapper } from "./Chip.styles"; -import { SuggestStrings, HasClassName } from "../../../types"; +import { ChipContext } from "./Chip.context"; -type ChipVariants = SuggestStrings< - "default" | "warning" | "success" | "danger" ->; +export function ChipLeaf(...args: ChipArgs) { + const [props, ref] = useContextPropsV2(ChipContext, args[0], args[1]); -export interface ChipProps extends ItemProps, PressProps, HasClassName { - children: React.ReactNode; - variant?: ChipVariants; - /** Handler that is called when the user - * clicks the remove button for the chip */ - onRemove?: (e: PressEvent) => void; - isDisabled?: boolean; -} + // We're being rendered standalone + if (args.length === 2) { + return ( + + ); + } -/** Chip component */ -export function Chip(props: ChipProps) { - return ; + // We're being rendered as part of a collection (i.e ChipGroup) + const item = args[2]; + return ; } -copyStaticProperties(Item, Chip); +/** + * Chip component. Can be used stand-alone, or within a parent + * `ChipGroup` + */ +export const Chip = createLeafComponent("item", ChipLeaf); + +function ChipGroupChip(props: ChipGroupChipProps) { + const { item } = props; + const ref = useObjectRef(props.itemRef); + const state = useContext(ChipGroupStateContext)!; + const { + rowProps, + gridCellProps, + removeButtonProps, + allowsRemoving, + isFocused, + isSelected, + } = useTag(props, state, ref); + + const { focusProps, isFocusVisible } = useFocusRing({ within: true }); + + const isDisabled = state.disabledKeys.has(item.key); -interface ChipInternalProps extends ChipProps { - wrapperProps?: React.DOMAttributes; - contentProps?: React.DOMAttributes; - removeButtonProps?: AriaButtonProps<"button">; - allowsRemoving?: boolean; + const renderProps = useRenderProps({ + componentClassName: "aje-chip", + values: { + isSelected, + isFocusVisible, + isFocused, + }, + ...props, + }); + + return ( + + + {renderProps.children} + {allowsRemoving && ( + + )} + + + ); } -export const ChipInternal = React.forwardRef< - HTMLDivElement, - ChipInternalProps ->(function ChipInternal( +export const ChipInternal = React.forwardRef(function ChipInternal( props: ChipInternalProps, ref: React.Ref ) { const { className, - variant = "default", + variant, onRemove, isDisabled, children, - wrapperProps = {}, - contentProps = {}, - removeButtonProps = {}, allowsRemoving = false, ...rest } = props; - const variantClass = useVariantClass("aje-chip", variant); const { pressProps } = useConditionalPress(rest); - const allWrapperProps = [ - wrapperProps, - { "aria-disabled": isDisabled || undefined }, - ]; - - if (!isDisabled) { - allWrapperProps.push(pressProps); - } + const renderProps = useRenderProps({ + componentClassName: "aje-chip", + values: { + isSelected: false, + isFocusVisible: false, + isFocused: false, + }, + ...props, + }); return ( - - {children} - + + {renderProps.children} {allowsRemoving && ( )} diff --git a/packages/atomic-elements/src/components/Chips/Chip/Chip.context.ts b/packages/atomic-elements/src/components/Chips/Chip/Chip.context.ts new file mode 100644 index 00000000..3c76fb8e --- /dev/null +++ b/packages/atomic-elements/src/components/Chips/Chip/Chip.context.ts @@ -0,0 +1,4 @@ +import { createComponentContext } from "@utils/index"; +import { ChipProps } from "./Chip.types"; + +export const ChipContext = createComponentContext>(); diff --git a/packages/atomic-elements/src/components/Chips/Chip/Chip.spec.tsx b/packages/atomic-elements/src/components/Chips/Chip/Chip.spec.tsx index f31188f5..ffd00b3d 100644 --- a/packages/atomic-elements/src/components/Chips/Chip/Chip.spec.tsx +++ b/packages/atomic-elements/src/components/Chips/Chip/Chip.spec.tsx @@ -1,5 +1,5 @@ -import { render } from "@testing-library/react"; -import { expect, describe, test } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, describe, test, vi } from "vitest"; import { Chip } from "."; describe("Chip", () => { @@ -7,4 +7,11 @@ describe("Chip", () => { const result = render(Item); expect(result.asFragment()).toMatchSnapshot(); }); + + test("onRemove", () => { + const onRemove = vi.fn(); + render(Item); + fireEvent.click(screen.getByRole("button")); + expect(onRemove).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/atomic-elements/src/components/Chips/Chip/Chip.stories.tsx b/packages/atomic-elements/src/components/Chips/Chip/Chip.stories.tsx index 1d377aa0..4569d5ac 100644 --- a/packages/atomic-elements/src/components/Chips/Chip/Chip.stories.tsx +++ b/packages/atomic-elements/src/components/Chips/Chip/Chip.stories.tsx @@ -23,6 +23,11 @@ export default { category: "Events", }, }, + onAction: { + table: { + disable: true, + }, + }, }, } as Meta; diff --git a/packages/atomic-elements/src/components/Chips/Chip/Chip.types.ts b/packages/atomic-elements/src/components/Chips/Chip/Chip.types.ts new file mode 100644 index 00000000..305d39ae --- /dev/null +++ b/packages/atomic-elements/src/components/Chips/Chip/Chip.types.ts @@ -0,0 +1,29 @@ +import { Node } from "react-stately"; +import { AriaLabelProps, DomProps, SuggestStrings } from "../../../types"; +import { PressEvent, PressProps } from "@react-aria/interactions"; +import { ItemProps } from "../../Collection"; + +type ChipVariants = SuggestStrings< + "default" | "warning" | "success" | "danger" +>; + +export interface ChipProps extends ItemProps, PressProps { + variant?: ChipVariants; + /** Handler that is called when the user + * clicks the remove button for the chip */ + onRemove?: (e: PressEvent) => void; + isDisabled?: boolean; +} + +export type ChipArgs = + | [ChipProps, React.ForwardedRef] + | [ChipProps, React.ForwardedRef, Node]; + +export interface ChipGroupChipProps extends ChipProps { + item: Node; + itemRef: React.ForwardedRef; +} + +export interface ChipInternalProps extends ChipProps { + allowsRemoving?: boolean; +} diff --git a/packages/atomic-elements/src/components/Chips/Chip/__snapshots__/Chip.spec.tsx.snap b/packages/atomic-elements/src/components/Chips/Chip/__snapshots__/Chip.spec.tsx.snap index 8f5b6891..e3ec1788 100644 --- a/packages/atomic-elements/src/components/Chips/Chip/__snapshots__/Chip.spec.tsx.snap +++ b/packages/atomic-elements/src/components/Chips/Chip/__snapshots__/Chip.spec.tsx.snap @@ -3,7 +3,7 @@ exports[`Chip > matches snapshot 1`] = `
extends AriaProps>, @@ -22,93 +11,36 @@ export interface ChipGroupProps labelPlacement?: "above" | "inline"; } -/** Collection Component for displaying a group of chips. - * Chips can be selected and removed by the user. +/** + * A generic ChipGroup component that renders a group of chips with optional labels, messages, and error messages. */ export function ChipGroup(props: ChipGroupProps) { const { label, message, error, - isInvalid, - isDisabled, - isRequired, - className, labelPlacement = "above", - size = "full", + items, + children, + ...rest } = props; - const ref = useRef(null); - - const state = useListState(props); - const { gridProps, labelProps, descriptionProps, errorMessageProps } = - useTagGroup(props, state, ref); - return ( - + {labelPlacement === "above" && label && ( - + )} - - {labelPlacement === "inline" && label && ( - - )} - {[...state.collection].map((item) => ( - - ))} - - {message && {message}} - {isInvalid && error && ( - {error} - )} - - ); -} - -// NOTE: when Chip is rendered standalone, it renders the actual Chip component -// When rendered within a ChipGroup, it actually renders the ChipInternal component - -interface ChipGroupChipProps extends AriaTagProps { - state: ListState; -} - -function ChipGroupChip(props: ChipGroupChipProps) { - const { item, state } = props; - const ref = useRef(null); - const { rowProps, gridCellProps, removeButtonProps, allowsRemoving } = useTag( - props, - state, - ref - ); - - const { focusProps } = useFocusRing({ within: true }); - - const isDisabled = state.disabledKeys.has(item.key); - - return ( - - {item.rendered} - + {label} + } + > + {children} + + {message && {message}} + {error && {error}} + ); } diff --git a/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.spec.tsx b/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.spec.tsx index cfcddbfb..6b168263 100644 --- a/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.spec.tsx +++ b/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.spec.tsx @@ -24,13 +24,13 @@ describe("ChipGroup", () => { test("renders chips", () => { const chips = [ - { key: "1", rendered: "Chip 1" }, - { key: "2", rendered: "Chip 2" }, - { key: "3", rendered: "Chip 3" }, + { id: "1", rendered: "Chip 1" }, + { id: "2", rendered: "Chip 2" }, + { id: "3", rendered: "Chip 3" }, ]; render( - {({ key, rendered }) => {rendered}} + {({ rendered }) => {rendered}} ); chips.forEach((chip) => { diff --git a/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.stories.tsx b/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.stories.tsx index 2e8e01ed..87d29309 100644 --- a/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.stories.tsx +++ b/packages/atomic-elements/src/components/Chips/ChipGroup/ChipGroup.stories.tsx @@ -20,6 +20,9 @@ export default { }, onRemove: { description: "Function to call when a chip is removed", + table: { + category: "Events", + }, }, }, } as Meta; @@ -30,10 +33,10 @@ export const Primary: Story = { args: { label: "Chip Group", children: [ - News, - Travel, - Gaming, - Shopping, + News, + Travel, + Gaming, + Shopping, ], }, }; diff --git a/packages/atomic-elements/src/components/Chips/ChipGroup/__snapshots__/ChipGroup.spec.tsx.snap b/packages/atomic-elements/src/components/Chips/ChipGroup/__snapshots__/ChipGroup.spec.tsx.snap index b460350b..27b205bf 100644 --- a/packages/atomic-elements/src/components/Chips/ChipGroup/__snapshots__/ChipGroup.spec.tsx.snap +++ b/packages/atomic-elements/src/components/Chips/ChipGroup/__snapshots__/ChipGroup.spec.tsx.snap @@ -3,7 +3,7 @@ exports[`ChipGroup > matches snapshot 1`] = `