From f23c6df769c29677fff8038e2e44bf804f085e62 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Tue, 29 Oct 2024 23:21:39 +0800 Subject: [PATCH] WIP toggle button group --- docs/src/app/experiments/toggle.module.css | 38 ++++++++ docs/src/app/experiments/toggle.tsx | 60 ++++++++++-- .../ToggleButton/Root/ToggleButtonRoot.tsx | 2 + .../ToggleButton/Root/useToggleButtonRoot.ts | 6 ++ .../Root/ToggleButtonGroupRoot.test.tsx | 15 +++ .../Root/ToggleButtonGroupRoot.tsx | 84 ++++++++++++++++ .../Root/ToggleButtonGroupRootContext.ts | 26 +++++ .../Root/useToggleButtonGroupRoot.ts | 96 +++++++++++++++++++ .../src/ToggleButtonGroup/index.parts.ts | 1 + .../mui-base/src/ToggleButtonGroup/index.ts | 1 + 10 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 docs/src/app/experiments/toggle.module.css create mode 100644 packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx create mode 100644 packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx create mode 100644 packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts create mode 100644 packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts create mode 100644 packages/mui-base/src/ToggleButtonGroup/index.parts.ts create mode 100644 packages/mui-base/src/ToggleButtonGroup/index.ts diff --git a/docs/src/app/experiments/toggle.module.css b/docs/src/app/experiments/toggle.module.css new file mode 100644 index 0000000000..fd7ecf3489 --- /dev/null +++ b/docs/src/app/experiments/toggle.module.css @@ -0,0 +1,38 @@ +.button { + --size: 2.5rem; + --corner: 0.4rem; + + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + height: var(--size); + width: var(--size); + border: 1px solid var(--gray-outline-2); + border-radius: var(--corner); + background-color: var(--gray-container-1); + color: var(--gray-text-1); +} + +.button:hover { + background-color: var(--gray-surface-1); + color: var(--gray-text-2); +} + +.button:focus-visible { + outline: 2px solid var(--gray-900); +} + +.icon { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.button[data-pressed] .icon { + color: var(--gray-text-2); + fill: currentColor; + /* stroke-width: 0;*/ +} diff --git a/docs/src/app/experiments/toggle.tsx b/docs/src/app/experiments/toggle.tsx index 08ff006ac4..8131eecefe 100644 --- a/docs/src/app/experiments/toggle.tsx +++ b/docs/src/app/experiments/toggle.tsx @@ -1,30 +1,72 @@ 'use client'; import * as React from 'react'; import { ToggleButton } from '@base_ui/react/ToggleButton'; +import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup'; +import classes from './toggle.module.css'; 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 5a8a37800e..5cbd4a6c3b 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; diff --git a/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts b/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts index e944cd0902..bf2abee1cd 100644 --- a/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts +++ b/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts @@ -16,6 +16,7 @@ export function useToggleButtonRoot( defaultPressed = false, disabled = false, buttonRef: externalRef, + value = undefined, } = parameters; const [pressed, setPressedState] = useControlled({ @@ -90,6 +91,11 @@ export namespace useToggleButtonRoot { * @param {Event} event The event source of the callback. */ onPressedChange?: (pressed: boolean, event: Event) => void; + /** + * A unique value that identifies the component when used + * inside a ToggleButtonGroup + */ + value?: unknown; // TODO: a prop to indicate `aria-pressed='mixed'` is supported } 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 0000000000..377a0657ae --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx @@ -0,0 +1,15 @@ +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'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); +}); 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 0000000000..f24951a05d --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx @@ -0,0 +1,84 @@ +'use client'; +import * as React from 'react'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { + useToggleButtonGroupRoot, + type UseToggleButtonGroupRoot, +} from './useToggleButtonGroupRoot'; +import { ToggleButtonGroupRootContext } from './ToggleButtonGroupRootContext'; + +const customStyleHookMapping = { + disabled: () => null, + multiple(value: boolean) { + if (value) { + return { 'data-multiple': '' } as Record; + } + return null; + }, +}; + +const ToggleButtonGroupRoot = React.forwardRef(function ToggleButtonGroupRoot( + props: ToggleButtonGroupRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + disabled: disabledProp, + toggleMultiple = false, + className, + render, + ...otherProps + } = props; + + const { getRootProps, disabled, setValue, value } = useToggleButtonGroupRoot({ + disabled: disabledProp, + toggleMultiple, + }); + + const ownerState: ToggleButtonGroupRoot.OwnerState = React.useMemo( + () => ({ disabled, multiple: toggleMultiple }), + [disabled, toggleMultiple], + ); + + const contextValue: ToggleButtonGroupRootContext = React.useMemo( + () => ({ + setValue, + value, + }), + [value, setValue], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ref: forwardedRef, + ownerState, + className, + customStyleHookMapping, + extraProps: otherProps, + }); + + return ( + + {renderElement()} + + ); +}); + +export { ToggleButtonGroupRoot }; + +export namespace ToggleButtonGroupRoot { + export interface OwnerState { + disabled: boolean; + multiple: boolean; + } + + export interface Props + extends Pick, + BaseUIComponentProps<'div', OwnerState> { + /** + * @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 0000000000..cb2d7728d5 --- /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[]; + setValue: (value: unknown[], event: Event) => void; +} + +export const ToggleButtonGroupRootContext = React.createContext< + ToggleButtonGroupRootContext | undefined +>(undefined); + +if (process.env.NODE_ENV !== 'production') { + ToggleButtonGroupRootContext.displayName = 'ToggleButtonGroupRootContext'; +} + +export function useToggleButtonGroupRootContext(optional = true) { + const context = React.useContext(ToggleButtonGroupRootContext); + if (context === undefined && !optional) { + 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 0000000000..8854c5c281 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts @@ -0,0 +1,96 @@ +'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: valueParam, + defaultValue, + onValueChange, + toggleMultiple = false, + } = parameters; + + const [groupValue, setValueState] = useControlled({ + controlled: valueParam, + default: defaultValue, + name: 'ToggleButtonGroup', + state: 'value', + }); + + const setValue = useEventCallback((toggledValue: unknown, event: Event) => { + let newGroupValue; + if (toggleMultiple) { + const toggledValueIndex = groupValue.indexOf(toggledValue); + newGroupValue = + toggledValueIndex === -1 + ? groupValue.push(toggledValue) + : groupValue.splice(toggledValueIndex, 1); + } else { + newGroupValue = groupValue.length === 0 ? [toggledValue] : []; + } + if (Array.isArray(newGroupValue)) { + setValueState(newGroupValue); + onValueChange?.(newGroupValue, event); + } + }); + + const getRootProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + role: 'group', + }), + [], + ); + + return React.useMemo( + () => ({ + getRootProps, + disabled, + setValue, + value: groupValue, + }), + [getRootProps, disabled, groupValue, setValue], + ); +} + +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; + /** + * + */ + setValue: (toggledValue: unknown[], event: Event) => void; + /** + * + */ + 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 0000000000..b0fc12c64f --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/index.parts.ts @@ -0,0 +1 @@ +export { ToggleButtonGroupRoot as Root } from './Root/ToggleButtonGroupRoot'; diff --git a/packages/mui-base/src/ToggleButtonGroup/index.ts b/packages/mui-base/src/ToggleButtonGroup/index.ts new file mode 100644 index 0000000000..4ac6a6b047 --- /dev/null +++ b/packages/mui-base/src/ToggleButtonGroup/index.ts @@ -0,0 +1 @@ +export { Root as ToggleButtonGroup } from './index.parts';