From 4f82de28d975ed69ba3564982cab434c10f8ff20 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 5 Dec 2024 01:01:33 +0800 Subject: [PATCH 01/10] Add Toolbar components --- docs/reference/generated/toolbar-root.json | 26 +++++ packages/react/src/index.ts | 1 + packages/react/src/toolbar/index.parts.ts | 2 + packages/react/src/toolbar/index.ts | 1 + .../src/toolbar/root/ToolbarRoot.test.tsx | 23 ++++ .../react/src/toolbar/root/ToolbarRoot.tsx | 107 ++++++++++++++++++ .../src/toolbar/root/ToolbarRootContext.tsx | 31 +++++ .../react/src/toolbar/root/useToolbarRoot.ts | 45 ++++++++ 8 files changed, 236 insertions(+) create mode 100644 docs/reference/generated/toolbar-root.json create mode 100644 packages/react/src/toolbar/index.parts.ts create mode 100644 packages/react/src/toolbar/index.ts create mode 100644 packages/react/src/toolbar/root/ToolbarRoot.test.tsx create mode 100644 packages/react/src/toolbar/root/ToolbarRoot.tsx create mode 100644 packages/react/src/toolbar/root/ToolbarRootContext.tsx create mode 100644 packages/react/src/toolbar/root/useToolbarRoot.ts diff --git a/docs/reference/generated/toolbar-root.json b/docs/reference/generated/toolbar-root.json new file mode 100644 index 0000000000..e3daed5167 --- /dev/null +++ b/docs/reference/generated/toolbar-root.json @@ -0,0 +1,26 @@ +{ + "name": "ToolbarRoot", + "description": "", + "props": { + "loop": { + "type": "boolean", + "default": "true", + "description": "If `true`, using keyboard navigation will wrap focus to the other end of the toolbar once the end is reached." + }, + "orientation": { + "type": "'horizontal' | 'vertical'", + "default": "'horizontal'", + "description": "The component orientation (layout flow direction)." + }, + "className": { + "type": "string | (state) => string", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 35abfbeda7..5a0ccfd426 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -24,4 +24,5 @@ export * from './switch'; export * from './tabs'; export * from './toggle'; export * from './toggle-group'; +export * from './toolbar'; export * from './tooltip'; diff --git a/packages/react/src/toolbar/index.parts.ts b/packages/react/src/toolbar/index.parts.ts new file mode 100644 index 0000000000..8afd5c55e7 --- /dev/null +++ b/packages/react/src/toolbar/index.parts.ts @@ -0,0 +1,2 @@ +export { Separator } from '../separator/Separator'; +export { ToolbarRoot as Root } from './root/ToolbarRoot'; diff --git a/packages/react/src/toolbar/index.ts b/packages/react/src/toolbar/index.ts new file mode 100644 index 0000000000..01dbf1bcc5 --- /dev/null +++ b/packages/react/src/toolbar/index.ts @@ -0,0 +1 @@ +export * as Toolbar from './index.parts'; diff --git a/packages/react/src/toolbar/root/ToolbarRoot.test.tsx b/packages/react/src/toolbar/root/ToolbarRoot.test.tsx new file mode 100644 index 0000000000..4eb292d1f5 --- /dev/null +++ b/packages/react/src/toolbar/root/ToolbarRoot.test.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { expect } from 'chai'; +// import { spy } from 'sinon'; +// import { act } from '@mui/internal-test-utils'; +import { Toolbar } from '@base-ui-components/react/toolbar'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render, + })); + + describe('ARIA attributes', () => { + it('has role="toolbar"', async () => { + const { container } = await render(); + + expect(container.firstElementChild as HTMLElement).to.have.attribute('role', 'toolbar'); + }); + }); +}); diff --git a/packages/react/src/toolbar/root/ToolbarRoot.tsx b/packages/react/src/toolbar/root/ToolbarRoot.tsx new file mode 100644 index 0000000000..96a3948901 --- /dev/null +++ b/packages/react/src/toolbar/root/ToolbarRoot.tsx @@ -0,0 +1,107 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { CompositeRoot } from '../../composite/root/CompositeRoot'; +import { ToolbarRootContext } from './ToolbarRootContext'; +import { useToolbarRoot } from './useToolbarRoot'; + +/** + * A container for grouping a set of controls, such as buttons, toggle groups, or menus. + * Renders a `
` element. + * + * Documentation: [Base UI Toolbar](https://base-ui.com/react/components/toolbar) + */ +const ToolbarRoot = React.forwardRef(function ToolbarRoot( + props: ToolbarRoot.Props, + forwardedRef: React.ForwardedRef, +) { + const { loop = true, orientation = 'horizontal', className, render, ...otherProps } = props; + + const { getRootProps } = useToolbarRoot({ + orientation, + }); + + const toolbarRootContext = React.useMemo( + () => ({ + orientation, + }), + [orientation], + ); + + const state = React.useMemo(() => ({ orientation }), [orientation]); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'div', + className, + state, + extraProps: otherProps, + ref: forwardedRef, + }); + + return ( + + + + ); +}); + +export type ToolbarOrientation = 'horizontal' | 'vertical'; + +namespace ToolbarRoot { + export type State = { + orientation: ToolbarOrientation; + }; + + export interface Props extends BaseUIComponentProps<'div', State> { + /** + * The component orientation (layout flow direction). + * @default 'horizontal' + */ + orientation?: ToolbarOrientation; + /** + * If `true`, using keyboard navigation will wrap focus to the other end of the toolbar once the end is reached. + * + * @default true + */ + loop?: boolean; + } +} + +export { ToolbarRoot }; + +ToolbarRoot.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, + /** + * CSS class applied to the element, or a function that + * returns a class based on the component’s state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * If `true`, using keyboard navigation will wrap focus to the other end of the toolbar once the end is reached. + * + * @default true + */ + loop: PropTypes.bool, + /** + * The component orientation (layout flow direction). + * @default 'horizontal' + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * Allows you to replace the component’s HTML element + * with a different tag, or compose it with another component. + * + * Accepts a `ReactElement` or a function that returns the element to render. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; diff --git a/packages/react/src/toolbar/root/ToolbarRootContext.tsx b/packages/react/src/toolbar/root/ToolbarRootContext.tsx new file mode 100644 index 0000000000..aa890e17b1 --- /dev/null +++ b/packages/react/src/toolbar/root/ToolbarRootContext.tsx @@ -0,0 +1,31 @@ +'use client'; +import * as React from 'react'; +import type { ToolbarOrientation } from './ToolbarRoot'; + +export interface ToolbarRootContext { + orientation: ToolbarOrientation; +} + +/** + * @ignore - internal component. + */ +export const ToolbarRootContext = React.createContext(undefined); + +if (process.env.NODE_ENV !== 'production') { + ToolbarRootContext.displayName = 'ToolbarRootContext'; +} + +function useToolbarRootContext(optional?: false): ToolbarRootContext; +function useToolbarRootContext(optional: true): ToolbarRootContext | undefined; +function useToolbarRootContext(optional?: boolean) { + const context = React.useContext(ToolbarRootContext); + if (context === undefined && !optional) { + throw new Error( + 'Base UI: ToolbarRootContext is missing. Toolbar parts must be placed within .', + ); + } + + return context; +} + +export { useToolbarRootContext }; diff --git a/packages/react/src/toolbar/root/useToolbarRoot.ts b/packages/react/src/toolbar/root/useToolbarRoot.ts new file mode 100644 index 0000000000..dc7bc09f4a --- /dev/null +++ b/packages/react/src/toolbar/root/useToolbarRoot.ts @@ -0,0 +1,45 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { GenericHTMLProps } from '../../utils/types'; + +function useToolbarRoot(parameters: useToolbarRoot.Parameters): useToolbarRoot.ReturnValue { + const { orientation } = parameters; + + const getRootProps = React.useCallback( + (otherProps = {}): React.ComponentPropsWithRef<'div'> => { + return mergeReactProps(otherProps, { + 'aria-orientation': orientation, + role: 'toolbar', + }); + }, + [orientation], + ); + + return React.useMemo( + () => ({ + getRootProps, + }), + [getRootProps], + ); +} + +namespace useToolbarRoot { + export interface Parameters { + /** + * The component orientation (layout flow direction). + */ + orientation: 'horizontal' | 'vertical'; + } + + export interface ReturnValue { + /** + * Resolver for the Toolbar component's props. + * @param externalProps additional props for Toolbar.Root + * @returns props that should be spread on Toolbar.Root + */ + getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + } +} + +export { useToolbarRoot }; From bde9221527d3edd00c2abaf00e45cac5364ce639 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Wed, 8 Jan 2025 22:14:47 +0800 Subject: [PATCH 02/10] Make ToggleGroup work in Toolbar --- .../(private)/experiments/toolbar.module.css | 93 ++++++++++++ .../src/app/(private)/experiments/toolbar.tsx | 136 ++++++++++++++++++ .../react/src/toggle-group/ToggleGroup.tsx | 9 +- 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 docs/src/app/(private)/experiments/toolbar.module.css create mode 100644 docs/src/app/(private)/experiments/toolbar.tsx diff --git a/docs/src/app/(private)/experiments/toolbar.module.css b/docs/src/app/(private)/experiments/toolbar.module.css new file mode 100644 index 0000000000..3b916d39b4 --- /dev/null +++ b/docs/src/app/(private)/experiments/toolbar.module.css @@ -0,0 +1,93 @@ +.Root { + display: flex; + gap: 0.5rem; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + padding: 0.125rem; + text-wrap: nowrap; +} + +.Separator { + width: 1px; + margin-block: 2px; + background-color: var(--color-gray-300); +} + +.Link { + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-gray-900); + text-decoration-color: var(--color-gray-400); + text-decoration-thickness: 1px; + text-decoration-line: none; + text-underline-offset: 2px; + + @media (hover: hover) { + &:hover { + text-decoration-line: underline; + } + } + + &:focus-visible { + border-radius: 0.125rem; + outline: 2px solid var(--color-blue); + text-decoration-line: none; + } +} + +/* ToggleGroup */ +.ToggleGroup { + display: flex; + gap: 1px; + border-radius: 0.375rem; + padding: 0.125rem; +} + +.Toggle { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + margin: 0; + outline: 0; + border: 0; + border-radius: 0.25rem; + background-color: transparent; + color: var(--color-gray-600); + user-select: none; + + &:focus-visible { + background-color: transparent; + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-200); + } + + &[data-pressed] { + background-color: var(--color-gray-100); + color: var(--color-gray-900); + } +} + +.Icon { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/docs/src/app/(private)/experiments/toolbar.tsx b/docs/src/app/(private)/experiments/toolbar.tsx new file mode 100644 index 0000000000..5aafdbc90f --- /dev/null +++ b/docs/src/app/(private)/experiments/toolbar.tsx @@ -0,0 +1,136 @@ +'use client'; +import * as React from 'react'; +import { Toolbar } from '@base-ui-components/react/toolbar'; +import { Toggle } from '@base-ui-components/react/toggle'; +import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import s from './toolbar.module.css'; +import '../../../demo-theme.css'; + +export default function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function AlignLeftIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + + ); +} + +function AlignCenterIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + + ); +} + +function AlignRightIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + + ); +} + +function BoldIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} + +function ItalicsIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} + +function UnderlineIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} diff --git a/packages/react/src/toggle-group/ToggleGroup.tsx b/packages/react/src/toggle-group/ToggleGroup.tsx index bf5f377e1e..38c5565a19 100644 --- a/packages/react/src/toggle-group/ToggleGroup.tsx +++ b/packages/react/src/toggle-group/ToggleGroup.tsx @@ -6,6 +6,7 @@ import { useComponentRenderer } from '../utils/useComponentRenderer'; import type { BaseUIComponentProps } from '../utils/types'; import { CompositeRoot } from '../composite/root/CompositeRoot'; import { useDirection } from '../direction-provider/DirectionContext'; +import { useToolbarRootContext } from '../toolbar/root/ToolbarRootContext'; import { useToggleGroup, type UseToggleGroup } from './useToggleGroup'; import { ToggleGroupContext } from './ToggleGroupContext'; @@ -42,6 +43,8 @@ const ToggleGroup = React.forwardRef(function ToggleGroup( const direction = useDirection(); + const toolbarContext = useToolbarRootContext(true); + const defaultValue = React.useMemo(() => { if (valueProp === undefined) { return defaultValueProp ?? []; @@ -90,7 +93,11 @@ const ToggleGroup = React.forwardRef(function ToggleGroup( return ( - + {toolbarContext ? ( + renderElement() + ) : ( + + )} ); }); From 899232a74b918290a25b95895b3f500c47fa0468 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 9 Jan 2025 00:36:21 +0800 Subject: [PATCH 03/10] Add toolbar button --- docs/reference/generated/toolbar-button.json | 16 ++ docs/reference/generated/toolbar-root.json | 2 +- .../(private)/experiments/toolbar.module.css | 43 +++++ .../src/app/(private)/experiments/toolbar.tsx | 148 ++++++++++++++++++ .../src/toolbar/button/ToolbarButton.test.tsx | 48 ++++++ .../src/toolbar/button/ToolbarButton.tsx | 78 +++++++++ packages/react/src/toolbar/index.parts.ts | 1 + packages/react/vitest.config.mts | 6 + 8 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 docs/reference/generated/toolbar-button.json create mode 100644 packages/react/src/toolbar/button/ToolbarButton.test.tsx create mode 100644 packages/react/src/toolbar/button/ToolbarButton.tsx diff --git a/docs/reference/generated/toolbar-button.json b/docs/reference/generated/toolbar-button.json new file mode 100644 index 0000000000..2d5966a6d5 --- /dev/null +++ b/docs/reference/generated/toolbar-button.json @@ -0,0 +1,16 @@ +{ + "name": "ToolbarButton", + "description": "", + "props": { + "className": { + "type": "string | (state) => string", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/toolbar-root.json b/docs/reference/generated/toolbar-root.json index e3daed5167..f9a72c21dd 100644 --- a/docs/reference/generated/toolbar-root.json +++ b/docs/reference/generated/toolbar-root.json @@ -1,6 +1,6 @@ { "name": "ToolbarRoot", - "description": "", + "description": "A container for grouping a set of controls, such as buttons, toggle groups, or menus.\nRenders a `
` element.", "props": { "loop": { "type": "boolean", diff --git a/docs/src/app/(private)/experiments/toolbar.module.css b/docs/src/app/(private)/experiments/toolbar.module.css index 3b916d39b4..92db3c44cb 100644 --- a/docs/src/app/(private)/experiments/toolbar.module.css +++ b/docs/src/app/(private)/experiments/toolbar.module.css @@ -1,5 +1,6 @@ .Root { display: flex; + align-items: center; gap: 0.5rem; border: 1px solid var(--color-gray-200); border-radius: 0.375rem; @@ -10,6 +11,7 @@ .Separator { width: 1px; + align-self: stretch; margin-block: 2px; background-color: var(--color-gray-300); } @@ -91,3 +93,44 @@ stroke-linecap: round; stroke-linejoin: round; } + +/* Menu */ +.More { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2.5rem; + padding: 0 0.875rem; + margin: 0; + outline: 0; + border: 1px solid var(--color-gray-200); + border-radius: 0.375rem; + background-color: var(--color-gray-50); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + line-height: 1.5rem; + color: var(--color-gray-900); + user-select: none; + + @media (hover: hover) { + &:hover { + background-color: var(--color-gray-100); + } + } + + &:active { + background-color: var(--color-gray-100); + } + + &[data-popup-open] { + background-color: var(--color-gray-100); + } + + &:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; + } +} diff --git a/docs/src/app/(private)/experiments/toolbar.tsx b/docs/src/app/(private)/experiments/toolbar.tsx index 5aafdbc90f..b59eab8cc8 100644 --- a/docs/src/app/(private)/experiments/toolbar.tsx +++ b/docs/src/app/(private)/experiments/toolbar.tsx @@ -3,12 +3,68 @@ import * as React from 'react'; import { Toolbar } from '@base-ui-components/react/toolbar'; import { Toggle } from '@base-ui-components/react/toggle'; import { ToggleGroup } from '@base-ui-components/react/toggle-group'; +import { Select } from '@base-ui-components/react/select'; +import { Menu } from '@base-ui-components/react/menu'; import s from './toolbar.module.css'; +import selectClasses from '../../(public)/(content)/react/components/select/demos/hero/css-modules/index.module.css'; +import menuClasses from '../../(public)/(content)/react/components/menu/demos/hero/css-modules/index.module.css'; import '../../../demo-theme.css'; export default function App() { return ( + + } className={selectClasses.Select}> + + + + + + + + + + + + + + + + + Sans-serif + + + + + + + + Serif + + + + + + + + Monospace + + + + + + + + Cursive + + + + + + + + + @@ -34,6 +90,31 @@ export default function App() { + + + + + } className={s.More}> + + + + + + + + + Add to Library + Add to Playlist + + Play Next + Play Last + + Favorite + Share + + + + ); } @@ -134,3 +215,70 @@ function UnderlineIcon(props: React.ComponentProps<'svg'>) { ); } + +function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} + +function ChevronUpDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + ); +} + +function CheckIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function ChevronDownIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + ); +} + +function MoreHorizontalIcon(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} diff --git a/packages/react/src/toolbar/button/ToolbarButton.test.tsx b/packages/react/src/toolbar/button/ToolbarButton.test.tsx new file mode 100644 index 0000000000..3da7e7f909 --- /dev/null +++ b/packages/react/src/toolbar/button/ToolbarButton.test.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { expect } from 'chai'; +// import { spy } from 'sinon'; +// import { act } from '@mui/internal-test-utils'; +import { Toolbar } from '@base-ui-components/react/toolbar'; +import { screen } from '@mui/internal-test-utils'; +import { createRenderer, describeConformance } from '#test-utils'; +import { NOOP } from '../../utils/noop'; +import { ToolbarRootContext } from '../root/ToolbarRootContext'; +import { CompositeRootContext } from '../../composite/root/CompositeRootContext'; + +const testCompositeContext = { + highlightedIndex: 0, + onHighlightedIndexChange: NOOP, +}; + +const testToolbarContext: ToolbarRootContext = { + orientation: 'horizontal', +}; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLButtonElement, + render: (node) => { + return render( + + + {node} + + , + ); + }, + })); + + describe('ARIA attributes', () => { + it('renders a button', async () => { + const { getByTestId } = await render( + + + , + ); + + expect(getByTestId('button')).to.equal(screen.getByRole('button')); + }); + }); +}); diff --git a/packages/react/src/toolbar/button/ToolbarButton.tsx b/packages/react/src/toolbar/button/ToolbarButton.tsx new file mode 100644 index 0000000000..cfbba420eb --- /dev/null +++ b/packages/react/src/toolbar/button/ToolbarButton.tsx @@ -0,0 +1,78 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useButton } from '../../use-button'; +import { CompositeItem } from '../../composite/item/CompositeItem'; + +const EMPTY_OBJECT = {}; +/** + * A button that can be used as-is or as a trigger for other components. + * Renders a `