diff --git a/docs/data/api/toggle-button-group-item.json b/docs/data/api/toggle-button-group-item.json new file mode 100644 index 000000000..c51f3924c --- /dev/null +++ b/docs/data/api/toggle-button-group-item.json @@ -0,0 +1,22 @@ +{ + "props": { + "value": { "type": { "name": "any" }, "required": true }, + "aria-label": { "type": { "name": "string" } }, + "aria-labelledby": { "type": { "name": "string" } }, + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "ToggleButtonGroupItem", + "imports": [ + "import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';\nconst ToggleButtonGroupItem = ToggleButtonGroup.Item;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ToggleButtonGroupItem", + "forwardsRefTo": "HTMLButtonElement", + "filename": "/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/toggle-button-group-root.json b/docs/data/api/toggle-button-group-root.json new file mode 100644 index 000000000..a3b4e1779 --- /dev/null +++ b/docs/data/api/toggle-button-group-root.json @@ -0,0 +1,20 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } }, + "toggleMultiple": { "type": { "name": "bool" }, "default": "false" } + }, + "name": "ToggleButtonGroupRoot", + "imports": [ + "import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';\nconst ToggleButtonGroupRoot = ToggleButtonGroup.Root;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ToggleButtonGroupRoot", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/components/toggle-button-group/toggle-button-group.mdx b/docs/data/components/toggle-button-group/toggle-button-group.mdx new file mode 100644 index 000000000..4ac20f224 --- /dev/null +++ b/docs/data/components/toggle-button-group/toggle-button-group.mdx @@ -0,0 +1,19 @@ +--- +productId: base-ui +title: React ToggleButtonGroup component +description: ToggleButtonGroup provides a set of two-state buttons that can either be off (not pressed) or on (pressed). +components: ToggleButtonGroupRoot, ToggleButtonGroupItem +githubLabel: 'component: toggle button' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio-group/ +packageName: '@base_ui/react' +--- + +# ToggleButtonGroup + + + + + +## Installation + + diff --git a/docs/data/translations/api-docs/toggle-button-group-item/toggle-button-group-item.json b/docs/data/translations/api-docs/toggle-button-group-item/toggle-button-group-item.json new file mode 100644 index 000000000..06337967b --- /dev/null +++ b/docs/data/translations/api-docs/toggle-button-group-item/toggle-button-group-item.json @@ -0,0 +1,17 @@ +{ + "componentDescription": "", + "propDescriptions": { + "aria-label": { "description": "The label for the toggle button." }, + "aria-labelledby": { + "description": "An id or space-separated list of ids of elements that label the toggle button." + }, + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." }, + "value": { + "description": "A unique value that identifies the component when used inside a ToggleButtonGroup" + } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/toggle-button-group-root/toggle-button-group-root.json b/docs/data/translations/api-docs/toggle-button-group-root/toggle-button-group-root.json new file mode 100644 index 000000000..c94c564c7 --- /dev/null +++ b/docs/data/translations/api-docs/toggle-button-group-root/toggle-button-group-root.json @@ -0,0 +1,13 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." }, + "toggleMultiple": { + "description": "When false only one ToggleButton in the group can be pressed. When a ToggleButton is pressed, the others in the group will become unpressed" + } + }, + "classDescriptions": {} +} diff --git a/docs/src/app/experiments/toggle-group.tsx b/docs/src/app/experiments/toggle-group.tsx new file mode 100644 index 000000000..666736b7f --- /dev/null +++ b/docs/src/app/experiments/toggle-group.tsx @@ -0,0 +1,132 @@ +'use client'; +import * as React from 'react'; +import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup'; +import classes from './toggle.module.css'; + +export default function ToggleButtonGroupDemo() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/docs/src/app/experiments/toggle.module.css b/docs/src/app/experiments/toggle.module.css new file mode 100644 index 000000000..2f0795dea --- /dev/null +++ b/docs/src/app/experiments/toggle.module.css @@ -0,0 +1,61 @@ +.grid { + display: grid; + grid-gap: 2rem; +} + +.group { + display: flex; +} + +.button { + --size: 2.5rem; + --corner: 0.4rem; + --border-color: var(--gray-outline-2); + + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + height: var(--size); + width: var(--size); + border: 1px solid var(--border-color); + background-color: var(--gray-container-2); + color: var(--gray-400); +} + +.button:first-child { + border-radius: var(--corner) 0 0 var(--corner); + border-right-color: transparent; +} + +.button:last-child { + border-radius: 0 var(--corner) var(--corner) 0; + border-left-color: transparent; +} + +.button:hover { + background-color: var(--gray-surface-1); + outline: 1px solid var(--gray-500); + outline-offset: -1px; + color: var(--gray-text-2); + cursor: pointer; + z-index: 1; +} + +.button:focus-visible { + outline: 2px solid var(--gray-900); + z-index: 1; +} + +.icon { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.button[data-pressed] { + background-color: #fefefe; + color: var(--gray-text-2); +} diff --git a/docs/src/app/experiments/toggle.tsx b/docs/src/app/experiments/toggle.tsx deleted file mode 100644 index 08ff006ac..000000000 --- a/docs/src/app/experiments/toggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; -import * as React from 'react'; -import { ToggleButton } from '@base_ui/react/ToggleButton'; - -export default function ToggleButtonDemo() { - const [pressed, setPressed] = React.useState(true); - return ( -
- - - - - -
- ); -} diff --git a/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx b/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx index 5a8a37800..26b3638d5 100644 --- a/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx +++ b/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx @@ -30,6 +30,8 @@ const ToggleButtonRoot = React.forwardRef(function ToggleButtonRoot( onPressedChange, className, render, + type, + form, ...otherProps } = props; @@ -62,31 +64,6 @@ const ToggleButtonRoot = React.forwardRef(function ToggleButtonRoot( return renderElement(); }); -export { ToggleButtonRoot }; - -export namespace ToggleButtonRoot { - export interface OwnerState { - pressed: boolean; - disabled: boolean; - } - - export interface Props - extends Pick< - useToggleButtonRoot.Parameters, - 'pressed' | 'defaultPressed' | 'disabled' | 'onPressedChange' - >, - BaseUIComponentProps<'button', OwnerState> { - /** - * The label for the ToggleButton. - */ - 'aria-label'?: React.AriaAttributes['aria-label']; - /** - * An id or space-separated list of ids of elements that label the ToggleButton. - */ - 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; - } -} - ToggleButtonRoot.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -120,6 +97,10 @@ ToggleButtonRoot.propTypes /* remove-proptypes */ = { * @default false */ disabled: PropTypes.bool, + /** + * @ignore + */ + form: PropTypes.string, /** * Callback fired when the pressed state is changed. * @@ -137,4 +118,33 @@ ToggleButtonRoot.propTypes /* remove-proptypes */ = { * A function to customize rendering of the component. */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + type: PropTypes.oneOf(['button', 'reset', 'submit']), } as any; + +export { ToggleButtonRoot }; + +export namespace ToggleButtonRoot { + export interface OwnerState { + pressed: boolean; + disabled: boolean; + } + + export interface Props + extends Pick< + useToggleButtonRoot.Parameters, + 'pressed' | 'defaultPressed' | 'disabled' | 'onPressedChange' + >, + BaseUIComponentProps<'button', OwnerState> { + /** + * The label for the ToggleButton. + */ + 'aria-label'?: React.AriaAttributes['aria-label']; + /** + * An id or space-separated list of ids of elements that label the ToggleButton. + */ + 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; + } +} diff --git a/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.test.tsx b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.test.tsx new file mode 100644 index 000000000..963d379bc --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.test.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +// import { expect } from 'chai'; +// import { spy } from 'sinon'; +// import { act } from '@mui/internal-test-utils'; +import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup'; +import { createRenderer, describeConformance } from '#test-utils'; +import { NOOP } from '../../utils/noop'; +import { ToggleButtonGroupRootContext } from '../Root/ToggleButtonGroupRootContext'; + +const contextValue: ToggleButtonGroupRootContext = { + value: [], + setGroupValue: NOOP, +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLButtonElement, + render: (node) => + render( + + {node} + , + ), + })); +}); diff --git a/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx new file mode 100644 index 000000000..cb834dc25 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx @@ -0,0 +1,131 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useToggleButtonGroupRootContext } from '../Root/ToggleButtonGroupRootContext'; +import { useToggleButtonGroupItem } from './useToggleButtonGroupItem'; + +const customStyleHookMapping = { + disabled: () => null, +}; +/** + * + * Demos: + * + * - [ToggleButtonGroup](https://base-ui.netlify.app/components/react-toggle-button-group/) + * + * API: + * + * - [ToggleButtonGroupItem API](https://base-ui.netlify.app/components/react-toggle-button-group/#api-reference-ToggleButtonGroupItem) + */ +const ToggleButtonGroupItem = React.forwardRef(function ToggleButtonGroupItem( + props: ToggleButtonGroupItem.Props, + forwardedRef: React.ForwardedRef, +) { + const { + value, + disabled: disabledProp, + className, + render, + type, // cannot change button type + form, // never participates in form validation + ...otherProps + } = props; + + const { value: groupValue, setGroupValue } = useToggleButtonGroupRootContext(); + + const { disabled, pressed, getRootProps } = useToggleButtonGroupItem({ + value, + groupValue, + setGroupValue, + disabled: disabledProp, + itemRef: forwardedRef, + }); + + const ownerState: ToggleButtonGroupItem.OwnerState = React.useMemo( + () => ({ + disabled, + pressed, + }), + [disabled, pressed], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'button', + ref: forwardedRef, + ownerState, + className, + customStyleHookMapping, + extraProps: otherProps, + }); + + return renderElement(); +}); + +export { ToggleButtonGroupItem }; + +export namespace ToggleButtonGroupItem { + export interface OwnerState { + pressed: boolean; + disabled: boolean; + } + + export interface Props + extends Pick, + Omit, 'value'> { + /** + * The label for the toggle button. + */ + 'aria-label'?: React.AriaAttributes['aria-label']; + /** + * An id or space-separated list of ids of elements that label the toggle button. + */ + 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; + } +} + +ToggleButtonGroupItem.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The label for the toggle button. + */ + 'aria-label': PropTypes.string, + /** + * An id or space-separated list of ids of elements that label the toggle button. + */ + 'aria-labelledby': PropTypes.string, + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * @ignore + */ + disabled: PropTypes.bool, + /** + * @ignore + */ + form: PropTypes.string, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + type: PropTypes.oneOf(['button', 'reset', 'submit']), + /** + * A unique value that identifies the component when used + * inside a ToggleButtonGroup + */ + value: PropTypes.any.isRequired, +} as any; diff --git a/packages/mui-base/src/ToggleButtonGroup/Item/useToggleButtonGroupItem.ts b/packages/mui-base/src/ToggleButtonGroup/Item/useToggleButtonGroupItem.ts new file mode 100644 index 000000000..46c6413d3 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Item/useToggleButtonGroupItem.ts @@ -0,0 +1,73 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { GenericHTMLProps } from '../../utils/types'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useToggleButtonRoot } from '../../ToggleButton/Root/useToggleButtonRoot'; + +export function useToggleButtonGroupItem( + parameters: useToggleButtonGroupItem.Parameters, +): useToggleButtonGroupItem.ReturnValue { + const { + value, + groupValue, + setGroupValue, + disabled: disabledParam, + itemRef: externalRef, + } = parameters; + + const isPressed = groupValue?.indexOf(value) > -1; + + const handlePressedChange = useEventCallback((nextPressed: boolean, event: Event) => { + setGroupValue(value, nextPressed, event); + }); + + const { + pressed, + disabled, + getRootProps: getToggleButtonProps, + } = useToggleButtonRoot({ + pressed: isPressed, + disabled: disabledParam, + onPressedChange: handlePressedChange, + buttonRef: externalRef, + }); + + const getRootProps = React.useCallback( + (externalProps?: GenericHTMLProps): GenericHTMLProps => { + return mergeReactProps(externalProps, {}, getToggleButtonProps()); + }, + [getToggleButtonProps], + ); + + return React.useMemo( + () => ({ + getRootProps, + disabled, + pressed, + }), + [getRootProps, disabled, pressed], + ); +} + +export namespace useToggleButtonGroupItem { + export interface Parameters extends Pick { + itemRef?: React.Ref; + /** + * A unique value that identifies the component when used + * inside a ToggleButtonGroup + */ + value: unknown; + /** + * The value of the ToggleButtonGroup represented by an array of values + * of the items that are pressed + */ + groupValue: unknown[]; + /** + * + */ + setGroupValue: (newValue: unknown, nextPressed: boolean, event: Event) => void; + } + + export interface ReturnValue extends useToggleButtonRoot.ReturnValue {} +} diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx new file mode 100644 index 000000000..82270a18a --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { expect } from 'chai'; +// import { spy } from 'sinon'; +import { flushMicrotasks } from '@mui/internal-test-utils'; +import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); + + it('renders a `group`', async () => { + const { queryByRole } = await render(); + + expect(queryByRole('group', { name: 'My Toggle Group' })).not.to.equal(null); + }); + + describe('uncontrolled', () => { + it('pressed state', async function test(t = {}) { + if (/jsdom/.test(window.navigator.userAgent)) { + // @ts-expect-error to support mocha and vitest + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this?.skip?.() || t?.skip(); + } + + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button1).to.have.attribute('aria-pressed', 'false'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + + await user.pointer({ keys: '[MouseLeft]', target: button1 }); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button1).to.have.attribute('data-pressed'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + + await user.pointer({ keys: '[MouseLeft]', target: button2 }); + + expect(button2).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('data-pressed'); + expect(button1).to.have.attribute('aria-pressed', 'false'); + }); + + it('prop: defaultValue', async () => { + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button2).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('data-pressed'); + expect(button1).to.have.attribute('aria-pressed', 'false'); + + await user.pointer({ keys: '[MouseLeft]', target: button1 }); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button1).to.have.attribute('data-pressed'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + }); + }); + + describe('controlled', () => { + it('pressed state', async () => { + const { getAllByRole, setProps } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button1).to.have.attribute('aria-pressed', 'false'); + expect(button2).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('data-pressed'); + + setProps({ value: ['one'] }); + await flushMicrotasks(); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button1).to.have.attribute('data-pressed'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + + setProps({ value: ['two'] }); + await flushMicrotasks(); + + expect(button2).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('data-pressed'); + expect(button1).to.have.attribute('aria-pressed', 'false'); + }); + + it('prop: value', async () => { + const { getAllByRole, setProps } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button2).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('data-pressed'); + expect(button1).to.have.attribute('aria-pressed', 'false'); + + setProps({ value: ['one'] }); + await flushMicrotasks(); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button1).to.have.attribute('data-pressed'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + }); + }); +}); diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx new file mode 100644 index 000000000..c0ed06c74 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx @@ -0,0 +1,145 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { + useToggleButtonGroupRoot, + type UseToggleButtonGroupRoot, +} from './useToggleButtonGroupRoot'; +import { ToggleButtonGroupRootContext } from './ToggleButtonGroupRootContext'; + +const customStyleHookMapping = { + multiple(value: boolean) { + if (value) { + return { 'data-multiple': '' } as Record; + } + return null; + }, +}; +/** + * + * Demos: + * + * - [ToggleButtonGroup](https://base-ui.netlify.app/components/react-toggle-button-group/) + * + * API: + * + * - [ToggleButtonGroupRoot API](https://base-ui.netlify.app/components/react-toggle-button-group/#api-reference-ToggleButtonGroupRoot) + */ +const ToggleButtonGroupRoot = React.forwardRef(function ToggleButtonGroupRoot( + props: ToggleButtonGroupRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + defaultValue: defaultValueProp, + value: valueProp, + disabled: disabledProp, + toggleMultiple = false, + className, + render, + ...otherProps + } = props; + + const defaultValue = React.useMemo(() => { + if (valueProp === undefined) { + return defaultValueProp ?? []; + } + + return undefined; + }, [valueProp, defaultValueProp]); + + const { getRootProps, disabled, setGroupValue, value } = useToggleButtonGroupRoot({ + value: valueProp, + defaultValue, + disabled: disabledProp, + toggleMultiple, + }); + + const ownerState: ToggleButtonGroupRoot.OwnerState = React.useMemo( + () => ({ disabled, multiple: toggleMultiple }), + [disabled, toggleMultiple], + ); + + const contextValue: ToggleButtonGroupRootContext = React.useMemo( + () => ({ + setGroupValue, + value, + }), + [value, setGroupValue], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ref: forwardedRef, + ownerState, + className, + customStyleHookMapping, + extraProps: otherProps, + }); + + return ( + + {renderElement()} + + ); +}); + +ToggleButtonGroupRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * @ignore + */ + defaultValue: PropTypes.array, + /** + * @default false + */ + disabled: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * When `false` only one ToggleButton in the group can be pressed. + * When a ToggleButton is pressed, the others in the group will become unpressed + * @default false + */ + toggleMultiple: PropTypes.bool, + /** + * @ignore + */ + value: PropTypes.array, +} as any; + +export { ToggleButtonGroupRoot }; + +export namespace ToggleButtonGroupRoot { + export interface OwnerState { + disabled: boolean; + multiple: boolean; + } + + export interface Props + extends Pick< + UseToggleButtonGroupRoot.Parameters, + 'value' | 'defaultValue' | 'onValueChange' | 'disabled' | 'toggleMultiple' + >, + Omit, 'defaultValue'> { + /** + * @default false + */ + disabled?: boolean; + } +} diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts new file mode 100644 index 000000000..203a5e0fb --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts @@ -0,0 +1,26 @@ +'use client'; +import * as React from 'react'; + +export interface ToggleButtonGroupRootContext { + value: unknown[]; + setGroupValue: (newValue: unknown, nextPressed: boolean, event: Event) => void; +} + +export const ToggleButtonGroupRootContext = React.createContext< + ToggleButtonGroupRootContext | undefined +>(undefined); + +if (process.env.NODE_ENV !== 'production') { + ToggleButtonGroupRootContext.displayName = 'ToggleButtonGroupRootContext'; +} + +export function useToggleButtonGroupRootContext() { + const context = React.useContext(ToggleButtonGroupRootContext); + if (context === undefined) { + throw new Error( + 'Base UI: ToggleButtonGroupRootContext is missing. ToggleButtonGroup parts must be placed within .', + ); + } + + return context; +} diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts b/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts new file mode 100644 index 000000000..903bcea6b --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts @@ -0,0 +1,100 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useControlled } from '../../utils/useControlled'; +import { useEventCallback } from '../../utils/useEventCallback'; +import type { GenericHTMLProps } from '../../utils/types'; + +export function useToggleButtonGroupRoot( + parameters: UseToggleButtonGroupRoot.Parameters, +): UseToggleButtonGroupRoot.ReturnValue { + const { + disabled = false, + value, + defaultValue, + onValueChange, + toggleMultiple = false, + } = parameters; + + const [groupValue, setValueState] = useControlled({ + controlled: value, + default: defaultValue, + name: 'ToggleButtonGroup', + state: 'value', + }); + + const setGroupValue = useEventCallback( + (newValue: unknown, nextPressed: boolean, event: Event) => { + let newGroupValue: unknown[] | undefined; + if (toggleMultiple) { + newGroupValue = groupValue.slice(); + if (nextPressed) { + newGroupValue.push(newValue); + } else { + newGroupValue.splice(groupValue.indexOf(newValue), 1); + } + } else { + newGroupValue = nextPressed ? [newValue] : []; + } + if (Array.isArray(newGroupValue)) { + setValueState(newGroupValue); + onValueChange?.(newGroupValue, event); + } + }, + ); + + const getRootProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + role: 'group', + }), + [], + ); + + return React.useMemo( + () => ({ + getRootProps, + disabled, + setGroupValue, + value: groupValue, + }), + [getRootProps, disabled, groupValue, setGroupValue], + ); +} + +export namespace UseToggleButtonGroupRoot { + export interface Parameters { + value?: unknown[]; + defaultValue?: unknown[]; + onValueChange?: (groupValue: unknown[], event: Event) => void; + /** + * When `true` the component is disabled + * @false + */ + disabled?: boolean; + /** + * When `false` only one ToggleButton in the group can be pressed. + * When a ToggleButton is pressed, the others in the group will become unpressed + * @default false + */ + toggleMultiple?: boolean; + } + + export interface ReturnValue { + getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * When `true` the component is disabled + * @false + */ + disabled: boolean; + /** + * + */ + setGroupValue: (newValue: unknown, nextPressed: boolean, event: Event) => void; + /** + * The value of the ToggleButtonGroup represented by an array of values + * of the items that are pressed + */ + value: unknown[]; + } +} diff --git a/packages/mui-base/src/ToggleButtonGroup/index.parts.ts b/packages/mui-base/src/ToggleButtonGroup/index.parts.ts new file mode 100644 index 000000000..5c0e559a3 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/index.parts.ts @@ -0,0 +1,2 @@ +export { ToggleButtonGroupRoot as Root } from './Root/ToggleButtonGroupRoot'; +export { ToggleButtonGroupItem as Item } from './Item/ToggleButtonGroupItem'; diff --git a/packages/mui-base/src/ToggleButtonGroup/index.ts b/packages/mui-base/src/ToggleButtonGroup/index.ts new file mode 100644 index 000000000..6466ce449 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/index.ts @@ -0,0 +1 @@ +export * as ToggleButtonGroup from './index.parts'; diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts index bb0c9c1ff..770f1d944 100644 --- a/packages/mui-base/src/index.ts +++ b/packages/mui-base/src/index.ts @@ -19,4 +19,5 @@ export * from './Switch'; export * from './Tabs'; export * from './TextInput'; export * from './ToggleButton'; +export * from './ToggleButtonGroup'; export * from './Tooltip';