diff --git a/docs/data/api/toggle-group-root.json b/docs/data/api/toggle-group-root.json new file mode 100644 index 0000000000..afefa1e06c --- /dev/null +++ b/docs/data/api/toggle-group-root.json @@ -0,0 +1,29 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func| string" } }, + "defaultValue": { "type": { "name": "arrayOf", "description": "Array<string>" } }, + "onValueChange": { + "type": { "name": "func" }, + "signature": { + "type": "function(groupValue: Array, event: Event) => void", + "describedArgs": ["groupValue", "event"] + } + }, + "render": { "type": { "name": "union", "description": "element| func" } }, + "toggleMultiple": { "type": { "name": "bool" }, "default": "false" }, + "value": { "type": { "name": "arrayOf", "description": "Array<string>" } } + }, + "name": "ToggleGroupRoot", + "imports": [ + "import { ToggleGroup } from '@base-ui-components/react/toggle-group';\nconst ToggleGroupRoot = ToggleGroup.Root;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ToggleGroupRoot", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/react/src/toggle-group/root/ToggleGroupRoot.tsx", + "inheritance": null, + "demos": "ToggleGroup", + "cssComponent": false +} diff --git a/docs/data/api/toggle-root.json b/docs/data/api/toggle-root.json new file mode 100644 index 0000000000..21a8f85ef2 --- /dev/null +++ b/docs/data/api/toggle-root.json @@ -0,0 +1,32 @@ +{ + "props": { + "aria-label": { "type": { "name": "string" } }, + "aria-labelledby": { "type": { "name": "string" } }, + "className": { "type": { "name": "union", "description": "func| string" } }, + "defaultPressed": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "onPressedChange": { + "type": { "name": "func" }, + "default": "NOOP", + "signature": { + "type": "function(pressed: boolean, event: Event) => void", + "describedArgs": ["pressed", "event"] + } + }, + "pressed": { "type": { "name": "bool" }, "default": "undefined" }, + "render": { "type": { "name": "union", "description": "element| func" } } + }, + "name": "ToggleRoot", + "imports": [ + "import { Toggle } from '@base-ui-components/react/toggle';\nconst ToggleRoot = Toggle.Root;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "ToggleRoot", + "forwardsRefTo": "HTMLButtonElement", + "filename": "/packages/react/src/toggle/root/ToggleRoot.tsx", + "inheritance": null, + "demos": "Toggle", + "cssComponent": false +} diff --git a/docs/data/components/toggle-group/ToggleGroupIntroduction.js b/docs/data/components/toggle-group/ToggleGroupIntroduction.js new file mode 100644 index 0000000000..1f22e0675c --- /dev/null +++ b/docs/data/components/toggle-group/ToggleGroupIntroduction.js @@ -0,0 +1,74 @@ +'use client'; +import * as React from 'react'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './styles.module.css'; + +export default function ToggleGroupIntroduction() { + const [value, setValue] = React.useState(['align-center']); + return ( + { + if (newValue.length > 0) { + setValue(newValue); + } + }} + aria-label="Text alignment" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/components/toggle-group/ToggleGroupIntroduction.tsx b/docs/data/components/toggle-group/ToggleGroupIntroduction.tsx new file mode 100644 index 0000000000..1f22e0675c --- /dev/null +++ b/docs/data/components/toggle-group/ToggleGroupIntroduction.tsx @@ -0,0 +1,74 @@ +'use client'; +import * as React from 'react'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './styles.module.css'; + +export default function ToggleGroupIntroduction() { + const [value, setValue] = React.useState(['align-center']); + return ( + { + if (newValue.length > 0) { + setValue(newValue); + } + }} + aria-label="Text alignment" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/components/toggle-group/ToggleGroupToggleMultiple.js b/docs/data/components/toggle-group/ToggleGroupToggleMultiple.js new file mode 100644 index 0000000000..703e565e84 --- /dev/null +++ b/docs/data/components/toggle-group/ToggleGroupToggleMultiple.js @@ -0,0 +1,59 @@ +'use client'; +import * as React from 'react'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './styles.module.css'; + +export default function ToggleGroupToggleMultiple() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/components/toggle-group/ToggleGroupToggleMultiple.tsx b/docs/data/components/toggle-group/ToggleGroupToggleMultiple.tsx new file mode 100644 index 0000000000..703e565e84 --- /dev/null +++ b/docs/data/components/toggle-group/ToggleGroupToggleMultiple.tsx @@ -0,0 +1,59 @@ +'use client'; +import * as React from 'react'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './styles.module.css'; + +export default function ToggleGroupToggleMultiple() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/components/toggle-group/styles.module.css b/docs/data/components/toggle-group/styles.module.css new file mode 100644 index 0000000000..d2d07bcd2d --- /dev/null +++ b/docs/data/components/toggle-group/styles.module.css @@ -0,0 +1,66 @@ +.root { + display: flex; +} + +.button { + --size: 2.5rem; + --corner-radius: 0.4rem; + --border-color: var(--gray-outline-2); + --border-radius-right: 0 var(--corner-radius) var(--corner-radius) 0; + --border-radius-left: var(--corner-radius) 0 0 var(--corner-radius); + + 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(--border-radius-left); + border-inline-end-color: transparent; +} + +.button:last-child { + border-radius: var(--border-radius-right); + border-inline-start-color: transparent; +} + +.button:first-child:dir(rtl) { + border-radius: var(--border-radius-right); +} + +.button:last-child:dir(rtl) { + border-radius: var(--border-radius-left); +} + +.button:not(:disabled):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(--color-violet); + 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/data/components/toggle-group/toggle-group.mdx b/docs/data/components/toggle-group/toggle-group.mdx new file mode 100644 index 0000000000..18abca9987 --- /dev/null +++ b/docs/data/components/toggle-group/toggle-group.mdx @@ -0,0 +1,120 @@ +--- +productId: base-ui +title: React ToggleGroup component +description: ToggleGroup provides a set of two-state buttons that can either be off (not pressed) or on (pressed). +components: ToggleGroupRoot +githubLabel: 'component: toggle button' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio-group/ +packageName: '@base-ui-components/react' +--- + +# ToggleGroup + + + + + + + +## Installation + + + +## Anatomy + +ToggleGroups are composed with two components: + +- `` is the outer component that wraps a set of [`toggles`](/components/react-toggle) +- ``s renders the `` + +```tsx + + Bold + Italics + Underline + +``` + +## Value + +Each `Item` in the group must be given a unique value, e.g. a string like `'bold'`, `'italics'` in the examples above. + +### Uncontrolled + +When uncontrolled, use the `defaultValue` prop to set the initial state of the group: + +```tsx +// only the "Italics" button is initially pressed + + Bold + Italics + Underline + +``` + +### Controlled + +When controlled, pass the `value` and `onValueChange` props to `ToggleGroup`: + +```tsx +const [value, setValue] = React.useState(['align-center']); + +return ( + + + + + +); +``` + +## Customization + +### Orientation + +Use the `orientation` prop to configure a vertical toggle group: + +```tsx +{/* Toggles */} +``` + +### Allow multiple buttons to be pressed + +Use the `toggleMultiple` prop to allow multiple buttons to be pressed at the same time: + +```tsx + + + + + +``` + + + +### One button must be pressed + +Use controlled mode to always keep one button in the pressed state: + +```tsx +const [value, setValue] = React.useState(['align-left']); + +const handleValueChange = (newValue) => { + if (newValue.length > 0) { + setValue(newValue); + } +}; + +return ( + + + + + +); +``` + +## Accessibility + +- The `ToggleGroup` component, and every `Toggle` within must be given must be given an accessible name with `aria-label` or `aria-labelledby`. +- The `Toggle`'s label or accessible name _must not_ change based on the pressed state to ensure a smooth screen-reader experience, otherwise it may duplicate the announcement of the pressed state based on `aria-pressed`. diff --git a/docs/data/components/toggle/ToggleIntroduction.js b/docs/data/components/toggle/ToggleIntroduction.js new file mode 100644 index 0000000000..ee472c7999 --- /dev/null +++ b/docs/data/components/toggle/ToggleIntroduction.js @@ -0,0 +1,26 @@ +'use client'; +import * as React from 'react'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './styles.module.css'; + +export default function ToggleIntroduction() { + const [pressed, setPressed] = React.useState(false); + return ( + + + + + + ); +} diff --git a/docs/data/components/toggle/ToggleIntroduction.tsx b/docs/data/components/toggle/ToggleIntroduction.tsx new file mode 100644 index 0000000000..ee472c7999 --- /dev/null +++ b/docs/data/components/toggle/ToggleIntroduction.tsx @@ -0,0 +1,26 @@ +'use client'; +import * as React from 'react'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './styles.module.css'; + +export default function ToggleIntroduction() { + const [pressed, setPressed] = React.useState(false); + return ( + + + + + + ); +} diff --git a/docs/data/components/toggle/ToggleIntroduction.tsx.preview b/docs/data/components/toggle/ToggleIntroduction.tsx.preview new file mode 100644 index 0000000000..5534e14059 --- /dev/null +++ b/docs/data/components/toggle/ToggleIntroduction.tsx.preview @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/docs/data/components/toggle/styles.module.css b/docs/data/components/toggle/styles.module.css new file mode 100644 index 0000000000..1c18fed1ca --- /dev/null +++ b/docs/data/components/toggle/styles.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(--color-violet); +} + +.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/data/components/toggle/toggle.mdx b/docs/data/components/toggle/toggle.mdx new file mode 100644 index 0000000000..72e54fffca --- /dev/null +++ b/docs/data/components/toggle/toggle.mdx @@ -0,0 +1,75 @@ +--- +productId: base-ui +title: React Toggle component +description: Toggles are a two-state button that can either be off (not pressed) or on (pressed). +components: ToggleRoot +githubLabel: 'component: toggle button' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/button/ +packageName: '@base-ui-components/react' +--- + +# Toggle + + + + + + + +## Installation + + + +## Anatomy + +Toggle is a single component that renders a ``: + +```tsx +Toggle bookmark +``` + +## Controlled + +Use the `pressed` and `onPressedChange` props to control the pressed state: + +```jsx +const [pressed, setPressed] = React.useState(false); + + + {/* children */} +; +``` + +## Uncontrolled + +Use the `defaultPressed` prop to set the initial pressed state of the component when uncontrolled: + +```jsx +{/* children */} +``` + +## Accessibility + +Toggles must be given an accessible name with `aria-label` or `aria-labelledby`. It is important that the label does not change based on the pressed state to ensure a smooth screen-reader experience, since announcing the pressed state is already handled by `aria-pressed`. + +## Styling + +Use the `[data-pressed]` attribute to apply styles based on the pressed state: + +```jsx +{/* children */} +``` + +```css +/* base state, not pressed */ +.MyToggle { + background: white; + color: black; +} + +/* pressed state */ +.MyToggle[data-pressed] { + background: black; + color: white; +} +``` diff --git a/docs/data/pages.ts b/docs/data/pages.ts index b0efde5018..e43aa3e036 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -44,6 +44,8 @@ const pages: readonly RouteMetadata[] = [ { pathname: '/components/react-switch', title: 'Switch' }, { pathname: '/components/react-tabs', title: 'Tabs' }, { pathname: '/components/react-text-input', title: 'Text Input' }, + { pathname: '/components/react-toggle', title: 'Toggle' }, + { pathname: '/components/react-toggle-group', title: 'Toggle Group' }, { pathname: '/components/react-tooltip', title: 'Tooltip' }, ], }, diff --git a/docs/data/translations/api-docs/toggle-group-root/toggle-group-root.json b/docs/data/translations/api-docs/toggle-group-root/toggle-group-root.json new file mode 100644 index 0000000000..39d874a98b --- /dev/null +++ b/docs/data/translations/api-docs/toggle-group-root/toggle-group-root.json @@ -0,0 +1,26 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "defaultValue": { + "description": "The open state of the ToggleGroup represented by an array of the values of all pressed <ToggleGroup.Item/>s This is the uncontrolled counterpart of value." + }, + "onValueChange": { + "description": "Callback fired when the pressed states of the ToggleGroup changes.", + "typeDescriptions": { + "groupValue": "An array of the values of all the pressed items.", + "event": "The event source of the callback." + } + }, + "render": { "description": "A function to customize rendering of the component." }, + "toggleMultiple": { + "description": "When false only one item in the group can be pressed. If any item in the group becomes pressed, the others will become unpressed. When true multiple items can be pressed." + }, + "value": { + "description": "The open state of the ToggleGroup represented by an array of the values of all pressed <ToggleGroup.Item/>s This is the controlled counterpart of defaultValue." + } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/toggle-root/toggle-root.json b/docs/data/translations/api-docs/toggle-root/toggle-root.json new file mode 100644 index 0000000000..eb991b2783 --- /dev/null +++ b/docs/data/translations/api-docs/toggle-root/toggle-root.json @@ -0,0 +1,26 @@ +{ + "componentDescription": "", + "propDescriptions": { + "aria-label": { "description": "The label for the Toggle." }, + "aria-labelledby": { + "description": "An id or space-separated list of ids of elements that label the Toggle." + }, + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "defaultPressed": { + "description": "The default pressed state. Use when the component is not controlled." + }, + "disabled": { "description": "If true, the component is disabled." }, + "onPressedChange": { + "description": "Callback fired when the pressed state is changed.", + "typeDescriptions": { + "pressed": "The new pressed state.", + "event": "The event source of the callback." + } + }, + "pressed": { "description": "If true, the component is pressed." }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/reference/generated/toggle-group-root.json b/docs/reference/generated/toggle-group-root.json new file mode 100644 index 0000000000..740a3eb80d --- /dev/null +++ b/docs/reference/generated/toggle-group-root.json @@ -0,0 +1,31 @@ +{ + "name": "ToggleGroupRoot", + "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." + }, + "defaultValue": { + "type": "string[]", + "description": "The open state of the ToggleGroup represented by an array of\nthe values of all pressed ``s\nThis is the uncontrolled counterpart of `value`." + }, + "onValueChange": { + "type": "function(groupValue: Array, event: Event) => void", + "description": "Callback fired when the pressed states of the ToggleGroup changes." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + }, + "toggleMultiple": { + "type": "boolean", + "default": "false", + "description": "When `false` only one item in the group can be pressed. If any item in\nthe group becomes pressed, the others will become unpressed.\nWhen `true` multiple items can be pressed." + }, + "value": { + "type": "string[]", + "description": "The open state of the ToggleGroup represented by an array of\nthe values of all pressed ``s\nThis is the controlled counterpart of `defaultValue`." + } + } +} diff --git a/docs/reference/generated/toggle-root.json b/docs/reference/generated/toggle-root.json new file mode 100644 index 0000000000..c006a207d6 --- /dev/null +++ b/docs/reference/generated/toggle-root.json @@ -0,0 +1,42 @@ +{ + "name": "ToggleRoot", + "description": "", + "props": { + "aria-label": { + "type": "string", + "description": "The label for the Toggle." + }, + "aria-labelledby": { + "type": "string", + "description": "An id or space-separated list of ids of elements that label the Toggle." + }, + "className": { + "type": "string | (state) => string", + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "defaultPressed": { + "type": "boolean", + "default": "false", + "description": "The default pressed state. Use when the component is not controlled." + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "If `true`, the component is disabled." + }, + "onPressedChange": { + "type": "function(pressed: boolean, event: Event) => void", + "default": "NOOP", + "description": "Callback fired when the pressed state is changed." + }, + "pressed": { + "type": "boolean", + "default": "undefined", + "description": "If `true`, the component is pressed." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "A function to customize rendering of the component." + } + } +} diff --git a/docs/src/app/experiments/toggle-group.tsx b/docs/src/app/experiments/toggle-group.tsx new file mode 100644 index 0000000000..cbdbeafc9f --- /dev/null +++ b/docs/src/app/experiments/toggle-group.tsx @@ -0,0 +1,179 @@ +'use client'; +import * as React from 'react'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Toggle } from '@base-ui-components/react/toggle'; +import classes from './toggle.module.css'; + +export default function ToggleGroupDemo() { + const [value, setValue] = React.useState(['align-center']); + return ( + + LTR + + { + // one button must always remain pressed + if (newValue.length > 0) { + setValue(newValue); + } + }} + aria-label="Text alignment" + className={classes.group} + > + + + + + + + + + + + + + + RTL + + + + + + + + + + + + + + + + + + + + + + ); +} + +function AlignLeftIcon() { + return ( + + + + + + + ); +} + +function AlignCenterIcon() { + return ( + + + + + + + ); +} + +function AlignRightIcon() { + return ( + + + + + + + ); +} + +function BoldIcon() { + return ( + + + + + ); +} + +function ItalicsIcon() { + return ( + + + + + + ); +} + +function UnderlineIcon() { + return ( + + + + + ); +} diff --git a/docs/src/app/experiments/toggle.module.css b/docs/src/app/experiments/toggle.module.css new file mode 100644 index 0000000000..571033284b --- /dev/null +++ b/docs/src/app/experiments/toggle.module.css @@ -0,0 +1,75 @@ +.grid { + display: grid; + grid-gap: 2rem; +} + +.group { + display: flex; +} + +.button { + --size: 2.5rem; + --corner-radius: 0.4rem; + --border-color: var(--gray-outline-2); + --border-radius-right: 0 var(--corner-radius) var(--corner-radius) 0; + --border-radius-left: var(--corner-radius) 0 0 var(--corner-radius); + + 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:disabled { + cursor: not-allowed; +} + +.button:first-child { + border-radius: var(--border-radius-left); + border-inline-end-color: transparent; +} + +.button:last-child { + border-radius: var(--border-radius-right); + border-inline-start-color: transparent; +} + +.button:first-child:dir(rtl) { + border-radius: var(--border-radius-right); +} + +.button:last-child:dir(rtl) { + border-radius: var(--border-radius-left); +} + +.button:not(:disabled):hover { + background-color: var(--gray-surface-1); + outline: 1px solid #444; + outline-offset: -1px; + color: var(--gray-text-2); + cursor: pointer; + z-index: 1; +} + +.button:focus-visible { + outline: 2px solid black; + z-index: 1; +} + +.button svg { + 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/packages/react/src/composite/root/useCompositeRoot.ts b/packages/react/src/composite/root/useCompositeRoot.ts index e2ca640c81..cc79b0cac7 100644 --- a/packages/react/src/composite/root/useCompositeRoot.ts +++ b/packages/react/src/composite/root/useCompositeRoot.ts @@ -105,10 +105,10 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { return; } - const isRtl = - textDirectionRef?.current == null - ? getTextDirection(element) === 'rtl' - : textDirectionRef.current === 'rtl'; + if (textDirectionRef?.current == null) { + textDirectionRef.current = getTextDirection(element); + } + const isRtl = textDirectionRef.current === 'rtl'; let nextIndex = highlightedIndex; const minIndex = getMinIndex(elementsRef, disabledIndices); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 857bf20b0d..da5ee8ca62 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -21,4 +21,6 @@ export * from './slider'; export * from './switch'; export * from './tabs'; export * from './text-input'; +export * from './toggle'; +export * from './toggle-group'; export * from './tooltip'; diff --git a/packages/react/src/toggle-group/index.parts.ts b/packages/react/src/toggle-group/index.parts.ts new file mode 100644 index 0000000000..51fe853ee7 --- /dev/null +++ b/packages/react/src/toggle-group/index.parts.ts @@ -0,0 +1 @@ +export { ToggleGroupRoot as Root } from './root/ToggleGroupRoot'; diff --git a/packages/react/src/toggle-group/index.ts b/packages/react/src/toggle-group/index.ts new file mode 100644 index 0000000000..c612e33b6f --- /dev/null +++ b/packages/react/src/toggle-group/index.ts @@ -0,0 +1 @@ +export { Root as ToggleGroup } from './index.parts'; diff --git a/packages/react/src/toggle-group/root/ToggleGroupRoot.test.tsx b/packages/react/src/toggle-group/root/ToggleGroupRoot.test.tsx new file mode 100644 index 0000000000..647f00b7f5 --- /dev/null +++ b/packages/react/src/toggle-group/root/ToggleGroupRoot.test.tsx @@ -0,0 +1,397 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { act, describeSkipIf, flushMicrotasks } from '@mui/internal-test-utils'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Toggle } from '@base-ui-components/react/toggle'; +import { createRenderer, describeConformance } from '#test-utils'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +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 (isJSDOM) { + // @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'); + }); + }); + + describe('prop: disabled', () => { + it('can disable the whole group', async () => { + const { getAllByRole } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button1).to.have.attribute('disabled'); + expect(button2).to.have.attribute('disabled'); + }); + + it('can disable individual items', async () => { + const { getAllByRole } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button1).to.not.have.attribute('disabled'); + expect(button2).to.have.attribute('disabled'); + }); + }); + + describe('prop: orientation', () => { + it('vertical', async () => { + const { queryByRole } = await render( + + + + , + ); + + const group = queryByRole('group'); + expect(group).to.have.attribute('data-orientation', 'vertical'); + }); + }); + + describe('prop: toggleMultiple', () => { + it('multiple items can be pressed when true', async () => { + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + + await user.pointer({ keys: '[MouseLeft]', target: button2 }); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('aria-pressed', 'true'); + }); + + it('only one item can be pressed when false', async () => { + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + expect(button2).to.have.attribute('aria-pressed', 'false'); + + await user.pointer({ keys: '[MouseLeft]', target: button2 }); + + expect(button1).to.have.attribute('aria-pressed', 'false'); + expect(button2).to.have.attribute('aria-pressed', 'true'); + }); + }); + + describeSkipIf(isJSDOM)('keyboard interactions', () => { + it('ltr', async () => { + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + await user.keyboard('[Tab]'); + + expect(button1).to.have.attribute('data-highlighted'); + expect(button1).to.have.attribute('tabindex', '0'); + expect(button1).toHaveFocus(); + + await user.keyboard('[ArrowRight]'); + + expect(button2).to.have.attribute('data-highlighted'); + expect(button2).to.have.attribute('tabindex', '0'); + expect(button2).toHaveFocus(); + + await user.keyboard('[ArrowRight]'); + + expect(button1).to.have.attribute('data-highlighted'); + expect(button1).to.have.attribute('tabindex', '0'); + expect(button1).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + + expect(button2).to.have.attribute('data-highlighted'); + expect(button2).to.have.attribute('tabindex', '0'); + expect(button2).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + + expect(button1).to.have.attribute('data-highlighted'); + expect(button1).to.have.attribute('tabindex', '0'); + expect(button1).toHaveFocus(); + }); + + it('rtl', async () => { + const { getAllByRole, user } = await render( + + + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + await user.keyboard('[Tab]'); + + expect(button1).to.have.attribute('data-highlighted'); + expect(button1).to.have.attribute('tabindex', '0'); + expect(button1).toHaveFocus(); + + await user.keyboard('[ArrowLeft]'); + + expect(button2).to.have.attribute('data-highlighted'); + expect(button2).to.have.attribute('tabindex', '0'); + expect(button2).toHaveFocus(); + + await user.keyboard('[ArrowLeft]'); + + expect(button1).to.have.attribute('data-highlighted'); + expect(button1).to.have.attribute('tabindex', '0'); + expect(button1).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + + expect(button2).to.have.attribute('data-highlighted'); + expect(button2).to.have.attribute('tabindex', '0'); + expect(button2).toHaveFocus(); + + await user.keyboard('[ArrowDown]'); + + expect(button1).to.have.attribute('data-highlighted'); + expect(button1).to.have.attribute('tabindex', '0'); + expect(button1).toHaveFocus(); + }); + + ['Enter', 'Space'].forEach((key) => { + it(`key: ${key} toggles the pressed state`, async () => { + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1] = getAllByRole('button'); + + expect(button1).to.have.attribute('aria-pressed', 'false'); + + await act(async () => { + button1.focus(); + }); + + await user.keyboard(`[${key}]`); + + expect(button1).to.have.attribute('aria-pressed', 'true'); + + await user.keyboard(`[${key}]`); + + expect(button1).to.have.attribute('aria-pressed', 'false'); + }); + }); + }); + + describe('prop: onValueChange', () => { + it('fires when an Item is clicked', async () => { + const onValueChange = spy(); + + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(onValueChange.callCount).to.equal(0); + + await user.pointer({ keys: '[MouseLeft]', target: button1 }); + + expect(onValueChange.callCount).to.equal(1); + expect(onValueChange.args[0][0]).to.deep.equal(['one']); + + await user.pointer({ keys: '[MouseLeft]', target: button2 }); + + expect(onValueChange.callCount).to.equal(2); + expect(onValueChange.args[1][0]).to.deep.equal(['two']); + }); + + ['Enter', 'Space'].forEach((key) => { + it(`fires when when the ${key} is pressed`, async function test(t = {}) { + if (isJSDOM) { + // @ts-expect-error to support mocha and vitest + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this?.skip?.() || t?.skip(); + } + + const onValueChange = spy(); + + const { getAllByRole, user } = await render( + + + + , + ); + + const [button1, button2] = getAllByRole('button'); + + expect(onValueChange.callCount).to.equal(0); + + await act(async () => { + button1.focus(); + }); + + await user.keyboard(`[${key}]`); + + expect(onValueChange.callCount).to.equal(1); + expect(onValueChange.args[0][0]).to.deep.equal(['one']); + + await act(async () => { + button2.focus(); + }); + + await user.keyboard(`[${key}]`); + + expect(onValueChange.callCount).to.equal(2); + expect(onValueChange.args[1][0]).to.deep.equal(['two']); + }); + }); + }); +}); diff --git a/packages/react/src/toggle-group/root/ToggleGroupRoot.tsx b/packages/react/src/toggle-group/root/ToggleGroupRoot.tsx new file mode 100644 index 0000000000..81f3332745 --- /dev/null +++ b/packages/react/src/toggle-group/root/ToggleGroupRoot.tsx @@ -0,0 +1,179 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { NOOP } from '../../utils/noop'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { CompositeRoot } from '../../composite/root/CompositeRoot'; +import { useToggleGroupRoot, type UseToggleGroupRoot } from './useToggleGroupRoot'; +import { ToggleGroupRootContext } from './ToggleGroupRootContext'; + +const customStyleHookMapping = { + multiple(value: boolean) { + if (value) { + return { 'data-multiple': '' } as Record; + } + return null; + }, +}; +/** + * + * Demos: + * + * - [ToggleGroup](https://base-ui.com/components/react-toggle-group/) + * + * API: + * + * - [ToggleGroupRoot API](https://base-ui.com/components/react-toggle-group/#api-reference-ToggleGroupRoot) + */ +const ToggleGroupRoot = React.forwardRef(function ToggleGroupRoot( + props: ToggleGroupRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + defaultValue: defaultValueProp, + disabled = false, + loop = true, + onValueChange: onValueChangeProp, + orientation = 'horizontal', + toggleMultiple = false, + value: valueProp, + className, + render, + ...otherProps + } = props; + + const defaultValue = React.useMemo(() => { + if (valueProp === undefined) { + return defaultValueProp ?? []; + } + + return undefined; + }, [valueProp, defaultValueProp]); + + const { + getRootProps, + disabled: isDisabled, + setGroupValue, + value, + } = useToggleGroupRoot({ + value: valueProp, + defaultValue, + disabled, + toggleMultiple, + onValueChange: onValueChangeProp ?? NOOP, + }); + + const state: ToggleGroupRoot.State = React.useMemo( + () => ({ disabled: isDisabled, multiple: toggleMultiple, orientation }), + [isDisabled, orientation, toggleMultiple], + ); + + const contextValue: ToggleGroupRootContext = React.useMemo( + () => ({ + disabled: isDisabled, + orientation, + setGroupValue, + value, + }), + [isDisabled, orientation, setGroupValue, value], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + ref: forwardedRef, + state, + className, + customStyleHookMapping, + extraProps: otherProps, + }); + + return ( + + + + ); +}); + +export { ToggleGroupRoot }; + +export type ToggleGroupOrientation = 'horizontal' | 'vertical'; + +export namespace ToggleGroupRoot { + export interface State { + disabled: boolean; + multiple: boolean; + } + + export interface Props + extends Partial< + Pick< + UseToggleGroupRoot.Parameters, + 'value' | 'defaultValue' | 'onValueChange' | 'disabled' | 'toggleMultiple' + > + >, + Omit, 'defaultValue'> { + /** + * @default false + */ + disabled?: boolean; + /** + * @default 'horizontal' + */ + orientation?: ToggleGroupOrientation; + /** + * @default true + */ + loop?: boolean; + } +} + +ToggleGroupRoot.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]), + /** + * The open state of the ToggleGroup represented by an array of + * the values of all pressed ``s + * This is the uncontrolled counterpart of `value`. + */ + defaultValue: PropTypes.arrayOf(PropTypes.string), + /** + * @default false + */ + disabled: PropTypes.bool, + /** + * Callback fired when the pressed states of the ToggleGroup changes. + * + * @param {any[]} groupValue An array of the `value`s of all the pressed items. + * @param {Event} event The event source of the callback. + */ + onValueChange: PropTypes.func, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * When `false` only one item in the group can be pressed. If any item in + * the group becomes pressed, the others will become unpressed. + * When `true` multiple items can be pressed. + * @default false + */ + toggleMultiple: PropTypes.bool, + /** + * The open state of the ToggleGroup represented by an array of + * the values of all pressed ``s + * This is the controlled counterpart of `defaultValue`. + */ + value: PropTypes.arrayOf(PropTypes.string), +} as any; diff --git a/packages/react/src/toggle-group/root/ToggleGroupRootContext.ts b/packages/react/src/toggle-group/root/ToggleGroupRootContext.ts new file mode 100644 index 0000000000..2c2ab0616c --- /dev/null +++ b/packages/react/src/toggle-group/root/ToggleGroupRootContext.ts @@ -0,0 +1,29 @@ +'use client'; +import * as React from 'react'; +import type { ToggleGroupOrientation } from './ToggleGroupRoot'; + +export interface ToggleGroupRootContext { + value: readonly any[]; + setGroupValue: (newValue: string, nextPressed: boolean, event: Event) => void; + disabled: boolean; + orientation: ToggleGroupOrientation; +} + +export const ToggleGroupRootContext = React.createContext( + undefined, +); + +if (process.env.NODE_ENV !== 'production') { + ToggleGroupRootContext.displayName = 'ToggleGroupRootContext'; +} + +export function useToggleGroupRootContext(optional = true) { + const context = React.useContext(ToggleGroupRootContext); + if (context === undefined && !optional) { + throw new Error( + 'Base UI: ToggleGroupRootContext is missing. ToggleGroup parts must be placed within .', + ); + } + + return context; +} diff --git a/packages/react/src/toggle-group/root/useToggleGroupRoot.ts b/packages/react/src/toggle-group/root/useToggleGroupRoot.ts new file mode 100644 index 0000000000..b912bc554a --- /dev/null +++ b/packages/react/src/toggle-group/root/useToggleGroupRoot.ts @@ -0,0 +1,111 @@ +'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 useToggleGroupRoot( + parameters: UseToggleGroupRoot.Parameters, +): UseToggleGroupRoot.ReturnValue { + const { value, defaultValue, disabled, onValueChange, toggleMultiple } = parameters; + + const [groupValue, setValueState] = useControlled({ + controlled: value, + default: defaultValue, + name: 'ToggleGroup', + state: 'value', + }); + + const setGroupValue = useEventCallback((newValue: string, nextPressed: boolean, event: Event) => { + let newGroupValue: any[] | 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 type ToggleGroupOrientation = 'horizontal' | 'vertical'; + +export namespace UseToggleGroupRoot { + export interface Parameters { + /** + * The open state of the ToggleGroup represented by an array of + * the values of all pressed ``s + * This is the controlled counterpart of `defaultValue`. + */ + value?: readonly any[]; + /** + * The open state of the ToggleGroup represented by an array of + * the values of all pressed ``s + * This is the uncontrolled counterpart of `value`. + */ + defaultValue?: readonly any[]; + /** + * Callback fired when the pressed states of the ToggleGroup changes. + * + * @param {any[]} groupValue An array of the `value`s of all the pressed items. + * @param {Event} event The event source of the callback. + */ + onValueChange: (groupValue: any[], event: Event) => void; + /** + * When `true` the component is disabled + * @false + */ + disabled: boolean; + /** + * When `false` only one item in the group can be pressed. If any item in + * the group becomes pressed, the others will become unpressed. + * When `true` multiple items can be pressed. + * @default false + */ + toggleMultiple: boolean; + } + + export interface ReturnValue { + getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * When `true` the component is disabled + * @false + */ + disabled: boolean; + /** + * + */ + setGroupValue: (newValue: string, nextPressed: boolean, event: Event) => void; + /** + * The value of the ToggleGroup represented by an array of values + * of the items that are pressed + */ + value: readonly any[]; + } +} diff --git a/packages/react/src/toggle/index.parts.ts b/packages/react/src/toggle/index.parts.ts new file mode 100644 index 0000000000..f133bab1ef --- /dev/null +++ b/packages/react/src/toggle/index.parts.ts @@ -0,0 +1 @@ +export { ToggleRoot as Root } from './root/ToggleRoot'; diff --git a/packages/react/src/toggle/index.ts b/packages/react/src/toggle/index.ts new file mode 100644 index 0000000000..b7fc575d87 --- /dev/null +++ b/packages/react/src/toggle/index.ts @@ -0,0 +1 @@ +export { Root as Toggle } from './index.parts'; diff --git a/packages/react/src/toggle/root/ToggleRoot.test.tsx b/packages/react/src/toggle/root/ToggleRoot.test.tsx new file mode 100644 index 0000000000..ff4d180cdf --- /dev/null +++ b/packages/react/src/toggle/root/ToggleRoot.test.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { act } from '@mui/internal-test-utils'; +import { Toggle } from '@base-ui-components/react/toggle'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLButtonElement, + render, + })); + + describe('pressed state', () => { + it('controlled', async () => { + function App() { + const [pressed, setPressed] = React.useState(false); + return ( + + setPressed(!pressed)} /> + ; + + ); + } + + const { getByRole } = await render(); + const checkbox = getByRole('checkbox'); + const button = getByRole('button'); + + expect(button).to.have.attribute('aria-pressed', 'false'); + await act(async () => { + checkbox.click(); + }); + + expect(button).to.have.attribute('aria-pressed', 'true'); + + await act(async () => { + checkbox.click(); + }); + + expect(button).to.have.attribute('aria-pressed', 'false'); + }); + + it('uncontrolled', async () => { + const { getByRole } = await render(); + + const button = getByRole('button'); + + expect(button).to.have.attribute('aria-pressed', 'false'); + await act(async () => { + button.click(); + }); + + expect(button).to.have.attribute('aria-pressed', 'true'); + + await act(async () => { + button.click(); + }); + + expect(button).to.have.attribute('aria-pressed', 'false'); + }); + }); + + describe('prop: onPressedChange', () => { + it('is called when the pressed state changes', async () => { + const handlePressed = spy(); + const { getByRole } = await render( + , + ); + + const button = getByRole('button'); + + await act(async () => { + button.click(); + }); + + expect(handlePressed.callCount).to.equal(1); + expect(handlePressed.firstCall.args[0]).to.equal(true); + }); + }); + + describe('prop: disabled', () => { + it('disables the component', async () => { + const handlePressed = spy(); + const { getByRole } = await render(); + + const button = getByRole('button'); + expect(button).to.have.attribute('disabled'); + expect(button).to.have.attribute('aria-pressed', 'false'); + + await act(async () => { + button.click(); + }); + + expect(handlePressed.callCount).to.equal(0); + expect(button).to.have.attribute('aria-pressed', 'false'); + }); + }); +}); diff --git a/packages/react/src/toggle/root/ToggleRoot.tsx b/packages/react/src/toggle/root/ToggleRoot.tsx new file mode 100644 index 0000000000..813f1dffb8 --- /dev/null +++ b/packages/react/src/toggle/root/ToggleRoot.tsx @@ -0,0 +1,163 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { NOOP } from '../../utils/noop'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { CompositeItem } from '../../composite/item/CompositeItem'; +import { useToggleGroupRootContext } from '../../toggle-group/root/ToggleGroupRootContext'; +import { useToggleRoot } from './useToggleRoot'; + +const customStyleHookMapping = { + disabled: () => null, +}; +/** + * + * Demos: + * + * - [Toggle](https://base-ui.com/components/react-toggle/) + * + * API: + * + * - [ToggleRoot API](https://base-ui.com/components/react-toggle/#api-reference-ToggleRoot) + */ +const ToggleRoot = React.forwardRef(function ToggleRoot( + props: ToggleRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { + className, + defaultPressed = false, + disabled: disabledProp = false, + form, // never participates in form validation + onPressedChange: onPressedChangeProp, + pressed: pressedProp, + render, + type, // cannot change button type + value: valueProp, + ...otherProps + } = props; + + const groupContext = useToggleGroupRootContext(); + + const groupValue = groupContext?.value ?? []; + + const { disabled, pressed, getRootProps } = useToggleRoot({ + buttonRef: forwardedRef, + defaultPressed: groupContext ? undefined : defaultPressed, + disabled: (disabledProp || groupContext?.disabled) ?? false, + onPressedChange: onPressedChangeProp ?? NOOP, + pressed: groupContext && valueProp ? groupValue?.indexOf(valueProp) > -1 : pressedProp, + setGroupValue: groupContext?.setGroupValue ?? NOOP, + value: valueProp ?? '', + }); + + const state: ToggleRoot.State = React.useMemo( + () => ({ + disabled, + pressed, + }), + [disabled, pressed], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'button', + ref: forwardedRef, + state, + className, + customStyleHookMapping, + extraProps: otherProps, + }); + + return groupContext ? : renderElement(); +}); + +export { ToggleRoot }; + +export namespace ToggleRoot { + export interface State { + pressed: boolean; + disabled: boolean; + } + + export interface Props + extends Partial< + Pick< + useToggleRoot.Parameters, + 'pressed' | 'defaultPressed' | 'disabled' | 'onPressedChange' | 'value' + > + >, + Omit, 'value'> { + /** + * The label for the Toggle. + */ + 'aria-label'?: React.AriaAttributes['aria-label']; + /** + * An id or space-separated list of ids of elements that label the Toggle. + */ + 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; + } +} + +ToggleRoot.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. + */ + 'aria-label': PropTypes.string, + /** + * An id or space-separated list of ids of elements that label the Toggle. + */ + '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]), + /** + * The default pressed state. Use when the component is not controlled. + * + * @default false + */ + defaultPressed: PropTypes.bool, + /** + * If `true`, the component is disabled. + * + * @default false + */ + disabled: PropTypes.bool, + /** + * @ignore + */ + form: PropTypes.string, + /** + * Callback fired when the pressed state is changed. + * + * @param {boolean} pressed The new pressed state. + * @param {Event} event The event source of the callback. + * + * @default NOOP + */ + onPressedChange: PropTypes.func, + /** + * If `true`, the component is pressed. + * + * @default undefined + */ + pressed: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + type: PropTypes.oneOf(['button', 'reset', 'submit']), +} as any; diff --git a/packages/react/src/toggle/root/useToggleRoot.ts b/packages/react/src/toggle/root/useToggleRoot.ts new file mode 100644 index 0000000000..6f13f791d4 --- /dev/null +++ b/packages/react/src/toggle/root/useToggleRoot.ts @@ -0,0 +1,119 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { NOOP } from '../../utils/noop'; +import { GenericHTMLProps } from '../../utils/types'; +import { useControlled } from '../../utils/useControlled'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useButton } from '../../use-button/useButton'; + +export function useToggleRoot(parameters: useToggleRoot.Parameters): useToggleRoot.ReturnValue { + const { + buttonRef: externalRef, + defaultPressed, + disabled, + onPressedChange: onPressedChangeProp = NOOP, + pressed: pressedProp, + setGroupValue, + value, + } = parameters; + + const [pressed, setPressedState] = useControlled({ + controlled: pressedProp, + default: defaultPressed, + name: 'Toggle', + state: 'pressed', + }); + + const onPressedChange = useEventCallback((nextPressed: boolean, event: Event) => { + setGroupValue(value, nextPressed, event); + onPressedChangeProp(nextPressed, event); + }); + + const { getButtonProps, buttonRef } = useButton({ + disabled, + buttonRef: externalRef, + type: 'button', + }); + + const getRootProps = React.useCallback( + (externalProps?: GenericHTMLProps): GenericHTMLProps => { + return mergeReactProps( + externalProps, + { + 'aria-pressed': pressed, + onClick(event: React.MouseEvent) { + const nextPressed = !pressed; + setPressedState(nextPressed); + onPressedChange(nextPressed, event.nativeEvent); + }, + ref: buttonRef, + }, + getButtonProps(), + ); + }, + [getButtonProps, buttonRef, onPressedChange, pressed, setPressedState], + ); + + return React.useMemo( + () => ({ + getRootProps, + disabled, + pressed, + }), + [getRootProps, disabled, pressed], + ); +} + +export namespace useToggleRoot { + export interface Parameters { + buttonRef?: React.Ref; + /** + * If `true`, the component is pressed. + */ + pressed?: boolean; + /** + * The default pressed state. Use when the component is not controlled. + * @default false + */ + defaultPressed?: boolean; + /** + * If `true`, the component is disabled. + */ + disabled: boolean; + /** + * Callback fired when the pressed state is changed. + * + * @param {boolean} pressed The new pressed state. + * @param {Event} event The event source of the callback. + */ + onPressedChange: (pressed: boolean, event: Event) => void; + /** + * State setter for toggle group value when used in a toggle group + */ + setGroupValue: (newValue: string, nextPressed: boolean, event: Event) => void; + /** + * A unique string that identifies the component when used + * inside a ToggleGroup + */ + value: string; + // TODO: a prop to indicate `aria-pressed='mixed'` is supported + } + + export interface ReturnValue { + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + /** + * If `true`, the Toggle is disabled. + */ + disabled: boolean; + /** + * If `true`, the Toggle is pressed. + */ + pressed: boolean; + } +}
<ToggleGroup.Item/>
value
false
true
defaultValue