From c2a8483e869b6413b5101db048c9c84615e7d7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 15 Nov 2024 11:32:47 +0100 Subject: [PATCH 1/6] [docs] Add missing API definition files for Select (#822) --- docs/reference/generated/select-arrow.json | 19 +++++ docs/reference/generated/select-backdrop.json | 24 ++++++ .../generated/select-group-label.json | 14 ++++ docs/reference/generated/select-group.json | 14 ++++ docs/reference/generated/select-icon.json | 14 ++++ .../generated/select-option-indicator.json | 19 +++++ .../generated/select-option-text.json | 5 ++ docs/reference/generated/select-option.json | 20 +++++ docs/reference/generated/select-popup.json | 18 +++++ .../generated/select-positioner.json | 77 +++++++++++++++++++ docs/reference/generated/select-root.json | 61 +++++++++++++++ .../generated/select-scroll-down-arrow.json | 11 +++ .../generated/select-scroll-up-arrow.json | 11 +++ docs/reference/generated/select-trigger.json | 28 +++++++ docs/reference/generated/select-value.json | 18 +++++ 15 files changed, 353 insertions(+) create mode 100644 docs/reference/generated/select-arrow.json create mode 100644 docs/reference/generated/select-backdrop.json create mode 100644 docs/reference/generated/select-group-label.json create mode 100644 docs/reference/generated/select-group.json create mode 100644 docs/reference/generated/select-icon.json create mode 100644 docs/reference/generated/select-option-indicator.json create mode 100644 docs/reference/generated/select-option-text.json create mode 100644 docs/reference/generated/select-option.json create mode 100644 docs/reference/generated/select-popup.json create mode 100644 docs/reference/generated/select-positioner.json create mode 100644 docs/reference/generated/select-root.json create mode 100644 docs/reference/generated/select-scroll-down-arrow.json create mode 100644 docs/reference/generated/select-scroll-up-arrow.json create mode 100644 docs/reference/generated/select-trigger.json create mode 100644 docs/reference/generated/select-value.json diff --git a/docs/reference/generated/select-arrow.json b/docs/reference/generated/select-arrow.json new file mode 100644 index 0000000000..bc1d46efd2 --- /dev/null +++ b/docs/reference/generated/select-arrow.json @@ -0,0 +1,19 @@ +{ + "name": "SelectArrow", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "hideWhenUncentered": { + "type": "boolean", + "default": "false", + "description": "If `true`, the arrow is hidden when it can't point to the center of the anchor element." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-backdrop.json b/docs/reference/generated/select-backdrop.json new file mode 100644 index 0000000000..a90d3453d2 --- /dev/null +++ b/docs/reference/generated/select-backdrop.json @@ -0,0 +1,24 @@ +{ + "name": "SelectBackdrop", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "container": { + "type": "React.Ref | HTMLElement | null", + "default": "false", + "description": "The container element to which the Backdrop is appended to." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "If `true`, the Backdrop remains mounted when the Select popup is closed." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-group-label.json b/docs/reference/generated/select-group-label.json new file mode 100644 index 0000000000..206636b1be --- /dev/null +++ b/docs/reference/generated/select-group-label.json @@ -0,0 +1,14 @@ +{ + "name": "SelectGroupLabel", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-group.json b/docs/reference/generated/select-group.json new file mode 100644 index 0000000000..45137972ba --- /dev/null +++ b/docs/reference/generated/select-group.json @@ -0,0 +1,14 @@ +{ + "name": "SelectGroup", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-icon.json b/docs/reference/generated/select-icon.json new file mode 100644 index 0000000000..cc45a1789a --- /dev/null +++ b/docs/reference/generated/select-icon.json @@ -0,0 +1,14 @@ +{ + "name": "SelectIcon", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-option-indicator.json b/docs/reference/generated/select-option-indicator.json new file mode 100644 index 0000000000..fca900b096 --- /dev/null +++ b/docs/reference/generated/select-option-indicator.json @@ -0,0 +1,19 @@ +{ + "name": "SelectOptionIndicator", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "If `true`, the item indicator remains mounted when the item is not\nselected." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-option-text.json b/docs/reference/generated/select-option-text.json new file mode 100644 index 0000000000..154388a990 --- /dev/null +++ b/docs/reference/generated/select-option-text.json @@ -0,0 +1,5 @@ +{ + "name": "SelectOptionText", + "description": "", + "props": {} +} diff --git a/docs/reference/generated/select-option.json b/docs/reference/generated/select-option.json new file mode 100644 index 0000000000..ed2e0c9116 --- /dev/null +++ b/docs/reference/generated/select-option.json @@ -0,0 +1,20 @@ +{ + "name": "SelectOption", + "description": "", + "props": { + "disabled": { + "type": "boolean", + "default": "false", + "description": "If `true`, the select option will be disabled." + }, + "label": { + "type": "string", + "description": "A text representation of the select option's content.\nUsed for keyboard text navigation matching." + }, + "value": { + "type": "any", + "default": "null", + "description": "The value of the select option." + } + } +} diff --git a/docs/reference/generated/select-popup.json b/docs/reference/generated/select-popup.json new file mode 100644 index 0000000000..81eb4dde51 --- /dev/null +++ b/docs/reference/generated/select-popup.json @@ -0,0 +1,18 @@ +{ + "name": "SelectPopup", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "id": { + "type": "string", + "description": "The id of the popup element." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-positioner.json b/docs/reference/generated/select-positioner.json new file mode 100644 index 0000000000..9f26988621 --- /dev/null +++ b/docs/reference/generated/select-positioner.json @@ -0,0 +1,77 @@ +{ + "name": "SelectPositioner", + "description": "", + "props": { + "alignment": { + "type": "'start' | 'center' | 'end'", + "default": "'start'", + "description": "The alignment of the Select element to the anchor element along its cross axis." + }, + "alignmentOffset": { + "type": "number", + "default": "0", + "description": "The offset of the Select element along its alignment axis." + }, + "anchor": { + "type": "React.Ref | Element | VirtualElement | (() => Element | VirtualElement | null) | null", + "description": "The anchor element to which the Select popup will be placed at." + }, + "arrowPadding": { + "type": "number", + "default": "5", + "description": "Determines the padding between the arrow and the Select popup's edges. Useful when the popover\npopup has rounded corners via `border-radius`." + }, + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "collisionBoundary": { + "type": "'clippingAncestors' | Element | Element[] | Rect", + "default": "'clippingAncestors'", + "description": "The boundary that the Select element should be constrained to." + }, + "collisionPadding": { + "type": "number | Rect", + "default": "5", + "description": "The padding of the collision boundary." + }, + "container": { + "type": "React.Ref | HTMLElement | null", + "description": "The container element to which the Select popup will be appended to." + }, + "hideWhenDetached": { + "type": "boolean", + "default": "false", + "description": "If `true`, the Select will be hidden if it is detached from its anchor element due to\ndiffering clipping contexts." + }, + "positionMethod": { + "type": "'absolute' | 'fixed'", + "default": "'absolute'", + "description": "The CSS position method for positioning the Select popup element." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + }, + "side": { + "type": "'top' | 'bottom' | 'left' | 'right'", + "default": "'bottom'", + "description": "The side of the anchor element that the Select element should align to." + }, + "sideOffset": { + "type": "number", + "default": "0", + "description": "The gap between the anchor element and the Select element." + }, + "sticky": { + "type": "boolean", + "default": "false", + "description": "If `true`, allow the Select to remain in stuck view while the anchor element is scrolled out\nof view." + }, + "trackAnchor": { + "type": "boolean", + "default": "true", + "description": "Whether the select popup continuously tracks its anchor after the initial positioning upon mount." + } + } +} diff --git a/docs/reference/generated/select-root.json b/docs/reference/generated/select-root.json new file mode 100644 index 0000000000..cb26d3dfdf --- /dev/null +++ b/docs/reference/generated/select-root.json @@ -0,0 +1,61 @@ +{ + "name": "SelectRoot", + "description": "", + "props": { + "alignOptionToTrigger": { + "type": "boolean", + "default": "true", + "description": "Determines if the selected option inside the popup should align to the trigger element." + }, + "animated": { + "type": "boolean", + "default": "true", + "description": "If `true`, the Select supports CSS-based animations and transitions.\nIt is kept in the DOM until the animation completes." + }, + "defaultOpen": { + "type": "boolean", + "default": "false", + "description": "If `true`, the Select is initially open." + }, + "defaultValue": { + "type": "any", + "default": "null", + "description": "The default value of the select." + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "If `true`, the Select is disabled." + }, + "name": { + "type": "string", + "description": "The name of the Select in the owning form." + }, + "onOpenChange": { + "type": "function", + "description": "Callback fired when the component requests to be opened or closed." + }, + "onValueChange": { + "type": "function", + "description": "Callback fired when the value of the select changes. Use when controlled." + }, + "open": { + "type": "boolean", + "description": "Allows to control whether the dropdown is open.\nThis is a controlled counterpart of `defaultOpen`." + }, + "readOnly": { + "type": "boolean", + "default": "false", + "description": "If `true`, the Select is read-only." + }, + "required": { + "type": "boolean", + "default": "false", + "description": "If `true`, the Select is required." + }, + "value": { + "type": "any", + "description": "The value of the select." + } + } +} diff --git a/docs/reference/generated/select-scroll-down-arrow.json b/docs/reference/generated/select-scroll-down-arrow.json new file mode 100644 index 0000000000..5bd4ed803c --- /dev/null +++ b/docs/reference/generated/select-scroll-down-arrow.json @@ -0,0 +1,11 @@ +{ + "name": "SelectScrollDownArrow", + "description": "", + "props": { + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether the component should be kept mounted when it is not rendered." + } + } +} diff --git a/docs/reference/generated/select-scroll-up-arrow.json b/docs/reference/generated/select-scroll-up-arrow.json new file mode 100644 index 0000000000..6a28db98e3 --- /dev/null +++ b/docs/reference/generated/select-scroll-up-arrow.json @@ -0,0 +1,11 @@ +{ + "name": "SelectScrollUpArrow", + "description": "", + "props": { + "keepMounted": { + "type": "boolean", + "default": "false", + "description": "Whether the component should be kept mounted when it is not rendered." + } + } +} diff --git a/docs/reference/generated/select-trigger.json b/docs/reference/generated/select-trigger.json new file mode 100644 index 0000000000..24a9b98b9e --- /dev/null +++ b/docs/reference/generated/select-trigger.json @@ -0,0 +1,28 @@ +{ + "name": "SelectTrigger", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "If `true`, the component is disabled." + }, + "focusableWhenDisabled": { + "type": "boolean", + "default": "false", + "description": "If `true`, allows a disabled button to receive focus." + }, + "label": { + "type": "string", + "description": "Label of the button" + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/reference/generated/select-value.json b/docs/reference/generated/select-value.json new file mode 100644 index 0000000000..6874711e88 --- /dev/null +++ b/docs/reference/generated/select-value.json @@ -0,0 +1,18 @@ +{ + "name": "SelectValue", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "placeholder": { + "type": "string", + "description": "The placeholder value to display when the value is empty (such as during SSR)." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} From 7cf48f499fc0baa782017b514f4dd551e95b7913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 18 Nov 2024 11:53:10 +0100 Subject: [PATCH 2/6] [test] Switch vitest to forks mode and limit concurrency on CI (#823) --- vitest.config.mts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vitest.config.mts b/vitest.config.mts index d5e50b26a3..91159e7a7f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,6 +2,7 @@ import path from 'path'; import { defineConfig } from 'vitest/config'; const WORKSPACE_ROOT = path.resolve(__dirname, './'); +const CI = process.env.CI === 'true'; export default defineConfig({ test: { @@ -18,5 +19,15 @@ export default defineConfig({ env: { MUI_VITEST: 'true', }, + pool: 'forks', + poolOptions: { + forks: { + minForks: CI ? 2 : undefined, + maxForks: CI ? 2 : undefined, + }, + }, + }, + optimizeDeps: { + include: ['@vitest/coverage-v8/browser'], }, }); From d7089ab80d6d13b99b82b365e2d355fd81355374 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 19 Nov 2024 16:18:11 +0800 Subject: [PATCH 3/6] [Composite] Support RTL navigation, Home/End keys (#825) --- .../src/Composite/Root/CompositeRoot.test.tsx | 444 ++++++++++++++++++ .../src/Composite/Root/CompositeRoot.tsx | 18 +- .../src/Composite/Root/useCompositeRoot.ts | 71 ++- packages/mui-base/src/Composite/composite.ts | 32 +- .../src/RadioGroup/Root/RadioGroupRoot.tsx | 2 +- .../src/utils/hasComputedStyleMapSupport.ts | 22 + 6 files changed, 573 insertions(+), 16 deletions(-) create mode 100644 packages/mui-base/src/Composite/Root/CompositeRoot.test.tsx create mode 100644 packages/mui-base/src/utils/hasComputedStyleMapSupport.ts diff --git a/packages/mui-base/src/Composite/Root/CompositeRoot.test.tsx b/packages/mui-base/src/Composite/Root/CompositeRoot.test.tsx new file mode 100644 index 0000000000..401729c9ca --- /dev/null +++ b/packages/mui-base/src/Composite/Root/CompositeRoot.test.tsx @@ -0,0 +1,444 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { + act, + createRenderer, + describeSkipIf, + fireEvent, + flushMicrotasks, +} from '@mui/internal-test-utils'; +import { CompositeItem } from '../Item/CompositeItem'; +import { CompositeRoot } from './CompositeRoot'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +describe('Composite', () => { + const { render } = createRenderer(); + + describe('list', () => { + it('controlled mode', async () => { + function App() { + const [activeIndex, setActiveIndex] = React.useState(0); + return ( + + 1 + 2 + 3 + + ); + } + + const { getByTestId } = render(); + + const item1 = getByTestId('1'); + const item2 = getByTestId('2'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + + expect(item1).to.have.attribute('data-active'); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + + fireEvent.keyDown(item3, { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + }); + + it('uncontrolled mode', async () => { + const { getByTestId } = render( + + 1 + 2 + 3 + , + ); + + const item1 = getByTestId('1'); + const item2 = getByTestId('2'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + + expect(item1).to.have.attribute('data-active'); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + + fireEvent.keyDown(item3, { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + }); + + describe('Home and End keys', () => { + it('Home key moves focus to the first item', async () => { + const { getByTestId } = render( + + 1 + 2 + 3 + , + ); + + const item1 = getByTestId('1'); + const item3 = getByTestId('3'); + + act(() => item3.focus()); + expect(item3).to.have.attribute('data-active'); + + fireEvent.keyDown(item3, { key: 'Home' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + }); + + it('End key moves focus to the last item', async () => { + const { getByTestId } = render( + + 1 + 2 + 3 + , + ); + + const item1 = getByTestId('1'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + expect(item1).to.have.attribute('data-active'); + + fireEvent.keyDown(item1, { key: 'End' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + }); + }); + + describeSkipIf(isJSDOM)('rtl', () => { + it('horizontal orientation', async () => { + const { getByTestId } = render( +
+ + 1 + 2 + 3 + +
, + ); + + const item1 = getByTestId('1'); + const item2 = getByTestId('2'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + + expect(item1).to.have.attribute('data-active'); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-active'); + + fireEvent.keyDown(item1, { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + + fireEvent.keyDown(item3, { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + + // loop backward + fireEvent.keyDown(item1, { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + }); + + it('both horizontal and vertical orientation', async () => { + const { getByTestId } = render( +
+ + 1 + 2 + 3 + +
, + ); + + const item1 = getByTestId('1'); + const item2 = getByTestId('2'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + + expect(item1).to.have.attribute('data-active'); + + fireEvent.keyDown(item1, { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + + fireEvent.keyDown(item3, { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-active'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-active'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-active'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + }); + }); + }); + + describe('grid', () => { + it('uniform 1x1 items', async () => { + function App() { + return ( + // 1 to 9 numpad + + {['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((i) => ( + + {i} + + ))} + + ); + } + + const { getByTestId } = await render(); + + act(() => getByTestId('1').focus()); + expect(getByTestId('1')).to.have.attribute('data-active'); + + fireEvent.keyDown(getByTestId('1'), { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('tabindex', '0'); + expect(getByTestId('4')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('4'), { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(getByTestId('5')).to.have.attribute('data-active'); + expect(getByTestId('5')).to.have.attribute('tabindex', '0'); + expect(getByTestId('5')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('5'), { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(getByTestId('8')).to.have.attribute('data-active'); + expect(getByTestId('8')).to.have.attribute('tabindex', '0'); + expect(getByTestId('8')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('8'), { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(getByTestId('7')).to.have.attribute('data-active'); + expect(getByTestId('7')).to.have.attribute('tabindex', '0'); + expect(getByTestId('7')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('7'), { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('tabindex', '0'); + expect(getByTestId('4')).toHaveFocus(); + + act(() => getByTestId('9').focus()); + await flushMicrotasks(); + expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(getByTestId('9'), { key: 'Home' }); + await flushMicrotasks(); + expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(getByTestId('1'), { key: 'End' }); + await flushMicrotasks(); + expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('tabindex', '0'); + }); + + describeSkipIf(isJSDOM)('rtl', () => { + it('horizontal orientation', async () => { + const { getByTestId } = render( +
+ + {['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((i) => ( + + {i} + + ))} + +
, + ); + + act(() => getByTestId('1').focus()); + expect(getByTestId('1')).to.have.attribute('data-active'); + + fireEvent.keyDown(getByTestId('1'), { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(getByTestId('2')).to.have.attribute('data-active'); + expect(getByTestId('2')).to.have.attribute('tabindex', '0'); + expect(getByTestId('2')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('2'), { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(getByTestId('3')).to.have.attribute('data-active'); + expect(getByTestId('3')).to.have.attribute('tabindex', '0'); + expect(getByTestId('3')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('3'), { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('tabindex', '0'); + expect(getByTestId('4')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('4'), { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(getByTestId('5')).to.have.attribute('data-active'); + expect(getByTestId('5')).to.have.attribute('tabindex', '0'); + expect(getByTestId('5')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('5'), { key: 'Home' }); + await flushMicrotasks(); + expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(getByTestId('1'), { key: 'End' }); + await flushMicrotasks(); + expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('tabindex', '0'); + }); + + it('both horizontal and vertical orientation', async () => { + const { getByTestId } = await render( +
+ + {['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((i) => ( + + {i} + + ))} + +
, + ); + + act(() => getByTestId('1').focus()); + expect(getByTestId('1')).to.have.attribute('data-active'); + + fireEvent.keyDown(getByTestId('1'), { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('tabindex', '0'); + expect(getByTestId('4')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('4'), { key: 'ArrowLeft' }); + await flushMicrotasks(); + expect(getByTestId('5')).to.have.attribute('data-active'); + expect(getByTestId('5')).to.have.attribute('tabindex', '0'); + expect(getByTestId('5')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('5'), { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(getByTestId('8')).to.have.attribute('data-active'); + expect(getByTestId('8')).to.have.attribute('tabindex', '0'); + expect(getByTestId('8')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('8'), { key: 'ArrowRight' }); + await flushMicrotasks(); + expect(getByTestId('7')).to.have.attribute('data-active'); + expect(getByTestId('7')).to.have.attribute('tabindex', '0'); + expect(getByTestId('7')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('7'), { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(getByTestId('4')).to.have.attribute('data-active'); + expect(getByTestId('4')).to.have.attribute('tabindex', '0'); + expect(getByTestId('4')).toHaveFocus(); + + fireEvent.keyDown(getByTestId('4'), { key: 'End' }); + await flushMicrotasks(); + expect(getByTestId('9')).to.have.attribute('data-active'); + expect(getByTestId('9')).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(getByTestId('9'), { key: 'Home' }); + await flushMicrotasks(); + expect(getByTestId('1')).to.have.attribute('data-active'); + expect(getByTestId('1')).to.have.attribute('tabindex', '0'); + }); + }); + }); +}); diff --git a/packages/mui-base/src/Composite/Root/CompositeRoot.tsx b/packages/mui-base/src/Composite/Root/CompositeRoot.tsx index 089addb887..5cdeff18e1 100644 --- a/packages/mui-base/src/Composite/Root/CompositeRoot.tsx +++ b/packages/mui-base/src/Composite/Root/CompositeRoot.tsx @@ -25,10 +25,21 @@ const CompositeRoot = React.forwardRef(function CompositeRoot( itemSizes, loop, cols, + enableHomeAndEndKeys, ...otherProps } = props; - const { getRootProps, activeIndex, onActiveIndexChange, elementsRef } = useCompositeRoot(props); + const { getRootProps, activeIndex, onActiveIndexChange, elementsRef } = useCompositeRoot({ + itemSizes, + cols, + loop, + dense, + orientation, + activeIndex: activeIndexProp, + onActiveIndexChange: onActiveIndexChangeProp, + rootRef: forwardedRef, + enableHomeAndEndKeys, + }); const { renderElement } = useComponentRenderer({ propGetter: getRootProps, @@ -62,6 +73,7 @@ namespace CompositeRoot { onActiveIndexChange?: (index: number) => void; itemSizes?: Dimensions[]; dense?: boolean; + enableHomeAndEndKeys?: boolean; } } @@ -90,6 +102,10 @@ CompositeRoot.propTypes /* remove-proptypes */ = { * @ignore */ dense: PropTypes.bool, + /** + * @ignore + */ + enableHomeAndEndKeys: PropTypes.bool, /** * @ignore */ diff --git a/packages/mui-base/src/Composite/Root/useCompositeRoot.ts b/packages/mui-base/src/Composite/Root/useCompositeRoot.ts index 3a42f6f8dd..ca5a49748f 100644 --- a/packages/mui-base/src/Composite/Root/useCompositeRoot.ts +++ b/packages/mui-base/src/Composite/Root/useCompositeRoot.ts @@ -1,13 +1,17 @@ 'use client'; import * as React from 'react'; -import { useEventCallback } from '../../utils/useEventCallback'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useForkRef } from '../../utils/useForkRef'; import { ALL_KEYS, + ARROW_KEYS, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, + HOME, + END, buildCellMap, findNonDisabledIndex, getCellIndexOfCorner, @@ -15,11 +19,15 @@ import { getGridNavigatedIndex, getMaxIndex, getMinIndex, + getTextDirection, HORIZONTAL_KEYS, + HORIZONTAL_KEYS_WITH_EXTRA_KEYS, isDisabled, isIndexOutOfBounds, VERTICAL_KEYS, + VERTICAL_KEYS_WITH_EXTRA_KEYS, type Dimensions, + type TextDirection, } from '../composite'; export interface UseCompositeRootParameters { @@ -30,6 +38,13 @@ export interface UseCompositeRootParameters { onActiveIndexChange?: (index: number) => void; dense?: boolean; itemSizes?: Array; + rootRef?: React.Ref; + /** + * When `true`, pressing the Home key moves focus to the first item, + * and pressing the End key moves focus to the last item. + * @default false + */ + enableHomeAndEndKeys?: boolean; } // Advanced options of Composite, to be implemented later if needed. @@ -47,6 +62,8 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { orientation = 'both', activeIndex: externalActiveIndex, onActiveIndexChange: externalSetActiveIndex, + rootRef: externalRef, + enableHomeAndEndKeys = false, } = params; const [internalActiveIndex, internalSetActiveIndex] = React.useState(0); @@ -56,17 +73,36 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { const activeIndex = externalActiveIndex ?? internalActiveIndex; const onActiveIndexChange = useEventCallback(externalSetActiveIndex ?? internalSetActiveIndex); + const textDirectionRef = React.useRef(null); + + const rootRef = React.useRef(null); + const handleRootRef = useEventCallback((element: HTMLElement) => { + if (!element) { + return; + } + + rootRef.current = element; + + textDirectionRef.current = getTextDirection(element); + }); + const mergedRef = useForkRef(handleRootRef, externalRef); + const elementsRef = React.useRef>([]); const getRootProps = React.useCallback( (externalProps = {}) => mergeReactProps<'div'>(externalProps, { 'aria-orientation': orientation === 'both' ? undefined : orientation, + ref: mergedRef, onKeyDown(event) { - if (!ALL_KEYS.includes(event.key)) { + const RELEVANT_KEYS = enableHomeAndEndKeys ? ALL_KEYS : ARROW_KEYS; + + if (!RELEVANT_KEYS.includes(event.key)) { return; } + const isRtl = textDirectionRef?.current === 'rtl'; + let nextIndex = activeIndex; const minIndex = getMinIndex(elementsRef, disabledIndices); const maxIndex = getMaxIndex(elementsRef, disabledIndices); @@ -130,31 +166,44 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { // eslint-disable-next-line no-nested-ternary event.key === ARROW_DOWN ? 'bl' : event.key === ARROW_RIGHT ? 'tr' : 'tl', ), + rtl: isRtl, }, ) ] as number; // navigated cell will never be nullish } + const horizontalEndKey = isRtl ? ARROW_LEFT : ARROW_RIGHT; const toEndKeys = { - horizontal: [ARROW_RIGHT], + horizontal: [horizontalEndKey], vertical: [ARROW_DOWN], - both: [ARROW_RIGHT, ARROW_DOWN], + both: [horizontalEndKey, ARROW_DOWN], }[orientation]; + const horizontalStartKey = isRtl ? ARROW_RIGHT : ARROW_LEFT; const toStartKeys = { - horizontal: [ARROW_LEFT], + horizontal: [horizontalStartKey], vertical: [ARROW_UP], - both: [ARROW_LEFT, ARROW_UP], + both: [horizontalStartKey, ARROW_UP], }[orientation]; const preventedKeys = isGrid - ? ALL_KEYS + ? RELEVANT_KEYS : { - horizontal: HORIZONTAL_KEYS, - vertical: VERTICAL_KEYS, - both: ALL_KEYS, + horizontal: enableHomeAndEndKeys + ? HORIZONTAL_KEYS_WITH_EXTRA_KEYS + : HORIZONTAL_KEYS, + vertical: enableHomeAndEndKeys ? VERTICAL_KEYS_WITH_EXTRA_KEYS : VERTICAL_KEYS, + both: RELEVANT_KEYS, }[orientation]; + if (enableHomeAndEndKeys) { + if (event.key === HOME) { + nextIndex = minIndex; + } else if (event.key === END) { + nextIndex = maxIndex; + } + } + if (nextIndex === activeIndex && [...toEndKeys, ...toStartKeys].includes(event.key)) { if (loop && nextIndex === maxIndex && toEndKeys.includes(event.key)) { nextIndex = minIndex; @@ -193,8 +242,10 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { isGrid, itemSizes, loop, + mergedRef, onActiveIndexChange, orientation, + enableHomeAndEndKeys, ], ); diff --git a/packages/mui-base/src/Composite/composite.ts b/packages/mui-base/src/Composite/composite.ts index 402c329c9a..5afae911b9 100644 --- a/packages/mui-base/src/Composite/composite.ts +++ b/packages/mui-base/src/Composite/composite.ts @@ -1,4 +1,8 @@ import * as React from 'react'; +import { hasComputedStyleMapSupport } from '../utils/hasComputedStyleMapSupport'; +import { ownerWindow } from '../utils/owner'; + +export type TextDirection = 'ltr' | 'rtl'; export interface Dimensions { width: number; @@ -9,10 +13,15 @@ export const ARROW_UP = 'ArrowUp'; export const ARROW_DOWN = 'ArrowDown'; export const ARROW_LEFT = 'ArrowLeft'; export const ARROW_RIGHT = 'ArrowRight'; +export const HOME = 'Home'; +export const END = 'End'; export const HORIZONTAL_KEYS = [ARROW_LEFT, ARROW_RIGHT]; +export const HORIZONTAL_KEYS_WITH_EXTRA_KEYS = [ARROW_LEFT, ARROW_RIGHT, HOME, END]; export const VERTICAL_KEYS = [ARROW_UP, ARROW_DOWN]; -export const ALL_KEYS = [...HORIZONTAL_KEYS, ...VERTICAL_KEYS]; +export const VERTICAL_KEYS_WITH_EXTRA_KEYS = [ARROW_UP, ARROW_DOWN, HOME, END]; +export const ARROW_KEYS = [...HORIZONTAL_KEYS, ...VERTICAL_KEYS]; +export const ALL_KEYS = [...ARROW_KEYS, HOME, END]; function stopEvent(event: Event | React.SyntheticEvent) { event.preventDefault(); @@ -83,6 +92,7 @@ export function getGridNavigatedIndex( minIndex, maxIndex, prevIndex, + rtl, stopEvent: stop = false, }: { event: React.KeyboardEvent; @@ -93,6 +103,7 @@ export function getGridNavigatedIndex( minIndex: number; maxIndex: number; prevIndex: number; + rtl: boolean; stopEvent?: boolean; }, ) { @@ -161,9 +172,12 @@ export function getGridNavigatedIndex( // Remains on the same row/column. if (orientation === 'both') { + const nextKey = rtl ? ARROW_LEFT : ARROW_RIGHT; + const prevKey = rtl ? ARROW_RIGHT : ARROW_LEFT; + const prevRow = Math.floor(prevIndex / cols); - if (event.key === ARROW_RIGHT) { + if (event.key === nextKey) { if (stop) { stopEvent(event); } @@ -192,7 +206,7 @@ export function getGridNavigatedIndex( } } - if (event.key === ARROW_LEFT) { + if (event.key === prevKey) { if (stop) { stopEvent(event); } @@ -229,7 +243,7 @@ export function getGridNavigatedIndex( if (isIndexOutOfBounds(elementsRef, nextIndex)) { if (loop && lastRow) { nextIndex = - event.key === ARROW_LEFT + event.key === prevKey ? maxIndex : findNonDisabledIndex(elementsRef, { startingIndex: prevIndex - (prevIndex % cols) - 1, @@ -347,3 +361,13 @@ export function isDisabled( element.getAttribute('aria-disabled') === 'true' ); } + +export function getTextDirection(element: HTMLElement): TextDirection { + if (hasComputedStyleMapSupport()) { + const direction = element.computedStyleMap().get('direction'); + + return (direction as CSSKeywordValue)?.value as TextDirection; + } + + return ownerWindow(element).getComputedStyle(element).direction as TextDirection; +} diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx index be5658b2a9..4fb91a3990 100644 --- a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx +++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx @@ -86,7 +86,7 @@ const RadioGroupRoot = React.forwardRef(function RadioGroupRoot( return ( - + ); diff --git a/packages/mui-base/src/utils/hasComputedStyleMapSupport.ts b/packages/mui-base/src/utils/hasComputedStyleMapSupport.ts new file mode 100644 index 0000000000..347a348003 --- /dev/null +++ b/packages/mui-base/src/utils/hasComputedStyleMapSupport.ts @@ -0,0 +1,22 @@ +'use client'; + +let node: HTMLElement | null = null; + +let cachedHasComputedStyleMapSupport: boolean | undefined; + +/** + * Detect if Element: computedStyleMap() is supported as a more performant + * alternative to getComputedStyles() + * Only Firefox does not have support as of Nov 2024. + * https://developer.mozilla.org/en-US/docs/Web/API/Element/computedStyleMap + */ +export function hasComputedStyleMapSupport() { + if (node == null) { + node = document.createElement('div'); + } + + if (cachedHasComputedStyleMapSupport === undefined) { + cachedHasComputedStyleMapSupport = node.computedStyleMap !== undefined; + } + return cachedHasComputedStyleMapSupport; +} From c39d87353f96c035ae43df8291861e5786a4db77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 19 Nov 2024 11:09:10 +0100 Subject: [PATCH 4/6] [Menu, Popover, Select] Add `data-pressed` to MenuTrigger, PopoverTrigger and SelectTrigger (#826) --- docs/data/api/select-trigger.json | 3 ++ .../src/Menu/Trigger/MenuTrigger.test.tsx | 21 +++++++- .../mui-base/src/Menu/Trigger/MenuTrigger.tsx | 4 +- .../mui-base/src/Popover/Root/PopoverRoot.tsx | 3 ++ .../src/Popover/Root/PopoverRootContext.ts | 1 + .../src/Popover/Root/usePopoverRoot.ts | 10 ++++ .../Popover/Trigger/PopoverTrigger.test.tsx | 54 +++++++++++++++++++ .../src/Popover/Trigger/PopoverTrigger.tsx | 23 ++++++-- .../src/Select/Trigger/SelectTrigger.test.tsx | 39 ++++++++++++++ .../src/Select/Trigger/SelectTrigger.tsx | 4 +- .../src/utils/popupOpenStateMapping.ts | 38 +++++++++---- 11 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx diff --git a/docs/data/api/select-trigger.json b/docs/data/api/select-trigger.json index 92239a7615..f816e4ed5e 100644 --- a/docs/data/api/select-trigger.json +++ b/docs/data/api/select-trigger.json @@ -11,7 +11,10 @@ "import { Select } from '@base_ui/react/Select';\nconst SelectTrigger = Select.Trigger;" ], "classes": [], + "spread": true, + "themeDefaultProps": true, "muiName": "SelectTrigger", + "forwardsRefTo": "HTMLDivElement", "filename": "/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx", "inheritance": null, "demos": "", diff --git a/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx b/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx index 836cb1488e..3c199cc104 100644 --- a/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx +++ b/packages/mui-base/src/Menu/Trigger/MenuTrigger.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { FloatingRootContext, FloatingTree } from '@floating-ui/react'; import userEvent from '@testing-library/user-event'; -import { act } from '@mui/internal-test-utils'; +import { act, screen } from '@mui/internal-test-utils'; import { Menu } from '@base_ui/react/Menu'; import { describeConformance, createRenderer } from '#test-utils'; import { MenuRootContext } from '../Root/MenuRootContext'; @@ -164,4 +164,23 @@ describe('', () => { expect(button).to.have.attribute('aria-expanded', 'true'); }); }); + + describe('style hooks', () => { + it('should have the data-popup-open and data-pressed attributes when open', async () => { + await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + }); }); diff --git a/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx b/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx index 4c800e0957..eb0ba70bef 100644 --- a/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx +++ b/packages/mui-base/src/Menu/Trigger/MenuTrigger.tsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { useFloatingTree } from '@floating-ui/react'; import { useMenuTrigger } from './useMenuTrigger'; import { useMenuRootContext } from '../Root/MenuRootContext'; -import { triggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { pressableTriggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { BaseUIComponentProps } from '../../utils/types'; @@ -52,7 +52,7 @@ const MenuTrigger = React.forwardRef(function MenuTrigger( className, ownerState, propGetter: (externalProps) => getTriggerProps(getRootProps(externalProps)), - customStyleHookMapping: triggerOpenStateMapping, + customStyleHookMapping: pressableTriggerOpenStateMapping, extraProps: other, }); diff --git a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx index 7a52ac417a..354645c25b 100644 --- a/packages/mui-base/src/Popover/Root/PopoverRoot.tsx +++ b/packages/mui-base/src/Popover/Root/PopoverRoot.tsx @@ -40,6 +40,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { descriptionId, setDescriptionId, openMethod, + openReason, } = usePopoverRoot({ openOnHover, delay: delayWithDefault, @@ -73,6 +74,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { getRootPopupProps, getRootTriggerProps, openMethod, + openReason, }), [ openOnHover, @@ -96,6 +98,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) { getRootPopupProps, getRootTriggerProps, openMethod, + openReason, ], ); diff --git a/packages/mui-base/src/Popover/Root/PopoverRootContext.ts b/packages/mui-base/src/Popover/Root/PopoverRootContext.ts index 9efaa21b9d..7850e5e252 100644 --- a/packages/mui-base/src/Popover/Root/PopoverRootContext.ts +++ b/packages/mui-base/src/Popover/Root/PopoverRootContext.ts @@ -27,6 +27,7 @@ export interface PopoverRootContext { getRootTriggerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; getRootPopupProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; openMethod: InteractionType | null; + openReason: OpenChangeReason | null; } export const PopoverRootContext = React.createContext(undefined); diff --git a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts index d91198215c..4368f9173d 100644 --- a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts +++ b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts @@ -42,6 +42,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo const [descriptionId, setDescriptionId] = React.useState(); const [triggerElement, setTriggerElement] = React.useState(null); const [positionerElement, setPositionerElement] = React.useState(null); + const [openReason, setOpenReason] = React.useState(null); const popupRef = React.useRef(null); @@ -69,6 +70,12 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo setMounted(false); } } + + if (nextOpen) { + setOpenReason(reason ?? null); + } else { + setOpenReason(null); + } }, ); @@ -138,6 +145,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo floatingRootContext: context, instantType, openMethod, + openReason, }), [ mounted, @@ -154,6 +162,7 @@ export function usePopoverRoot(params: usePopoverRoot.Parameters): usePopoverRoo instantType, openMethod, triggerProps, + openReason, ], ); } @@ -222,5 +231,6 @@ export namespace usePopoverRoot { setPositionerElement: React.Dispatch>; popupRef: React.RefObject; openMethod: InteractionType | null; + openReason: OpenChangeReason | null; } } diff --git a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx index 9bb0c77738..53b6e382f0 100644 --- a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx +++ b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.test.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { Popover } from '@base_ui/react/Popover'; import { createRenderer, describeConformance } from '#test-utils'; +import { expect } from 'chai'; +import { act, screen } from '@mui/internal-test-utils'; describe('', () => { const { render } = createRenderer(); @@ -15,4 +17,56 @@ describe('', () => { ); }, })); + + describe('style hooks', () => { + it('should have the data-popup-open and data-pressed attributes when open by clicking', async () => { + await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + + it('should have the data-popup-open but not the data-pressed attribute when open by hover', async () => { + const { user } = await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).not.to.have.attribute('data-pressed'); + }); + + it('should have the data-popup-open and data-pressed attributes when open by click when `openOnHover=true`', async () => { + const { user } = await render( + + + , + ); + + const trigger = screen.getByRole('button'); + + await user.hover(trigger); + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + }); }); diff --git a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx index 42906c985f..1d9057c2d1 100644 --- a/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx +++ b/packages/mui-base/src/Popover/Trigger/PopoverTrigger.tsx @@ -5,7 +5,11 @@ import { usePopoverRootContext } from '../Root/PopoverRootContext'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useForkRef } from '../../utils/useForkRef'; import type { BaseUIComponentProps } from '../../utils/types'; -import { triggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { + triggerOpenStateMapping, + pressableTriggerOpenStateMapping, +} from '../../utils/popupOpenStateMapping'; +import { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; /** * Renders a trigger element that opens the popover. @@ -24,12 +28,25 @@ const PopoverTrigger = React.forwardRef(function PopoverTrigger( ) { const { render, className, ...otherProps } = props; - const { open, setTriggerElement, getRootTriggerProps } = usePopoverRootContext(); + const { open, setTriggerElement, getRootTriggerProps, openReason } = usePopoverRootContext(); const ownerState: PopoverTrigger.OwnerState = React.useMemo(() => ({ open }), [open]); const mergedRef = useForkRef(forwardedRef, setTriggerElement); + const customStyleHookMapping: CustomStyleHookMapping<{ open: boolean }> = React.useMemo( + () => ({ + open(value) { + if (value && openReason === 'click') { + return pressableTriggerOpenStateMapping.open(value); + } + + return triggerOpenStateMapping.open(value); + }, + }), + [openReason], + ); + const { renderElement } = useComponentRenderer({ propGetter: getRootTriggerProps, render: render ?? 'button', @@ -37,7 +54,7 @@ const PopoverTrigger = React.forwardRef(function PopoverTrigger( ownerState, ref: mergedRef, extraProps: otherProps, - customStyleHookMapping: triggerOpenStateMapping, + customStyleHookMapping, }); return renderElement(); diff --git a/packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx b/packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx new file mode 100644 index 0000000000..f4c0b1e8c9 --- /dev/null +++ b/packages/mui-base/src/Select/Trigger/SelectTrigger.test.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; +import { expect } from 'chai'; +import { act, screen } from '@mui/internal-test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); + + describe('style hooks', () => { + it('should have the data-popup-open and data-pressed attributes when open', async () => { + await render( + + + , + ); + + const trigger = screen.getByRole('combobox'); + + await act(async () => { + trigger.click(); + }); + + expect(trigger).to.have.attribute('data-popup-open'); + expect(trigger).to.have.attribute('data-pressed'); + }); + }); +}); diff --git a/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx b/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx index 909706b9ad..856ac0dbe5 100644 --- a/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx +++ b/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx @@ -6,7 +6,7 @@ import { useSelectRootContext } from '../Root/SelectRootContext'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { BaseUIComponentProps } from '../../utils/types'; import { useFieldRootContext } from '../../Field/Root/FieldRootContext'; -import { triggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { pressableTriggerOpenStateMapping } from '../../utils/popupOpenStateMapping'; /** * @@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef(function SelectTrigger( className, ownerState, propGetter: (externalProps) => getTriggerProps(getRootTriggerProps(externalProps)), - customStyleHookMapping: triggerOpenStateMapping, + customStyleHookMapping: pressableTriggerOpenStateMapping, extraProps: otherProps, }); diff --git a/packages/mui-base/src/utils/popupOpenStateMapping.ts b/packages/mui-base/src/utils/popupOpenStateMapping.ts index a17d2248f4..07e4ef00fe 100644 --- a/packages/mui-base/src/utils/popupOpenStateMapping.ts +++ b/packages/mui-base/src/utils/popupOpenStateMapping.ts @@ -1,23 +1,41 @@ import type { CustomStyleHookMapping } from './getStyleHookProps'; -export const triggerOpenStateMapping: CustomStyleHookMapping<{ open: boolean }> = { +const TRIGGER_HOOK = { + 'data-popup-open': '', +}; + +const PRESSABLE_TRIGGER_HOOK = { + 'data-popup-open': '', + 'data-pressed': '', +}; + +const POPUP_HOOK = { + 'data-open': '', +}; + +export const triggerOpenStateMapping = { open(value) { if (value) { - return { - 'data-popup-open': '', - }; + return TRIGGER_HOOK; } return null; }, -}; +} satisfies CustomStyleHookMapping<{ open: boolean }>; -export const popupOpenStateMapping: CustomStyleHookMapping<{ open: boolean }> = { +export const pressableTriggerOpenStateMapping = { open(value) { if (value) { - return { - 'data-open': '', - }; + return PRESSABLE_TRIGGER_HOOK; } return null; }, -}; +} satisfies CustomStyleHookMapping<{ open: boolean }>; + +export const popupOpenStateMapping = { + open(value) { + if (value) { + return POPUP_HOOK; + } + return null; + }, +} satisfies CustomStyleHookMapping<{ open: boolean }>; From 9555490d86aa7fc76b1205c1aade0a2daa77de31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 19 Nov 2024 13:38:10 +0100 Subject: [PATCH 5/6] [docs] Add default value annotations to JSDocs (#833) --- docs/data/api/alert-dialog-root.json | 2 +- docs/data/api/dialog-root.json | 2 +- docs/data/api/menu-checkbox-item.json | 3 +++ docs/data/api/switch-root.json | 2 +- .../menu-checkbox-item.json | 5 ++++ .../api-docs/switch-root/switch-root.json | 2 +- .../generated/alert-dialog-root.json | 1 + docs/reference/generated/dialog-root.json | 1 + .../generated/menu-checkbox-item.json | 13 +++++++++ docs/reference/generated/switch-root.json | 3 ++- .../src/AlertDialog/Root/AlertDialogRoot.tsx | 2 ++ .../mui-base/src/Dialog/Root/DialogRoot.tsx | 4 ++- .../mui-base/src/Dialog/Root/useDialogRoot.ts | 2 ++ .../Menu/CheckboxItem/MenuCheckboxItem.tsx | 27 ++++++++++++++----- .../mui-base/src/Switch/Root/SwitchRoot.tsx | 4 ++- .../mui-base/src/Switch/Root/useSwitchRoot.ts | 4 ++- 16 files changed, 63 insertions(+), 14 deletions(-) diff --git a/docs/data/api/alert-dialog-root.json b/docs/data/api/alert-dialog-root.json index 8e3b7c6638..0d12c7da09 100644 --- a/docs/data/api/alert-dialog-root.json +++ b/docs/data/api/alert-dialog-root.json @@ -1,7 +1,7 @@ { "props": { "animated": { "type": { "name": "bool" }, "default": "true" }, - "defaultOpen": { "type": { "name": "bool" } }, + "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, "onOpenChange": { "type": { "name": "func" } }, "open": { "type": { "name": "bool" } } }, diff --git a/docs/data/api/dialog-root.json b/docs/data/api/dialog-root.json index 3e8b87e708..4446f4c3ae 100644 --- a/docs/data/api/dialog-root.json +++ b/docs/data/api/dialog-root.json @@ -1,7 +1,7 @@ { "props": { "animated": { "type": { "name": "bool" }, "default": "true" }, - "defaultOpen": { "type": { "name": "bool" } }, + "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, "dismissible": { "type": { "name": "bool" }, "default": "true" }, "modal": { "type": { "name": "bool" }, "default": "true" }, "onOpenChange": { "type": { "name": "func" } }, diff --git a/docs/data/api/menu-checkbox-item.json b/docs/data/api/menu-checkbox-item.json index 71f0dc291c..f94e6bdb46 100644 --- a/docs/data/api/menu-checkbox-item.json +++ b/docs/data/api/menu-checkbox-item.json @@ -1,9 +1,12 @@ { "props": { + "checked": { "type": { "name": "bool" } }, "closeOnClick": { "type": { "name": "bool" }, "default": "true" }, + "defaultChecked": { "type": { "name": "bool" }, "default": "false" }, "disabled": { "type": { "name": "bool" }, "default": "false" }, "id": { "type": { "name": "string" } }, "label": { "type": { "name": "string" } }, + "onCheckedChange": { "type": { "name": "func" } }, "onClick": { "type": { "name": "func" } } }, "name": "MenuCheckboxItem", diff --git a/docs/data/api/switch-root.json b/docs/data/api/switch-root.json index 125ef04119..98feb6280a 100644 --- a/docs/data/api/switch-root.json +++ b/docs/data/api/switch-root.json @@ -2,7 +2,7 @@ "props": { "checked": { "type": { "name": "bool" } }, "className": { "type": { "name": "union", "description": "func
| string" } }, - "defaultChecked": { "type": { "name": "bool" } }, + "defaultChecked": { "type": { "name": "bool" }, "default": "false" }, "disabled": { "type": { "name": "bool" }, "default": "false" }, "inputRef": { "type": { "name": "custom", "description": "ref" } }, "name": { "type": { "name": "string" } }, diff --git a/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json b/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json index 7c401d2eae..3fad08e5cd 100644 --- a/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json +++ b/docs/data/translations/api-docs/menu-checkbox-item/menu-checkbox-item.json @@ -1,14 +1,19 @@ { "componentDescription": "An unstyled checkbox menu item to be used within a Menu.", "propDescriptions": { + "checked": { "description": "If true, the checkbox is checked." }, "closeOnClick": { "description": "If true, the menu will close when the menu item is clicked." }, + "defaultChecked": { + "description": "The default checked state. Use when the component is uncontrolled." + }, "disabled": { "description": "If true, the menu item will be disabled." }, "id": { "description": "The id of the menu item." }, "label": { "description": "A text representation of the menu item's content. Used for keyboard text navigation matching." }, + "onCheckedChange": { "description": "Callback fired when the checked state is changed." }, "onClick": { "description": "The click handler for the menu item." } }, "classDescriptions": {} diff --git a/docs/data/translations/api-docs/switch-root/switch-root.json b/docs/data/translations/api-docs/switch-root/switch-root.json index 401676fbbc..8dc03b5a3b 100644 --- a/docs/data/translations/api-docs/switch-root/switch-root.json +++ b/docs/data/translations/api-docs/switch-root/switch-root.json @@ -6,7 +6,7 @@ "description": "Class names applied to the element or a function that returns them based on the component's state." }, "defaultChecked": { - "description": "The default checked state. Use when the component is not controlled." + "description": "The default checked state. Use when the component is uncontrolled." }, "disabled": { "description": "If true, the component is disabled and can't be interacted with." diff --git a/docs/reference/generated/alert-dialog-root.json b/docs/reference/generated/alert-dialog-root.json index 7e6420324b..1e06823d8b 100644 --- a/docs/reference/generated/alert-dialog-root.json +++ b/docs/reference/generated/alert-dialog-root.json @@ -9,6 +9,7 @@ }, "defaultOpen": { "type": "boolean", + "default": "false", "description": "Determines whether the dialog is initally open.\nThis is an uncontrolled equivalent of the `open` prop." }, "onOpenChange": { diff --git a/docs/reference/generated/dialog-root.json b/docs/reference/generated/dialog-root.json index af19098732..8ad82fc4d8 100644 --- a/docs/reference/generated/dialog-root.json +++ b/docs/reference/generated/dialog-root.json @@ -9,6 +9,7 @@ }, "defaultOpen": { "type": "boolean", + "default": "false", "description": "Determines whether the dialog is initally open.\nThis is an uncontrolled equivalent of the `open` prop." }, "dismissible": { diff --git a/docs/reference/generated/menu-checkbox-item.json b/docs/reference/generated/menu-checkbox-item.json index 8c0cde01b1..b16861866f 100644 --- a/docs/reference/generated/menu-checkbox-item.json +++ b/docs/reference/generated/menu-checkbox-item.json @@ -2,11 +2,20 @@ "name": "MenuCheckboxItem", "description": "An unstyled checkbox menu item to be used within a Menu.", "props": { + "checked": { + "type": "boolean", + "description": "If `true`, the checkbox is checked." + }, "closeOnClick": { "type": "boolean", "default": "true", "description": "If `true`, the menu will close when the menu item is clicked." }, + "defaultChecked": { + "type": "boolean", + "default": "false", + "description": "The default checked state. Use when the component is uncontrolled." + }, "disabled": { "type": "boolean", "default": "false", @@ -20,6 +29,10 @@ "type": "string", "description": "A text representation of the menu item's content.\nUsed for keyboard text navigation matching." }, + "onCheckedChange": { + "type": "function", + "description": "Callback fired when the checked state is changed." + }, "onClick": { "type": "(event) => void", "description": "The click handler for the menu item." diff --git a/docs/reference/generated/switch-root.json b/docs/reference/generated/switch-root.json index dc989c38c3..34408beef7 100644 --- a/docs/reference/generated/switch-root.json +++ b/docs/reference/generated/switch-root.json @@ -12,7 +12,8 @@ }, "defaultChecked": { "type": "boolean", - "description": "The default checked state. Use when the component is not controlled." + "default": "false", + "description": "The default checked state. Use when the component is uncontrolled." }, "disabled": { "type": "boolean", diff --git a/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.tsx b/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.tsx index 25477b7890..5601986dcd 100644 --- a/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.tsx +++ b/packages/mui-base/src/AlertDialog/Root/AlertDialogRoot.tsx @@ -67,6 +67,8 @@ AlertDialogRoot.propTypes /* remove-proptypes */ = { /** * Determines whether the dialog is initally open. * This is an uncontrolled equivalent of the `open` prop. + * + * @default false */ defaultOpen: PropTypes.bool, /** diff --git a/packages/mui-base/src/Dialog/Root/DialogRoot.tsx b/packages/mui-base/src/Dialog/Root/DialogRoot.tsx index 67106880be..f700e6f1c9 100644 --- a/packages/mui-base/src/Dialog/Root/DialogRoot.tsx +++ b/packages/mui-base/src/Dialog/Root/DialogRoot.tsx @@ -19,7 +19,7 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) { const { animated = true, children, - defaultOpen, + defaultOpen = false, dismissible = true, modal = true, onOpenChange, @@ -73,6 +73,8 @@ DialogRoot.propTypes /* remove-proptypes */ = { /** * Determines whether the dialog is initally open. * This is an uncontrolled equivalent of the `open` prop. + * + * @default false */ defaultOpen: PropTypes.bool, /** diff --git a/packages/mui-base/src/Dialog/Root/useDialogRoot.ts b/packages/mui-base/src/Dialog/Root/useDialogRoot.ts index b23d2c2d8e..5605ca355d 100644 --- a/packages/mui-base/src/Dialog/Root/useDialogRoot.ts +++ b/packages/mui-base/src/Dialog/Root/useDialogRoot.ts @@ -170,6 +170,8 @@ export interface CommonParameters { /** * Determines whether the dialog is initally open. * This is an uncontrolled equivalent of the `open` prop. + * + * @default false */ defaultOpen?: boolean; /** diff --git a/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx index 7305bea6da..830487d5c9 100644 --- a/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx +++ b/packages/mui-base/src/Menu/CheckboxItem/MenuCheckboxItem.tsx @@ -73,7 +73,7 @@ InnerMenuCheckboxItem.propTypes /* remove-proptypes */ = { // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ /** - * @ignore + * If `true`, the checkbox is checked. */ checked: PropTypes.bool, /** @@ -91,7 +91,9 @@ InnerMenuCheckboxItem.propTypes /* remove-proptypes */ = { */ closeOnClick: PropTypes.bool, /** - * @ignore + * The default checked state. Use when the component is uncontrolled. + * + * @default false */ defaultChecked: PropTypes.bool, /** @@ -121,7 +123,7 @@ InnerMenuCheckboxItem.propTypes /* remove-proptypes */ = { on: PropTypes.func.isRequired, }).isRequired, /** - * @ignore + * Callback fired when the checked state is changed. */ onCheckedChange: PropTypes.func, /** @@ -211,8 +213,19 @@ namespace MenuCheckboxItem { }; export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * If `true`, the checkbox is checked. + */ checked?: boolean; + /** + * The default checked state. Use when the component is uncontrolled. + * + * @default false + */ defaultChecked?: boolean; + /** + * Callback fired when the checked state is changed. + */ onCheckedChange?: (checked: boolean, event: Event) => void; children?: React.ReactNode; /** @@ -248,7 +261,7 @@ MenuCheckboxItem.propTypes /* remove-proptypes */ = { // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ /** - * @ignore + * If `true`, the checkbox is checked. */ checked: PropTypes.bool, /** @@ -262,7 +275,9 @@ MenuCheckboxItem.propTypes /* remove-proptypes */ = { */ closeOnClick: PropTypes.bool, /** - * @ignore + * The default checked state. Use when the component is uncontrolled. + * + * @default false */ defaultChecked: PropTypes.bool, /** @@ -280,7 +295,7 @@ MenuCheckboxItem.propTypes /* remove-proptypes */ = { */ label: PropTypes.string, /** - * @ignore + * Callback fired when the checked state is changed. */ onCheckedChange: PropTypes.func, /** diff --git a/packages/mui-base/src/Switch/Root/SwitchRoot.tsx b/packages/mui-base/src/Switch/Root/SwitchRoot.tsx index 40831d0b5d..6966b26833 100644 --- a/packages/mui-base/src/Switch/Root/SwitchRoot.tsx +++ b/packages/mui-base/src/Switch/Root/SwitchRoot.tsx @@ -104,7 +104,9 @@ SwitchRoot.propTypes /* remove-proptypes */ = { */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** - * The default checked state. Use when the component is not controlled. + * The default checked state. Use when the component is uncontrolled. + * + * @default false */ defaultChecked: PropTypes.bool, /** diff --git a/packages/mui-base/src/Switch/Root/useSwitchRoot.ts b/packages/mui-base/src/Switch/Root/useSwitchRoot.ts index 3dd896d081..8fbdf94fd4 100644 --- a/packages/mui-base/src/Switch/Root/useSwitchRoot.ts +++ b/packages/mui-base/src/Switch/Root/useSwitchRoot.ts @@ -154,7 +154,9 @@ export namespace useSwitchRoot { */ checked?: boolean; /** - * The default checked state. Use when the component is not controlled. + * The default checked state. Use when the component is uncontrolled. + * + * @default false */ defaultChecked?: boolean; /** From 9b63f1b4c2c0181f731d45f64fd1efe8461ed57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 19 Nov 2024 14:39:44 +0100 Subject: [PATCH 6/6] [core] Make callback parameters consistent (#832) --- docs/data/api/slider-root.json | 4 ++-- .../translations/api-docs/slider-root/slider-root.json | 4 ++-- docs/reference/generated/slider-root.json | 2 +- docs/reference/overrides/slider-root.json | 2 +- packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx | 2 +- packages/mui-base/src/Menu/Root/MenuRoot.tsx | 2 +- packages/mui-base/src/Menu/Root/useMenuRoot.ts | 2 +- packages/mui-base/src/Popover/Root/usePopoverRoot.ts | 2 +- .../mui-base/src/PreviewCard/Root/usePreviewCardRoot.ts | 2 +- packages/mui-base/src/Radio/Root/useRadioRoot.tsx | 2 +- packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx | 2 +- .../mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts | 2 +- packages/mui-base/src/Slider/Root/SliderRoot.test.tsx | 2 +- packages/mui-base/src/Slider/Root/SliderRoot.tsx | 3 +-- packages/mui-base/src/Slider/Root/useSliderRoot.ts | 7 +++---- packages/mui-base/src/Switch/Root/useSwitchRoot.ts | 4 ++-- packages/mui-base/src/Tabs/Root/TabsRoot.tsx | 2 +- packages/mui-base/src/Tabs/Root/TabsRootContext.ts | 2 +- packages/mui-base/src/Tabs/Root/useTabsRoot.ts | 6 +++--- packages/mui-base/src/Tabs/TabsList/useTabsList.ts | 2 +- packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts | 2 +- 21 files changed, 28 insertions(+), 30 deletions(-) diff --git a/docs/data/api/slider-root.json b/docs/data/api/slider-root.json index 077f5d8052..f9a02ac8ea 100644 --- a/docs/data/api/slider-root.json +++ b/docs/data/api/slider-root.json @@ -15,8 +15,8 @@ "onValueChange": { "type": { "name": "func" }, "signature": { - "type": "function(value: number | Array, activeThumb: number, event: Event) => void", - "describedArgs": ["value", "activeThumb", "event"] + "type": "function(value: number | Array, event: Event, activeThumbIndex: number) => void", + "describedArgs": ["value", "event", "activeThumbIndex"] } }, "onValueCommitted": { diff --git a/docs/data/translations/api-docs/slider-root/slider-root.json b/docs/data/translations/api-docs/slider-root/slider-root.json index 0d195e281c..db96dd5cf0 100644 --- a/docs/data/translations/api-docs/slider-root/slider-root.json +++ b/docs/data/translations/api-docs/slider-root/slider-root.json @@ -24,8 +24,8 @@ "description": "Callback function that is fired when the slider's value changed.", "typeDescriptions": { "value": "The new value.", - "activeThumb": "Index of the currently moved thumb.", - "event": "The event source of the callback. You can pull out the new value by accessing event.target.value (any). Warning: This is a generic event not a change event." + "event": "The event source of the callback. You can pull out the new value by accessing event.target.value (any).", + "activeThumbIndex": "Index of the currently moved thumb." } }, "onValueCommitted": { diff --git a/docs/reference/generated/slider-root.json b/docs/reference/generated/slider-root.json index 2eff7c49be..a1cc52b9cc 100644 --- a/docs/reference/generated/slider-root.json +++ b/docs/reference/generated/slider-root.json @@ -35,7 +35,7 @@ "description": "The minimum steps between values in a range slider." }, "onValueChange": { - "type": "(value, activeThumb, event) => void", + "type": "(value, event, activeThumbIndex) => void", "description": "Callback function that is fired when the slider's value changed." }, "onValueCommitted": { diff --git a/docs/reference/overrides/slider-root.json b/docs/reference/overrides/slider-root.json index 0b21a0289b..673bdceab5 100644 --- a/docs/reference/overrides/slider-root.json +++ b/docs/reference/overrides/slider-root.json @@ -2,7 +2,7 @@ "name": "SliderRoot", "props": { "onValueChange": { - "type": "(value, activeThumb, event) => void" + "type": "(value, event, activeThumbIndex) => void" }, "onValueCommitted": { "type": "(value, event) => void" diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx index 53d8dedd0b..59296d84bf 100644 --- a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx +++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx @@ -84,7 +84,7 @@ namespace MenuRadioGroup { * * @default () => {} */ - onValueChange?: (newValue: any, event: Event) => void; + onValueChange?: (value: any, event: Event) => void; } export type OwnerState = {}; diff --git a/packages/mui-base/src/Menu/Root/MenuRoot.tsx b/packages/mui-base/src/Menu/Root/MenuRoot.tsx index 68802b020a..d0ea5dc328 100644 --- a/packages/mui-base/src/Menu/Root/MenuRoot.tsx +++ b/packages/mui-base/src/Menu/Root/MenuRoot.tsx @@ -115,7 +115,7 @@ namespace MenuRoot { /** * Callback fired when the component requests to be opened or closed. */ - onOpenChange?: (open: boolean, event: Event | undefined) => void; + onOpenChange?: (open: boolean, event?: Event) => void; /** * Allows to control whether the dropdown is open. * This is a controlled counterpart of `defaultOpen`. diff --git a/packages/mui-base/src/Menu/Root/useMenuRoot.ts b/packages/mui-base/src/Menu/Root/useMenuRoot.ts index 9ccdcb6df9..e09805b073 100644 --- a/packages/mui-base/src/Menu/Root/useMenuRoot.ts +++ b/packages/mui-base/src/Menu/Root/useMenuRoot.ts @@ -210,7 +210,7 @@ export namespace useMenuRoot { /** * Callback fired when the component requests to be opened or closed. */ - onOpenChange: ((open: boolean, event: Event | undefined) => void) | undefined; + onOpenChange: ((open: boolean, event?: Event) => void) | undefined; /** * If `true`, the Menu is initially open. */ diff --git a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts index 4368f9173d..793ad1a997 100644 --- a/packages/mui-base/src/Popover/Root/usePopoverRoot.ts +++ b/packages/mui-base/src/Popover/Root/usePopoverRoot.ts @@ -183,7 +183,7 @@ export namespace usePopoverRoot { * Callback fired when the popover popup is requested to be opened or closed. Use when * controlled. */ - onOpenChange?: (isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void; + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; /** * Whether the popover popup opens when the trigger is hovered after the provided `delay`. * @default false diff --git a/packages/mui-base/src/PreviewCard/Root/usePreviewCardRoot.ts b/packages/mui-base/src/PreviewCard/Root/usePreviewCardRoot.ts index 7b9e9dfe62..4a1acb594b 100644 --- a/packages/mui-base/src/PreviewCard/Root/usePreviewCardRoot.ts +++ b/packages/mui-base/src/PreviewCard/Root/usePreviewCardRoot.ts @@ -161,7 +161,7 @@ export namespace usePreviewCardRoot { * Callback fired when the preview card popup is requested to be opened or closed. Use when * controlled. */ - onOpenChange?: (isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void; + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; /** * Whether the preview card popup opens when the trigger is hovered after the provided `delay`. * @default false diff --git a/packages/mui-base/src/Radio/Root/useRadioRoot.tsx b/packages/mui-base/src/Radio/Root/useRadioRoot.tsx index 87dda643b5..a2dc129ae1 100644 --- a/packages/mui-base/src/Radio/Root/useRadioRoot.tsx +++ b/packages/mui-base/src/Radio/Root/useRadioRoot.tsx @@ -84,7 +84,7 @@ export function useRadioRoot(params: useRadioRoot.Parameters) { setFieldTouched(true); setDirty(value !== validityData.initialValue); setCheckedValue(value); - onValueChange?.(value, event); + onValueChange?.(value, event.nativeEvent); }, }), [ diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx index 4fb91a3990..13410e31f0 100644 --- a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx +++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx @@ -130,7 +130,7 @@ namespace RadioGroupRoot { /** * Callback fired when the value changes. */ - onValueChange?: (value: unknown, event: React.ChangeEvent) => void; + onValueChange?: (value: unknown, event: Event) => void; } } diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts b/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts index 73fe309707..12bfdade11 100644 --- a/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts +++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts @@ -8,7 +8,7 @@ export interface RadioGroupRootContext { required: boolean | undefined; checkedValue: unknown; setCheckedValue: React.Dispatch>; - onValueChange: (value: unknown, event: React.ChangeEvent) => void; + onValueChange: (value: unknown, event: Event) => void; touched: boolean; setTouched: React.Dispatch>; } diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx b/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx index d3d27ff948..7aee6437ca 100644 --- a/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx +++ b/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx @@ -1255,7 +1255,7 @@ describeSkipIf(typeof Touch === 'undefined')('', () => { }); it('should pass "name" and "value" as part of the event.target for onValueChange', async () => { - const handleValueChange = stub().callsFake((newValue, thumbIndex, event) => event.target); + const handleValueChange = stub().callsFake((newValue, event) => event.target); const { getByRole } = await render( , diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.tsx b/packages/mui-base/src/Slider/Root/SliderRoot.tsx index 0346f83f65..fe0aef8c69 100644 --- a/packages/mui-base/src/Slider/Root/SliderRoot.tsx +++ b/packages/mui-base/src/Slider/Root/SliderRoot.tsx @@ -216,10 +216,9 @@ SliderRoot.propTypes /* remove-proptypes */ = { * Callback function that is fired when the slider's value changed. * * @param {number | number[]} value The new value. - * @param {number} activeThumb Index of the currently moved thumb. * @param {Event} event The event source of the callback. * You can pull out the new value by accessing `event.target.value` (any). - * **Warning**: This is a generic event not a change event. + * @param {number} activeThumbIndex Index of the currently moved thumb. */ onValueChange: PropTypes.func, /** diff --git a/packages/mui-base/src/Slider/Root/useSliderRoot.ts b/packages/mui-base/src/Slider/Root/useSliderRoot.ts index 1984aa5ac4..5db2afb84c 100644 --- a/packages/mui-base/src/Slider/Root/useSliderRoot.ts +++ b/packages/mui-base/src/Slider/Root/useSliderRoot.ts @@ -233,7 +233,7 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo value: { value, name }, }); - onValueChange(value, thumbIndex, clonedEvent); + onValueChange(value, clonedEvent, thumbIndex); }, [name, onValueChange], ); @@ -541,12 +541,11 @@ export namespace useSliderRoot { * Callback function that is fired when the slider's value changed. * * @param {number | number[]} value The new value. - * @param {number} activeThumb Index of the currently moved thumb. * @param {Event} event The event source of the callback. * You can pull out the new value by accessing `event.target.value` (any). - * **Warning**: This is a generic event not a change event. + * @param {number} activeThumbIndex Index of the currently moved thumb. */ - onValueChange?: (value: number | number[], activeThumb: number, event: Event) => void; + onValueChange?: (value: number | number[], event: Event, activeThumbIndex: number) => void; /** * Callback function that is fired when the `pointerup` is triggered. * diff --git a/packages/mui-base/src/Switch/Root/useSwitchRoot.ts b/packages/mui-base/src/Switch/Root/useSwitchRoot.ts index 8fbdf94fd4..78abbd1476 100644 --- a/packages/mui-base/src/Switch/Root/useSwitchRoot.ts +++ b/packages/mui-base/src/Switch/Root/useSwitchRoot.ts @@ -115,7 +115,7 @@ export function useSwitchRoot(params: useSwitchRoot.Parameters): useSwitchRoot.R setDirty(nextChecked !== validityData.initialValue); setCheckedState(nextChecked); - onCheckedChange?.(nextChecked, event); + onCheckedChange?.(nextChecked, event.nativeEvent); }, }), [ @@ -179,7 +179,7 @@ export namespace useSwitchRoot { * @param {boolean} checked The new checked state. * @param {React.ChangeEvent} event The event source of the callback. */ - onCheckedChange?: (checked: boolean, event: React.ChangeEvent) => void; + onCheckedChange?: (checked: boolean, event: Event) => void; /** * If `true`, the component is read-only. * Functionally, this is equivalent to being disabled, but the assistive technologies will announce this differently. diff --git a/packages/mui-base/src/Tabs/Root/TabsRoot.tsx b/packages/mui-base/src/Tabs/Root/TabsRoot.tsx index 7660a562d1..70942a0e3a 100644 --- a/packages/mui-base/src/Tabs/Root/TabsRoot.tsx +++ b/packages/mui-base/src/Tabs/Root/TabsRoot.tsx @@ -93,7 +93,7 @@ namespace TabsRoot { /** * Callback invoked when new value is being set. */ - onValueChange?: (value: any | null, event: React.SyntheticEvent | null) => void; + onValueChange?: (value: any | null, event?: Event) => void; } } diff --git a/packages/mui-base/src/Tabs/Root/TabsRootContext.ts b/packages/mui-base/src/Tabs/Root/TabsRootContext.ts index ac011c009d..5e1e5e30da 100644 --- a/packages/mui-base/src/Tabs/Root/TabsRootContext.ts +++ b/packages/mui-base/src/Tabs/Root/TabsRootContext.ts @@ -11,7 +11,7 @@ export interface TabsRootContext { * Callback for setting new value. */ onSelected: ( - event: React.SyntheticEvent | null, + event: Event | undefined, value: any | null, activationDirection: TabActivationDirection, ) => void; diff --git a/packages/mui-base/src/Tabs/Root/useTabsRoot.ts b/packages/mui-base/src/Tabs/Root/useTabsRoot.ts index 8c9e49354d..1a38f6e8f9 100644 --- a/packages/mui-base/src/Tabs/Root/useTabsRoot.ts +++ b/packages/mui-base/src/Tabs/Root/useTabsRoot.ts @@ -35,13 +35,13 @@ function useTabsRoot(parameters: useTabsRoot.Parameters): useTabsRoot.ReturnValu const onSelected = React.useCallback( ( - event: React.SyntheticEvent | null, + event: Event | undefined, newValue: any | null, activationDirection: TabActivationDirection, ) => { setValue(newValue); setTabActivationDirection(activationDirection); - onValueChange?.(newValue, event); + onValueChange?.(newValue, event ?? undefined); }, [onValueChange, setValue], ); @@ -117,7 +117,7 @@ namespace useTabsRoot { /** * Callback invoked when new value is being set. */ - onValueChange?: (value: any | null, event: React.SyntheticEvent | null) => void; + onValueChange?: (value: any | null, event?: Event) => void; } export interface ReturnValue { diff --git a/packages/mui-base/src/Tabs/TabsList/useTabsList.ts b/packages/mui-base/src/Tabs/TabsList/useTabsList.ts index 5ecbbbb81f..846b111d5a 100644 --- a/packages/mui-base/src/Tabs/TabsList/useTabsList.ts +++ b/packages/mui-base/src/Tabs/TabsList/useTabsList.ts @@ -77,7 +77,7 @@ function useTabsList(parameters: useTabsList.Parameters): useTabsList.ReturnValu ) => { const newSelectedValue = newValue[0] ?? null; const activationDirection = detectActivationDirection(newSelectedValue); - onSelected(event, newValue[0] ?? null, activationDirection); + onSelected(event?.nativeEvent, newValue[0] ?? null, activationDirection); }, [onSelected, detectActivationDirection], ); diff --git a/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts b/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts index bda68dfbef..604f626cd0 100644 --- a/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts +++ b/packages/mui-base/src/Tooltip/Root/useTooltipRoot.ts @@ -182,7 +182,7 @@ export namespace useTooltipRoot { /** * Callback fired when the tooltip popup is requested to be opened or closed. Use when controlled. */ - onOpenChange?: (isOpen: boolean, event?: Event, reason?: OpenChangeReason) => void; + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; /** * Whether the user can move their cursor from the trigger element toward the tooltip popup element * without it closing using a "safe polygon" technique.