Skip to content

Commit

Permalink
WIP toggle button group
Browse files Browse the repository at this point in the history
  • Loading branch information
mj12albert committed Oct 30, 2024
1 parent 71212e1 commit f23c6df
Show file tree
Hide file tree
Showing 10 changed files with 320 additions and 9 deletions.
38 changes: 38 additions & 0 deletions docs/src/app/experiments/toggle.module.css
Original file line number Diff line number Diff line change
@@ -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;*/
}
60 changes: 51 additions & 9 deletions docs/src/app/experiments/toggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<ToggleButtonGroup>
<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
// className={classes.button}
className={classes.button}
value="align-left"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={classes.icon}
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
<line x1={17} y1={10} x2={3} y2={10} />
<line x1="21" y1="6" x2="3" y2="6" />
<line x1="21" y1="14" x2="3" y2="14" />
<line x1="17" y1="18" x2="3" y2="18" />
</svg>
</ToggleButton>
</div>

<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
className={classes.button}
value="align-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className={classes.icon}
>
<line x1="18" y1="10" x2="6" y2="10" />
<line x1="21" y1="6" x2="3" y2="6" />
<line x1="21" y1="14" x2="3" y2="14" />
<line x1="18" y1="18" x2="6" y2="18" />
</svg>
</ToggleButton>

<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
className={classes.button}
value="align-right"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className={classes.icon}
>
<line x1="21" y1="10" x2="7" y2="10" />
<line x1="21" y1="6" x2="3" y2="6" />
<line x1="21" y1="14" x2="3" y2="14" />
<line x1="21" y1="18" x2="7" y2="18" />
</svg>
</ToggleButton>
</ToggleButtonGroup>
);
}
2 changes: 2 additions & 0 deletions packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const ToggleButtonRoot = React.forwardRef(function ToggleButtonRoot(
onPressedChange,
className,
render,
type,
form,
...otherProps
} = props;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function useToggleButtonRoot(
defaultPressed = false,
disabled = false,
buttonRef: externalRef,
value = undefined,
} = parameters;

const [pressed, setPressedState] = useControlled({
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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('<ToggleButtonGroup.Root />', () => {
const { render } = createRenderer();

describeConformance(<ToggleButtonGroup.Root />, () => ({
refInstanceof: window.HTMLDivElement,
render,
}));
});
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}
return null;
},
};

const ToggleButtonGroupRoot = React.forwardRef(function ToggleButtonGroupRoot(
props: ToggleButtonGroupRoot.Props,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
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 (
<ToggleButtonGroupRootContext.Provider value={contextValue}>
{renderElement()}
</ToggleButtonGroupRootContext.Provider>
);
});

export { ToggleButtonGroupRoot };

export namespace ToggleButtonGroupRoot {
export interface OwnerState {
disabled: boolean;
multiple: boolean;
}

export interface Props
extends Pick<UseToggleButtonGroupRoot.Parameters, 'toggleMultiple'>,
BaseUIComponentProps<'div', OwnerState> {
/**
* @default false
*/
disabled?: boolean;
}
}
Original file line number Diff line number Diff line change
@@ -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 <ToggleButtonGroup.Root>.',
);
}

return context;
}
Original file line number Diff line number Diff line change
@@ -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[];
}
}
1 change: 1 addition & 0 deletions packages/mui-base/src/ToggleButtonGroup/index.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ToggleButtonGroupRoot as Root } from './Root/ToggleButtonGroupRoot';
1 change: 1 addition & 0 deletions packages/mui-base/src/ToggleButtonGroup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Root as ToggleButtonGroup } from './index.parts';

0 comments on commit f23c6df

Please sign in to comment.