From 42c3addbe59afa55478dd230e0173935f6d9287c Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 26 Nov 2025 15:06:39 +0100 Subject: [PATCH 1/5] feat(Disclosure): add component --- .changeset/add-disclosure-component.md | 14 + .../content/Disclosure/Disclosure.docs.mdx | 209 ++++++ .../content/Disclosure/Disclosure.stories.tsx | 421 ++++++++++++ .../content/Disclosure/Disclosure.test.tsx | 489 +++++++++++++ .../content/Disclosure/Disclosure.tsx | 640 ++++++++++++++++++ src/components/content/Disclosure/index.ts | 1 + src/components/form/Form/RULES.md | 82 +++ .../DisplayTransition/DisplayTransition.tsx | 52 +- src/index.ts | 9 + src/tokens.ts | 1 + 10 files changed, 1903 insertions(+), 15 deletions(-) create mode 100644 .changeset/add-disclosure-component.md create mode 100644 src/components/content/Disclosure/Disclosure.docs.mdx create mode 100644 src/components/content/Disclosure/Disclosure.stories.tsx create mode 100644 src/components/content/Disclosure/Disclosure.test.tsx create mode 100644 src/components/content/Disclosure/Disclosure.tsx create mode 100644 src/components/content/Disclosure/index.ts create mode 100644 src/components/form/Form/RULES.md diff --git a/.changeset/add-disclosure-component.md b/.changeset/add-disclosure-component.md new file mode 100644 index 000000000..7641d611d --- /dev/null +++ b/.changeset/add-disclosure-component.md @@ -0,0 +1,14 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add new `Disclosure` component for expandable/collapsible content sections. Features include: + +- `Disclosure` - Single expandable panel with trigger and content +- `Disclosure.Trigger` - Built on ItemButton with full support for icons, descriptions, and actions +- `Disclosure.Content` - Collapsible content area with smooth height animations +- `Disclosure.Group` - Accordion container for multiple disclosures with single or multiple expanded support +- `Disclosure.Item` - Individual item within a group + +Supports controlled/uncontrolled state, `shape` variants (`default`, `card`, `sharp`), disabled state, custom transition duration, and render prop API for custom triggers. + diff --git a/src/components/content/Disclosure/Disclosure.docs.mdx b/src/components/content/Disclosure/Disclosure.docs.mdx new file mode 100644 index 000000000..38e326c2f --- /dev/null +++ b/src/components/content/Disclosure/Disclosure.docs.mdx @@ -0,0 +1,209 @@ +import { Meta, Canvas, Story, Controls } from '@storybook/addon-docs/blocks'; +import { Disclosure } from './Disclosure'; +import * as DisclosureStories from './Disclosure.stories'; + + + +# Disclosure + +An accessible collapsible container that reveals or hides additional content. Built on React Aria's `useDisclosure` hook, it provides keyboard navigation, screen reader support, and smooth height animations. Supports both standalone usage and grouped accordion behavior. + +## When to Use + +- For FAQ sections where users can expand answers to questions +- To hide supplementary information that shouldn't overwhelm the main content +- For accordion-style navigation or settings panels +- When you need collapsible sections with consistent accessibility patterns +- To progressively disclose complex information + +## Component + + + +--- + +### Properties + + + +### Base Properties + +Supports [Base properties](/BaseProperties) + +## Compound Components + +### Disclosure.Trigger + +The clickable button that toggles the disclosure. Built on `ItemButton` and accepts all ItemButton props including `icon`, `type`, `theme`, `size`, and more. + +### Disclosure.Content + +The collapsible panel containing the hidden content. Animates smoothly using CSS `interpolate-size: allow-keywords` for height transitions. + +### Disclosure.Group + +Container for multiple disclosure items that coordinates their expanded states. By default, only one item can be expanded at a time (accordion behavior). + + + +### Disclosure.Item + +Individual disclosure within a group. Must have a unique `id` prop when used inside `Disclosure.Group`. + +## Styling + +### styles + +Customizes the root element of the Disclosure component. + +### triggerStyles + +When using `Disclosure.Trigger`, you can pass `styles` prop to customize the trigger button. + +### contentStyles + +When using `Disclosure.Content`, you can pass `styles` prop to customize the content panel. + +### Group-level Styling + +`Disclosure.Group` accepts `triggerProps` to apply consistent props to all triggers, and `contentStyles` to style all content panels uniformly. + +### Style Properties + +Direct style application without using the `styles` prop: `width`, `height`, `padding`, `margin`, `gap`. + +### Modifiers + +The `mods` property accepts the following modifiers: + +| Modifier | Type | Description | +|----------|------|-------------| +| `expanded` | `boolean` | True when the disclosure content is visible | +| `disabled` | `boolean` | True when interactions are disabled | +| `shape` | `string` | The current shape variant (`default`, `card`, `sharp`) | + +For `Disclosure.Content`, additional modifiers are available: + +| Modifier | Type | Description | +|----------|------|-------------| +| `shown` | `boolean` | True when content is visible (controls height animation) | +| `phase` | `string` | Transition phase (`enter`, `entered`, `exit`, `unmounted`) | + +## Variants + +### Shapes + +#### Card + +Bordered container with rounded corners. + + + +#### Sharp + +Sharp edges with no border radius. + + + +## Examples + +### Default Expanded + +Disclosure that starts in expanded state using `defaultExpanded` prop. + + + +### Disabled + +Disabled disclosure that cannot be toggled. + + + +### Controlled State + +Control the disclosure state externally with `isExpanded` and `onExpandedChange`. + + + +### Multiple Expanded Items + +Group with `allowsMultipleExpanded` where multiple items can be open simultaneously. + + + +### Default Expanded Keys + +Group with specific items pre-expanded via `defaultExpandedKeys`. + + + +### Custom Trigger with Render Prop + +Use render prop pattern to create custom triggers with access to `isExpanded` and `toggle`. + + + +### Disabled Group + +Group-level `isDisabled` that disables all items at once. + + + +### Nested Disclosures + +Disclosures can be nested, each maintaining independent state. + + + +## Accessibility + +### Keyboard Navigation + +- `Tab` - Moves focus to the disclosure trigger +- `Space` / `Enter` - Toggles the disclosure open/closed +- `Tab` (when expanded) - Moves focus into the content panel + +### Screen Reader Support + +- Trigger announces as a button with expanded/collapsed state +- `aria-expanded` is automatically managed by React Aria +- `aria-controls` links trigger to content panel automatically +- State changes are announced when toggling + +### ARIA Properties + +- `aria-expanded` - Indicates whether content is visible (managed automatically) +- `aria-controls` - Links trigger to panel (managed automatically) +- `aria-disabled` - Applied when `isDisabled` is true + +## Best Practices + +1. **Do**: Provide clear, descriptive trigger labels + + ```jsx + View shipping details + ``` + +2. **Don't**: Use vague or icon-only triggers without labels + + ```jsx + + + ``` + +3. **Do**: Use groups for related collapsible sections + + ```jsx + + ... + ... + + ``` + +4. **Accessibility**: Always ensure trigger text clearly indicates what will be revealed + +5. **Performance**: Content remains mounted during transitions for smooth animations and accessibility + +## Related Components + +- [ItemButton](/docs/actions-itembutton--docs) - The base component used for triggers +- [DisplayTransition](/docs/helpers-displaytransition--docs) - Manages animation phases diff --git a/src/components/content/Disclosure/Disclosure.stories.tsx b/src/components/content/Disclosure/Disclosure.stories.tsx new file mode 100644 index 000000000..044f7dfc5 --- /dev/null +++ b/src/components/content/Disclosure/Disclosure.stories.tsx @@ -0,0 +1,421 @@ +import { useState } from 'react'; + +import { Button } from '../../actions/Button'; +import { Switch } from '../../fields/Switch'; +import { Space } from '../../layout/Space'; +import { Divider } from '../Divider'; +import { Paragraph } from '../Paragraph'; + +import { Disclosure } from './Disclosure'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Content/Disclosure', + component: Disclosure, + parameters: { + layout: 'padded', + }, + argTypes: { + /* State Control */ + isExpanded: { + control: 'boolean', + description: 'Controls expanded state in controlled mode', + table: { + type: { summary: 'boolean' }, + }, + }, + defaultExpanded: { + control: 'boolean', + description: 'Initial expanded state in uncontrolled mode', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + onExpandedChange: { + action: 'expanded-change', + description: 'Callback fired when expanded state changes', + table: { + type: { summary: '(isExpanded: boolean) => void' }, + }, + }, + + /* Behavior */ + isDisabled: { + control: 'boolean', + description: 'Disables trigger interactions and force-closes the content', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + + /* Appearance */ + shape: { + control: 'radio', + options: ['default', 'card', 'sharp'], + description: + 'Visual shape variant: default (no styling), card (border/radius), sharp (radius 0)', + table: { + type: { summary: "'default' | 'card' | 'sharp'" }, + defaultValue: { summary: 'default' }, + }, + }, + + /* Animation */ + transitionDuration: { + control: 'number', + description: + 'Duration for DisplayTransition animation in milliseconds. When undefined, uses default CSS transition timing', + table: { + type: { summary: 'number' }, + }, + }, + + /* Content */ + children: { + control: { type: null }, + description: 'Disclosure.Trigger and Disclosure.Content components', + table: { + type: { + summary: 'ReactNode | ((state: DisclosureStateContext) => ReactNode)', + }, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * Basic single disclosure with trigger and content + */ +export const SingleDisclosure: Story = { + render: (args) => ( + + What is Cube? + + + Cube is a semantic layer that connects data sources to applications. + It provides a unified interface for data modeling, access control, and + caching. + + + + ), +}; + +/** + * Multiple disclosures in a group with single-open (accordion) behavior + */ +export const GroupDisclosure: Story = { + render: () => ( + + + What is Cube? + + + Cube is a semantic layer that connects data sources to applications. + It provides a unified interface for data modeling, access control, + and caching. + + + + + + How does it work? + + + Cube connects to your data sources, allows you to define metrics and + dimensions, and serves them through a unified API. It handles query + optimization and caching automatically. + + + + + + What are the benefits? + + + Cube provides consistency across your data stack, improves + performance with intelligent caching, ensures governance with + centralized access control, and scales efficiently with your data + needs. + + + + + ), +}; + +/** + * Card shape variant with border and rounded corners + */ +export const CardShape: Story = { + render: (args) => ( + + What is Cube? + + + Cube is a semantic layer that connects data sources to applications. + It provides a unified interface for data modeling, access control, and + caching. + + + + ), +}; + +/** + * Sharp shape variant with no border radius + */ +export const SharpShape: Story = { + render: (args) => ( + + What is Cube? + + + Cube is a semantic layer that connects data sources to applications. + It provides a unified interface for data modeling, access control, and + caching. + + + + ), +}; + +/** + * Disclosure that starts in expanded state + */ +export const DefaultExpanded: Story = { + render: (args) => ( + + Already expanded + + + This content is visible by default when the component mounts. + + + + ), +}; + +/** + * Disabled disclosure that cannot be toggled + */ +export const Disabled: Story = { + render: (args) => ( + + Cannot be toggled + + This content will never be visible. + + + ), +}; + +/** + * Controlled disclosure with external state management + */ +export const Controlled: Story = { + render: function ControlledStory(args) { + const [isExpanded, setExpanded] = useState(false); + + return ( + + + + + + + + + Controlled disclosure + + + This disclosure is controlled by external buttons. + + + + + ); + }, +}; + +/** + * Group allowing multiple items to be expanded simultaneously + */ +export const MultipleExpanded: Story = { + render: () => ( + + + First item + + + This can be open at the same time as other items. + + + + + + Second item + + Multiple items can be expanded simultaneously. + + + + + Third item + + + Unlike accordion mode, opening one doesn't close others. + + + + + ), +}; + +/** + * Group with some items pre-expanded + */ +export const DefaultExpandedKeys: Story = { + render: () => ( + + + First item (collapsed) + + First item content. + + + + + + Second item (expanded by default) + + + + This item starts expanded via defaultExpandedKeys. + + + + + + Third item (collapsed) + + Third item content. + + + + ), +}; + +/** + * Using render prop for custom trigger structure + */ +export const RenderProp: Story = { + render: (args) => ( + + {({ isExpanded, toggle }) => ( + <> + + + + Custom trigger using render prop pattern. The trigger can be any + component that calls the toggle function. + + + + )} + + ), +}; + +/** + * Disabled group where all items are non-interactive + */ +export const DisabledGroup: Story = { + render: () => ( + + + Cannot toggle + + Hidden content. + + + + + Also disabled + + Hidden content. + + + + ), +}; + +/** + * Nested disclosures + */ +export const Nested: Story = { + render: (args) => ( + + Outer disclosure + + Outer content with a nested disclosure: + + + Inner disclosure + + + Nested disclosure content. Each disclosure maintains independent + state. + + + + + + ), +}; + +/** + * Trigger with actions containing a Switch to control disabled state + */ +export const TriggerWithActions: Story = { + render: function TriggerWithActionsStory(args) { + const [isDisabled, setIsDisabled] = useState(false); + + return ( + + setIsDisabled(!value)} + /> + } + > + Toggle disabled state with the switch + + + + Use the switch in the trigger to disable/enable this disclosure. + When disabled, the content cannot be toggled. + + + + ); + }, +}; diff --git a/src/components/content/Disclosure/Disclosure.test.tsx b/src/components/content/Disclosure/Disclosure.test.tsx new file mode 100644 index 000000000..4daf58c59 --- /dev/null +++ b/src/components/content/Disclosure/Disclosure.test.tsx @@ -0,0 +1,489 @@ +import { act, renderWithRoot, userEvent, waitFor } from '../../../test'; + +import { Disclosure } from './Disclosure'; + +jest.mock('../../../_internal/hooks/use-warn'); + +describe('', () => { + describe('Basic Rendering', () => { + it('should render trigger and content', () => { + const { getByRole, getByText } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByRole('button', { name: 'Toggle' })).toBeInTheDocument(); + expect(getByText('Content')).toBeInTheDocument(); + }); + + it('should add data-qa attribute', () => { + const { getByTestId } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByTestId('test-disclosure')).toBeInTheDocument(); + }); + + it('should add data-qa to content', () => { + const { container } = renderWithRoot( + + Toggle + + Content + + , + ); + + expect( + container.querySelector('[data-qa="disclosure-content"]'), + ).toBeInTheDocument(); + }); + }); + + describe('Expand/Collapse', () => { + it('should start collapsed by default', () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByRole('button')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should expand on click', async () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + const trigger = getByRole('button'); + + await userEvent.click(trigger); + + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + it('should collapse on second click', async () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + const trigger = getByRole('button'); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await userEvent.click(trigger); + + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + }); + + it('should call onExpandedChange when expanded', async () => { + const onExpandedChange = jest.fn(); + + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + await userEvent.click(getByRole('button')); + + await waitFor(() => { + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('Keyboard Interactions', () => { + it('should expand on Enter key', async () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + const trigger = getByRole('button'); + + await act(async () => { + trigger.focus(); + }); + + await userEvent.keyboard('{Enter}'); + + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + it('should expand on Space key', async () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + const trigger = getByRole('button'); + + await act(async () => { + trigger.focus(); + }); + + await userEvent.keyboard(' '); + + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + }); + }); + + describe('Controlled State', () => { + it('should respect controlled isExpanded prop', () => { + const { getByRole, rerender } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByRole('button')).toHaveAttribute('aria-expanded', 'false'); + + rerender( + + Toggle + Content + , + ); + + expect(getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should respect defaultExpanded prop', () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + describe('Disabled State', () => { + it('should disable trigger when isDisabled is true', () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByRole('button')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should not expand when disabled', async () => { + const onExpandedChange = jest.fn(); + + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + await userEvent.click(getByRole('button')); + + expect(onExpandedChange).not.toHaveBeenCalled(); + expect(getByRole('button')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should force close when disabled even if defaultExpanded', () => { + const { getByRole } = renderWithRoot( + + Toggle + Content + , + ); + + expect(getByRole('button')).toHaveAttribute('aria-expanded', 'false'); + }); + }); + + describe('ARIA Attributes', () => { + it('should have correct aria-controls linking', () => { + const { getByRole, container } = renderWithRoot( + + Toggle + Content + , + ); + + const trigger = getByRole('button'); + const ariaControls = trigger.getAttribute('aria-controls'); + + expect(ariaControls).toBeTruthy(); + expect(container.querySelector(`#${ariaControls}`)).toBeInTheDocument(); + }); + }); + + describe('Render Prop', () => { + it('should provide state context via render prop', async () => { + const { getByRole, getByText } = renderWithRoot( + + {({ isExpanded, toggle }) => ( + <> + + {isExpanded ? 'Open' : 'Closed'} + + )} + , + ); + + expect(getByText('Closed')).toBeInTheDocument(); + + await userEvent.click(getByRole('button')); + + await waitFor(() => { + expect(getByText('Open')).toBeInTheDocument(); + }); + }); + }); + + describe('Shape Variants', () => { + it('should render with card shape', () => { + const { container } = renderWithRoot( + + Toggle + Content + , + ); + + expect( + container.querySelector('[data-shape="card"]'), + ).toBeInTheDocument(); + }); + + it('should render with sharp shape', () => { + const { container } = renderWithRoot( + + Toggle + Content + , + ); + + expect( + container.querySelector('[data-shape="sharp"]'), + ).toBeInTheDocument(); + }); + }); +}); + +describe('', () => { + it('should render multiple disclosure items', () => { + const { getAllByRole } = renderWithRoot( + + + Item 1 + Content 1 + + + Item 2 + Content 2 + + , + ); + + expect(getAllByRole('button')).toHaveLength(2); + }); + + it('should only allow single expanded by default (accordion)', async () => { + const { getAllByRole } = renderWithRoot( + + + Item 1 + Content 1 + + + Item 2 + Content 2 + + , + ); + + const [trigger1, trigger2] = getAllByRole('button'); + + // Expand first item + await userEvent.click(trigger1); + + await waitFor(() => { + expect(trigger1).toHaveAttribute('aria-expanded', 'true'); + }); + expect(trigger2).toHaveAttribute('aria-expanded', 'false'); + + // Expand second item - should collapse first + await userEvent.click(trigger2); + + await waitFor(() => { + expect(trigger2).toHaveAttribute('aria-expanded', 'true'); + }); + expect(trigger1).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should allow multiple expanded when allowsMultipleExpanded is true', async () => { + const { getAllByRole } = renderWithRoot( + + + Item 1 + Content 1 + + + Item 2 + Content 2 + + , + ); + + const [trigger1, trigger2] = getAllByRole('button'); + + // Expand first item + await userEvent.click(trigger1); + + await waitFor(() => { + expect(trigger1).toHaveAttribute('aria-expanded', 'true'); + }); + + // Expand second item + await userEvent.click(trigger2); + + await waitFor(() => { + expect(trigger2).toHaveAttribute('aria-expanded', 'true'); + }); + + // Both should be expanded + expect(trigger1).toHaveAttribute('aria-expanded', 'true'); + expect(trigger2).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should respect defaultExpandedKeys', () => { + const { getAllByRole } = renderWithRoot( + + + Item 1 + Content 1 + + + Item 2 + Content 2 + + , + ); + + const [trigger1, trigger2] = getAllByRole('button'); + + expect(trigger1).toHaveAttribute('aria-expanded', 'false'); + expect(trigger2).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should call onExpandedChange with updated keys', async () => { + const onExpandedChange = jest.fn(); + + const { getAllByRole } = renderWithRoot( + + + Item 1 + Content 1 + + + Item 2 + Content 2 + + , + ); + + const [trigger1] = getAllByRole('button'); + + await userEvent.click(trigger1); + + await waitFor(() => { + expect(onExpandedChange).toHaveBeenCalledWith(new Set(['1'])); + }); + }); + + it('should disable all items when group isDisabled', () => { + const { getAllByRole } = renderWithRoot( + + + Item 1 + Content 1 + + + Item 2 + Content 2 + + , + ); + + const triggers = getAllByRole('button'); + + triggers.forEach((trigger) => { + expect(trigger).toHaveAttribute('aria-disabled', 'true'); + }); + }); +}); + +describe('Nested Disclosures', () => { + it('should maintain independent state for nested disclosures', async () => { + const { getAllByRole } = renderWithRoot( + + Outer + + + Inner + Inner Content + + + , + ); + + const [outerTrigger, innerTrigger] = getAllByRole('button'); + + expect(outerTrigger).toHaveAttribute('aria-expanded', 'true'); + expect(innerTrigger).toHaveAttribute('aria-expanded', 'false'); + + // Expand inner + await userEvent.click(innerTrigger); + + await waitFor(() => { + expect(innerTrigger).toHaveAttribute('aria-expanded', 'true'); + }); + expect(outerTrigger).toHaveAttribute('aria-expanded', 'true'); + + // Collapse outer - inner state should be independent + await userEvent.click(outerTrigger); + + await waitFor(() => { + expect(outerTrigger).toHaveAttribute('aria-expanded', 'false'); + }); + // Inner keeps its state even though outer is collapsed + expect(innerTrigger).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/src/components/content/Disclosure/Disclosure.tsx b/src/components/content/Disclosure/Disclosure.tsx new file mode 100644 index 000000000..d16f863c9 --- /dev/null +++ b/src/components/content/Disclosure/Disclosure.tsx @@ -0,0 +1,640 @@ +import { + createContext, + forwardRef, + Key, + ReactNode, + RefObject, + useContext, + useMemo, + useRef, +} from 'react'; +import { mergeProps, useDisclosure, useId } from 'react-aria'; +import { + DisclosureGroupState, + DisclosureState, + useDisclosureGroupState, + useDisclosureState, +} from 'react-stately'; + +import { RightIcon } from '../../../icons'; +import { + BaseProps, + BasePropsWithoutChildren, + extractStyles, + OUTER_STYLES, + OuterStyleProps, + Styles, + tasty, +} from '../../../tasty'; +import { CubeItemButtonProps, ItemButton } from '../../actions/ItemButton'; +import { DisplayTransition } from '../../helpers'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface DisclosureStateContext { + isExpanded: boolean; + toggle: () => void; + expand: () => void; + collapse: () => void; +} + +interface DisclosureContextValue { + state: DisclosureState; + buttonProps: Record; + panelProps: Record; + panelRef: RefObject; + isDisabled: boolean; + isExpanded: boolean; + shape: 'default' | 'card' | 'sharp'; + transitionDuration?: number; + triggerProps?: Partial; + contentStyles?: Styles; +} + +interface DisclosureGroupContextValue { + groupState: DisclosureGroupState; + triggerProps?: Partial; + contentStyles?: Styles; +} + +export interface CubeDisclosureProps + extends BasePropsWithoutChildren, + OuterStyleProps { + /** Controls expanded state in controlled mode */ + isExpanded?: boolean; + /** Initial expanded state in uncontrolled mode */ + defaultExpanded?: boolean; + /** Callback fired when expanded state changes */ + onExpandedChange?: (isExpanded: boolean) => void; + /** Disables trigger interactions and force-closes the content */ + isDisabled?: boolean; + /** Render-prop alternative to achieve custom trigger markup */ + children?: ReactNode | ((state: DisclosureStateContext) => ReactNode); + /** Visual shape variant */ + shape?: 'default' | 'card' | 'sharp'; + /** Duration for DisplayTransition animation in milliseconds */ + transitionDuration?: number; +} + +export interface CubeDisclosureTriggerProps + extends Omit { + /** Children content for the trigger */ + children?: ReactNode; +} + +export interface CubeDisclosureContentProps extends BaseProps { + /** Children content for the panel */ + children?: ReactNode; +} + +export interface CubeDisclosureGroupProps extends BaseProps, OuterStyleProps { + /** Allow more than one disclosure to be open */ + allowsMultipleExpanded?: boolean; + /** Controlled expanded keys */ + expandedKeys?: Iterable; + /** Uncontrolled default expanded keys */ + defaultExpandedKeys?: Iterable; + /** Change handler providing the full expanded keys Set */ + onExpandedChange?: (keys: Set) => void; + /** Disable all disclosures within group and force-close their content */ + isDisabled?: boolean; + /** Props forwarded to all ItemButton triggers in the group */ + triggerProps?: Partial; + /** Optional panel styles applied to all Content panels in the group */ + contentStyles?: Styles; + children: ReactNode; +} + +export interface CubeDisclosureItemProps + extends Omit, + OuterStyleProps { + /** Unique identifier for the disclosure item in a group */ + id?: Key; + /** Children content */ + children?: ReactNode; + /** Disables trigger interactions and force-closes the content */ + isDisabled?: boolean; + /** Controls expanded state in controlled mode */ + isExpanded?: boolean; + /** Initial expanded state in uncontrolled mode */ + defaultExpanded?: boolean; + /** Callback fired when expanded state changes */ + onExpandedChange?: (isExpanded: boolean) => void; + /** Visual shape variant */ + shape?: 'default' | 'card' | 'sharp'; +} + +// ============================================================================ +// Contexts +// ============================================================================ + +const DisclosureContext = createContext(null); +const DisclosureGroupContext = + createContext(null); + +function useDisclosureContext(): DisclosureContextValue { + const context = useContext(DisclosureContext); + + if (!context) { + throw new Error( + 'Disclosure.Trigger and Disclosure.Content must be used within a Disclosure', + ); + } + + return context; +} + +function useDisclosureGroupContext(): DisclosureGroupContextValue | null { + return useContext(DisclosureGroupContext); +} + +// ============================================================================ +// Styled Components +// ============================================================================ + +const DisclosureRoot = tasty({ + qa: 'Disclosure', + styles: { + display: 'flex', + flow: 'column', + gap: 0, + position: 'relative', + border: { + '': 'none', + 'shape=card': '1bw solid #border', + }, + radius: { + '': '1r', + 'shape=card': '1cr', + 'shape=sharp': '0', + }, + fill: '#white', + }, +}); + +const ContentWrapperElement = tasty({ + styles: { + display: 'block', + overflow: 'hidden', + interpolateSize: 'allow-keywords', + height: { + '': '0', + shown: 'max-content', + }, + transition: 'height $disclosure-transition linear', + }, +}); + +const ContentElement = tasty({ + qa: 'DisclosureContent', + styles: { + padding: '1x', + }, +}); + +const GroupRoot = tasty({ + qa: 'DisclosureGroup', + styles: { + display: 'flex', + flow: 'column', + gap: '0', + }, +}); + +const TriggerIcon = tasty(RightIcon, { + styles: { + transition: 'rotate', + rotate: { + '': '0deg', + expanded: '90deg', + }, + }, +}); + +const StyledTrigger = tasty(ItemButton, { + styles: { + radius: { + '': '1r', + 'expanded & shape=card': '(1cr - 1bw) (1cr - 1bw) 0 0', + 'shape=sharp': '0', + }, + border: '#clear', + }, +}); + +// ============================================================================ +// Disclosure Component +// ============================================================================ + +const DisclosureComponent = forwardRef( + function Disclosure(props, ref) { + const { + isExpanded: controlledIsExpanded, + defaultExpanded, + onExpandedChange, + isDisabled = false, + children, + shape = 'default', + transitionDuration, + qa, + mods, + styles, + ...otherProps + } = props; + + const groupContext = useDisclosureGroupContext(); + const panelRef = useRef(null); + + // When disabled, force expanded to false + const effectiveIsExpanded = isDisabled ? false : controlledIsExpanded; + + const state = useDisclosureState({ + isExpanded: effectiveIsExpanded, + defaultExpanded: isDisabled ? false : defaultExpanded, + onExpandedChange: isDisabled ? undefined : onExpandedChange, + }); + + // When disabled, override state.isExpanded to false + const isExpanded = isDisabled ? false : state.isExpanded; + + const { buttonProps, panelProps } = useDisclosure( + { + isExpanded, + isDisabled, + }, + state, + panelRef, + ); + + const contextValue = useMemo( + () => ({ + state, + buttonProps, + panelProps, + panelRef, + isDisabled, + isExpanded, + shape, + transitionDuration, + triggerProps: groupContext?.triggerProps, + contentStyles: groupContext?.contentStyles, + }), + [ + state, + buttonProps, + panelProps, + isDisabled, + isExpanded, + shape, + transitionDuration, + groupContext?.triggerProps, + groupContext?.contentStyles, + ], + ); + + const stateContext = useMemo( + () => ({ + isExpanded, + toggle: state.toggle, + expand: state.expand, + collapse: state.collapse, + }), + [isExpanded, state.toggle, state.expand, state.collapse], + ); + + const outerStyles = extractStyles(otherProps, OUTER_STYLES); + + const finalStyles = useMemo( + () => ({ + ...outerStyles, + ...styles, + }), + [outerStyles, styles], + ); + + const finalMods = useMemo( + () => ({ + expanded: isExpanded, + disabled: isDisabled, + shape, + ...mods, + }), + [isExpanded, isDisabled, shape, mods], + ); + + const content = + typeof children === 'function' ? children(stateContext) : children; + + return ( + + + {content} + + + ); + }, +); + +// ============================================================================ +// Disclosure.Trigger Component +// ============================================================================ + +const DisclosureTrigger = forwardRef< + HTMLButtonElement, + CubeDisclosureTriggerProps +>(function DisclosureTrigger(props, ref) { + const { children, icon, styles, mods, ...otherProps } = props; + const context = useDisclosureContext(); + const { buttonProps, isDisabled, isExpanded, shape, triggerProps } = context; + + const finalMods = useMemo( + () => ({ + expanded: isExpanded, + disabled: isDisabled, + shape, + ...mods, + }), + [isExpanded, isDisabled, shape, mods], + ); + + // Default icon is a rotating chevron + const defaultIcon = ; + + return ( + + {children} + + ); +}); + +// ============================================================================ +// Disclosure.Content Component +// ============================================================================ + +const DisclosureContent = forwardRef< + HTMLDivElement, + CubeDisclosureContentProps +>(function DisclosureContent(props, ref) { + const { children, styles, mods, ...otherProps } = props; + const context = useDisclosureContext(); + const { + panelProps, + panelRef, + isExpanded, + transitionDuration, + contentStyles, + } = context; + + const mergedStyles = useMemo( + () => ({ + ...contentStyles, + ...styles, + }), + [contentStyles, styles], + ); + + // Filter out hidden attribute from panelProps since we manage visibility via CSS height animation + const { hidden, ...filteredPanelProps } = panelProps as Record< + string, + unknown + >; + + return ( + + {({ phase, isShown, ref: transitionRef }) => ( + + + {children} + + + )} + + ); +}); + +// ============================================================================ +// Disclosure.Group Component +// ============================================================================ + +const DisclosureGroup = forwardRef( + function DisclosureGroup(props, ref) { + const { + allowsMultipleExpanded = false, + expandedKeys, + defaultExpandedKeys, + onExpandedChange, + isDisabled = false, + triggerProps, + contentStyles, + children, + qa, + mods, + styles, + ...otherProps + } = props; + + const groupState = useDisclosureGroupState({ + allowsMultipleExpanded, + expandedKeys, + defaultExpandedKeys, + onExpandedChange, + isDisabled, + }); + + const contextValue = useMemo( + () => ({ + groupState, + triggerProps, + contentStyles, + }), + [groupState, triggerProps, contentStyles], + ); + + const outerStyles = extractStyles(otherProps, OUTER_STYLES); + + const finalStyles = useMemo( + () => ({ + ...outerStyles, + ...styles, + }), + [outerStyles, styles], + ); + + return ( + + + {children} + + + ); + }, +); + +// ============================================================================ +// Disclosure.Item Component +// ============================================================================ + +const DisclosureItem = forwardRef( + function DisclosureItem(props, ref) { + const { + id: providedId, + children, + isDisabled: itemDisabled = false, + isExpanded: controlledIsExpanded, + defaultExpanded, + onExpandedChange, + shape = 'default', + qa, + mods, + styles, + ...otherProps + } = props; + + const defaultId = useId(); + const id = providedId ?? defaultId; + + const groupContext = useDisclosureGroupContext(); + const panelRef = useRef(null); + + // Determine if disabled from group or item + const isDisabled = + itemDisabled || groupContext?.groupState?.isDisabled || false; + + // Determine expanded state from group or local props + const groupIsExpanded = groupContext + ? groupContext.groupState.expandedKeys.has(id) + : undefined; + + // When disabled, force expanded to false + const effectiveIsExpanded = isDisabled + ? false + : groupIsExpanded ?? controlledIsExpanded; + + const state = useDisclosureState({ + isExpanded: effectiveIsExpanded, + defaultExpanded: isDisabled ? false : defaultExpanded, + onExpandedChange(expanded) { + if (isDisabled) return; + + if (groupContext) { + groupContext.groupState.toggleKey(id); + } + onExpandedChange?.(expanded); + }, + }); + + // When disabled, override state.isExpanded to false + const isExpanded = isDisabled ? false : state.isExpanded; + + const { buttonProps, panelProps } = useDisclosure( + { + isExpanded, + isDisabled, + }, + state, + panelRef, + ); + + const contextValue = useMemo( + () => ({ + state, + buttonProps, + panelProps, + panelRef, + isDisabled, + isExpanded, + shape, + transitionDuration: undefined, + triggerProps: groupContext?.triggerProps, + contentStyles: groupContext?.contentStyles, + }), + [ + state, + buttonProps, + panelProps, + isDisabled, + isExpanded, + shape, + groupContext?.triggerProps, + groupContext?.contentStyles, + ], + ); + + const outerStyles = extractStyles(otherProps, OUTER_STYLES); + + const finalStyles = useMemo( + () => ({ + ...outerStyles, + ...styles, + }), + [outerStyles, styles], + ); + + const finalMods = useMemo( + () => ({ + expanded: isExpanded, + disabled: isDisabled, + [`shape=${shape}`]: true, + ...mods, + }), + [isExpanded, isDisabled, shape, mods], + ); + + return ( + + + {children} + + + ); + }, +); + +// ============================================================================ +// Compound Component Export +// ============================================================================ + +const _Disclosure = Object.assign(DisclosureComponent, { + Trigger: DisclosureTrigger, + Content: DisclosureContent, + Group: DisclosureGroup, + Item: DisclosureItem, +}); + +export { _Disclosure as Disclosure }; +export type { + CubeDisclosureProps as DisclosureProps, + CubeDisclosureTriggerProps as DisclosureTriggerProps, + CubeDisclosureContentProps as DisclosureContentProps, + CubeDisclosureGroupProps as DisclosureGroupProps, + CubeDisclosureItemProps as DisclosureItemProps, +}; diff --git a/src/components/content/Disclosure/index.ts b/src/components/content/Disclosure/index.ts new file mode 100644 index 000000000..f70617284 --- /dev/null +++ b/src/components/content/Disclosure/index.ts @@ -0,0 +1 @@ +export * from './Disclosure'; diff --git a/src/components/form/Form/RULES.md b/src/components/form/Form/RULES.md new file mode 100644 index 000000000..9f17663ec --- /dev/null +++ b/src/components/form/Form/RULES.md @@ -0,0 +1,82 @@ +# Form Field Rules Documentation + +## How Rules Work + +### Rule Processing Flow + +1. **Field receives `rules` prop** - Array of validation rules, e.g., `[{ required: true, type: 'email' }]` +2. **`useFieldProps`** (`use-field-props.tsx`) - Validates that `rules` requires a `name` prop (field must be form-connected) +3. **`useField`** (`use-field.ts`) - Processes rules: + - Line 66-68: **MUTATES** rules array if `validationDelay` is set: `rules.unshift(delayValidationRule(validationDelay))` + - Line 100-102: Assigns rules to field instance: `field.rules = rules` + - Line 104: **Calculates `isRequired`** from rules: `rules && !!rules.find((rule) => rule.required)` + - Line 196: Returns `isRequired` in the field return value (conditionally) + +### Key Logic Points + +```66:68:src/components/form/Form/use-field/use-field.ts + if (rules && rules.length && validationDelay) { + rules.unshift(delayValidationRule(validationDelay)); + } +``` + +```100:104:src/components/form/Form/use-field/use-field.ts + if (field) { + field.rules = rules; + } + + let isRequired = rules && !!rules.find((rule) => rule.required); +``` + +## Potential Bugs + +### Bug 1: Rules Array Mutation + +**Issue**: Line 67 in `use-field.ts` mutates the `rules` array directly with `.unshift()`. + +**Impact**: +- If users share the same rules array reference between fields, changes affect all fields +- When combined with `validationDelay`, the delay rule gets added multiple times +- This is a side effect that happens on every render when `validationDelay` changes + +```typescript +// DANGEROUS: Shared reference +const sharedRules = [{ required: true }]; + + + +// field2 will get the delay rule too! +``` + +**Solution**: Clone the array before mutation: +```typescript +if (rules && rules.length && validationDelay) { + rules = [delayValidationRule(validationDelay), ...rules]; +} +``` + +### Bug 2: isRequired Propagation (Needs Investigation) + +**User Report**: "When they make a single field required then all fields become required" + +**Hypothesis**: The bug might be related to: +1. Shared rules array references (see Bug 1) +2. Field instance `field.rules` assignment creating shared references +3. Form re-render triggering all fields to recalculate with stale/shared state +4. `isRequired` from `useProviderProps` context overriding field-specific calculation + +**Investigation needed**: +- Check if `field.rules` assignment at line 101 creates shared state +- Verify `isRequired` doesn't come from Provider context (Form has `isRequired` prop that passes to Provider) +- Check useMemo dependencies for `isRequired` (line 221) - could cause stale closures +- Test if `form.forceReRender()` at line 110 causes cross-field pollution + +### Bug 3: Conditional `isRequired` Return + +Line 196 uses conditional spread: +```typescript +...(isRequired && { isRequired }), +``` + +This means `isRequired` is only added to return value if truthy. If a field previously had `isRequired: true` and then it becomes `false`, the prop won't be removed - the component will keep the old value. + diff --git a/src/components/helpers/DisplayTransition/DisplayTransition.tsx b/src/components/helpers/DisplayTransition/DisplayTransition.tsx index 0aa762ff9..0cc42e0d3 100644 --- a/src/components/helpers/DisplayTransition/DisplayTransition.tsx +++ b/src/components/helpers/DisplayTransition/DisplayTransition.tsx @@ -10,7 +10,8 @@ import { const AUTO_FALLBACK_DURATION = 500; -type Phase = 'enter' | 'entered' | 'exit' | 'unmounted'; +type Phase = 'enter' | 'entered' | 'exit-pending' | 'exit' | 'unmounted'; +type ReportedPhase = 'enter' | 'entered' | 'exit' | 'unmounted'; export type DisplayTransitionProps = { /** Desired visibility (driver). */ @@ -20,7 +21,7 @@ export type DisplayTransitionProps = { /** Fires after enter settles or after exit completes (unmount). */ onRest?: (transition: 'enter' | 'exit') => void; /** Fires when phase changes. */ - onPhaseChange?: (phase: Phase) => void; + onPhaseChange?: (phase: ReportedPhase) => void; /** Fires when isShown (derived from phase) changes. */ onToggle?: (isShown: boolean) => void; /** Keep calling children during "unmounted" (you decide what to render). */ @@ -31,7 +32,7 @@ export type DisplayTransitionProps = { respectReducedMotion?: boolean; /** Render-prop gets { phase, isShown, ref }. Bind ref to the transitioned element for native event detection. */ children: (props: { - phase: Phase; + phase: ReportedPhase; isShown: boolean; ref: RefCallback; }) => ReactNode; @@ -251,6 +252,11 @@ export function DisplayTransition({ ensureEnterFlow(); } else if (current === 'enter') { ensureEnterFlow(); + } else if (current === 'exit-pending') { + // User toggled back before exit started, cancel and stay entered + cancelRAF(); + clearTimer(); + setPhase('entered'); } else { // already "entered" cancelRAF(); @@ -260,12 +266,13 @@ export function DisplayTransition({ if (current === 'unmounted') { cancelRAF(); clearTimer(); - } else if (current !== 'exit') { - setPhase('exit'); - ensureExitFlow(); - } else { + } else if (current !== 'exit' && current !== 'exit-pending') { + // Set intermediate phase to trigger re-render, RAF will be scheduled from layout effect + setPhase('exit-pending'); + } else if (current === 'exit') { ensureExitFlow(); } + // 'exit-pending' is handled in useLayoutEffect below } return () => { @@ -275,22 +282,37 @@ export function DisplayTransition({ }; }, [targetShown, dur, onRestEvent]); - // OPTIONAL belt-and-suspenders: if we render while still "enter", re-arm enter flow. - // You can remove this if you want fewer moving parts; double-rAF usually suffices. + // Schedule RAF from layout effect for both enter and exit-pending to ensure symmetric timing useLayoutEffect(() => { if (phaseRef.current === 'enter') { ensureEnterFlow(); + } else if (phaseRef.current === 'exit-pending') { + // Schedule RAF for exit, mirroring the enter flow timing + nextPaint(() => { + if (phaseRef.current === 'exit-pending') { + setPhase('exit'); + ensureExitFlow(); + } + }); } return cancelRAF; }, [phase]); - // Call onPhaseChange when phase changes + // Map internal phase to reported phase (exit-pending is reported as 'entered') + const reportedPhase: ReportedPhase = + phase === 'exit-pending' ? 'entered' : phase; + + // Call onPhaseChange when reported phase changes + const prevReportedPhaseRef = useRef(reportedPhase); useLayoutEffect(() => { - onPhaseChangeEvent?.(phase); - }, [phase, onPhaseChangeEvent]); + if (prevReportedPhaseRef.current !== reportedPhase) { + prevReportedPhaseRef.current = reportedPhase; + onPhaseChangeEvent?.(reportedPhase); + } + }, [reportedPhase, onPhaseChangeEvent]); // Render-time boolean (true only when visually shown) - const isShownNow = phase === 'entered'; + const isShownNow = phase === 'entered' || phase === 'exit-pending'; const prevIsShownRef = useRef(isShownNow); // Call onToggle when isShown changes @@ -318,9 +340,9 @@ export function DisplayTransition({ if (phase === 'unmounted' && !exposeUnmounted) return null; return children({ phase: - phase === 'enter' && duration !== undefined && !duration + reportedPhase === 'enter' && duration !== undefined && !duration ? 'entered' - : phase, + : reportedPhase, isShown: isShownNow, ref: refCallback, }); diff --git a/src/index.ts b/src/index.ts index 2de666b58..66913cef9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,15 @@ export { Suffix } from './components/layout/Suffix'; export type { CubeSuffixProps } from './components/layout/Suffix'; export { Divider } from './components/content/Divider'; export type { CubeDividerProps } from './components/content/Divider'; +export { Disclosure } from './components/content/Disclosure'; +export type { + CubeDisclosureProps, + CubeDisclosureTriggerProps, + CubeDisclosureContentProps, + CubeDisclosureGroupProps, + CubeDisclosureItemProps, + DisclosureStateContext, +} from './components/content/Disclosure'; export { GridProvider } from './components/GridProvider'; export type { CubeGridProviderProps } from './components/GridProvider'; export { Content } from './components/content/Content'; diff --git a/src/tokens.ts b/src/tokens.ts index a0b6bfba0..6752b2edc 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -76,6 +76,7 @@ const TOKENS = { 'leaf-sharp-radius': '0px', 'fade-width': '32px', transition: '80ms', + 'disclosure-transition': '120ms', 'min-dialog-size': 'min(288px, calc(100vw - (2 * var(--gap))))', 'clear-color': 'transparent', 'border-opaque-color': 'rgb(227 227 233)', From b50ec51630dca46c7860a523748badedfd72dd3b Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 26 Nov 2025 15:16:06 +0100 Subject: [PATCH 2/5] fix(DisplayTransition): tests --- .../DisplayTransition.test.tsx | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx b/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx index 493311ca7..84d8fa7cb 100644 --- a/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx +++ b/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx @@ -173,7 +173,19 @@ describe('DisplayTransition', () => { , ); - // Should be in exit phase, isShown=false + // Immediately after rerender, still in 'entered' (exit-pending internally), isShown=true + expect( + container.querySelector('[data-phase="entered"]'), + ).toBeInTheDocument(); + expect(container.querySelector('[data-shown="true"]')).toBeInTheDocument(); + expect(onRest).not.toHaveBeenCalled(); + + // Advance through double-rAF to reach "exit" phase + act(() => { + jest.advanceTimersByTime(50); + }); + + // Now should be in exit phase, isShown=false expect(container.querySelector('[data-phase="exit"]')).toBeInTheDocument(); expect(container.querySelector('[data-shown="false"]')).toBeInTheDocument(); expect(onRest).not.toHaveBeenCalled(); @@ -238,26 +250,27 @@ describe('DisplayTransition', () => { onRest.mockClear(); // Test exit flow with duration=0 + rerender( + + {({ phase, isShown }) => ( +
+ content +
+ )} +
, + ); + + // Advance through double-rAF for exit-pending → exit transition act(() => { - rerender( - - {({ phase, isShown }) => ( -
- content -
- )} -
, - ); - // With duration=0, exit completes immediately - jest.advanceTimersByTime(1); + jest.advanceTimersByTime(50); }); - // With duration=0, it should go directly to unmounted (exit completes instantly) + // With duration=0, it should go directly to unmounted (exit completes instantly after rAF) expect( container.querySelector('[data-phase="unmounted"]'), ).toBeInTheDocument(); @@ -381,11 +394,11 @@ describe('DisplayTransition', () => { ); }); - // Should be in exit or unmounted (depending on timing) + // Should be in 'entered' (exit-pending internally), 'exit', or 'unmounted' (depending on timing) const phaseAfterToggle = container .querySelector('[data-phase]') ?.getAttribute('data-phase'); - expect(['exit', 'unmounted']).toContain(phaseAfterToggle); + expect(['entered', 'exit', 'unmounted']).toContain(phaseAfterToggle); // Complete all transitions act(() => { From 5672e07a740e8e99b88d4de421dff4bf02c4885a Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 26 Nov 2025 15:25:33 +0100 Subject: [PATCH 3/5] chore: delete rules doc --- src/components/form/Form/RULES.md | 82 ------------------------------- 1 file changed, 82 deletions(-) delete mode 100644 src/components/form/Form/RULES.md diff --git a/src/components/form/Form/RULES.md b/src/components/form/Form/RULES.md deleted file mode 100644 index 9f17663ec..000000000 --- a/src/components/form/Form/RULES.md +++ /dev/null @@ -1,82 +0,0 @@ -# Form Field Rules Documentation - -## How Rules Work - -### Rule Processing Flow - -1. **Field receives `rules` prop** - Array of validation rules, e.g., `[{ required: true, type: 'email' }]` -2. **`useFieldProps`** (`use-field-props.tsx`) - Validates that `rules` requires a `name` prop (field must be form-connected) -3. **`useField`** (`use-field.ts`) - Processes rules: - - Line 66-68: **MUTATES** rules array if `validationDelay` is set: `rules.unshift(delayValidationRule(validationDelay))` - - Line 100-102: Assigns rules to field instance: `field.rules = rules` - - Line 104: **Calculates `isRequired`** from rules: `rules && !!rules.find((rule) => rule.required)` - - Line 196: Returns `isRequired` in the field return value (conditionally) - -### Key Logic Points - -```66:68:src/components/form/Form/use-field/use-field.ts - if (rules && rules.length && validationDelay) { - rules.unshift(delayValidationRule(validationDelay)); - } -``` - -```100:104:src/components/form/Form/use-field/use-field.ts - if (field) { - field.rules = rules; - } - - let isRequired = rules && !!rules.find((rule) => rule.required); -``` - -## Potential Bugs - -### Bug 1: Rules Array Mutation - -**Issue**: Line 67 in `use-field.ts` mutates the `rules` array directly with `.unshift()`. - -**Impact**: -- If users share the same rules array reference between fields, changes affect all fields -- When combined with `validationDelay`, the delay rule gets added multiple times -- This is a side effect that happens on every render when `validationDelay` changes - -```typescript -// DANGEROUS: Shared reference -const sharedRules = [{ required: true }]; - - - -// field2 will get the delay rule too! -``` - -**Solution**: Clone the array before mutation: -```typescript -if (rules && rules.length && validationDelay) { - rules = [delayValidationRule(validationDelay), ...rules]; -} -``` - -### Bug 2: isRequired Propagation (Needs Investigation) - -**User Report**: "When they make a single field required then all fields become required" - -**Hypothesis**: The bug might be related to: -1. Shared rules array references (see Bug 1) -2. Field instance `field.rules` assignment creating shared references -3. Form re-render triggering all fields to recalculate with stale/shared state -4. `isRequired` from `useProviderProps` context overriding field-specific calculation - -**Investigation needed**: -- Check if `field.rules` assignment at line 101 creates shared state -- Verify `isRequired` doesn't come from Provider context (Form has `isRequired` prop that passes to Provider) -- Check useMemo dependencies for `isRequired` (line 221) - could cause stale closures -- Test if `form.forceReRender()` at line 110 causes cross-field pollution - -### Bug 3: Conditional `isRequired` Return - -Line 196 uses conditional spread: -```typescript -...(isRequired && { isRequired }), -``` - -This means `isRequired` is only added to return value if truthy. If a field previously had `isRequired: true` and then it becomes `false`, the prop won't be removed - the component will keep the old value. - From 16c05dd48b07a7e1245212454cbe3038a55dc571 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 26 Nov 2025 15:32:26 +0100 Subject: [PATCH 4/5] fix(Disclosure): minor fixes --- .../content/Disclosure/Disclosure.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/components/content/Disclosure/Disclosure.tsx b/src/components/content/Disclosure/Disclosure.tsx index d16f863c9..328feccfb 100644 --- a/src/components/content/Disclosure/Disclosure.tsx +++ b/src/components/content/Disclosure/Disclosure.tsx @@ -240,7 +240,6 @@ const DisclosureComponent = forwardRef( transitionDuration, qa, mods, - styles, ...otherProps } = props; @@ -306,14 +305,6 @@ const DisclosureComponent = forwardRef( const outerStyles = extractStyles(otherProps, OUTER_STYLES); - const finalStyles = useMemo( - () => ({ - ...outerStyles, - ...styles, - }), - [outerStyles, styles], - ); - const finalMods = useMemo( () => ({ expanded: isExpanded, @@ -329,7 +320,7 @@ const DisclosureComponent = forwardRef( return ( - + {content} @@ -345,7 +336,7 @@ const DisclosureTrigger = forwardRef< HTMLButtonElement, CubeDisclosureTriggerProps >(function DisclosureTrigger(props, ref) { - const { children, icon, styles, mods, ...otherProps } = props; + const { children, icon, mods, ...otherProps } = props; const context = useDisclosureContext(); const { buttonProps, isDisabled, isExpanded, shape, triggerProps } = context; @@ -367,9 +358,9 @@ const DisclosureTrigger = forwardRef< ref={ref} icon={icon ?? defaultIcon} isDisabled={isDisabled} + isSelected={isExpanded} {...triggerProps} {...(mergeProps(otherProps, buttonProps as any) as any)} - styles={styles} mods={finalMods} > {children} @@ -414,7 +405,6 @@ const DisclosureContent = forwardRef< isShown={isExpanded} duration={transitionDuration} animateOnMount={false} - // exposeUnmounted > {({ phase, isShown, ref: transitionRef }) => ( ( () => ({ expanded: isExpanded, disabled: isDisabled, - [`shape=${shape}`]: true, + shape, ...mods, }), [isExpanded, isDisabled, shape, mods], From 8a7285e90f73bf323fa5090eb4a299916d7ee806 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 26 Nov 2025 15:42:50 +0100 Subject: [PATCH 5/5] fix(Disclosure): panel ref --- src/components/content/Disclosure/Disclosure.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/content/Disclosure/Disclosure.tsx b/src/components/content/Disclosure/Disclosure.tsx index 328feccfb..c001940fe 100644 --- a/src/components/content/Disclosure/Disclosure.tsx +++ b/src/components/content/Disclosure/Disclosure.tsx @@ -26,6 +26,7 @@ import { Styles, tasty, } from '../../../tasty'; +import { mergeRefs } from '../../../utils/react'; import { CubeItemButtonProps, ItemButton } from '../../actions/ItemButton'; import { DisplayTransition } from '../../helpers'; @@ -412,6 +413,7 @@ const DisclosureContent = forwardRef< mods={{ shown: isShown, phase }} >