Skip to content

Commit

Permalink
WIP - New Accordion hooks and components
Browse files Browse the repository at this point in the history
  • Loading branch information
mj12albert committed Aug 23, 2024
1 parent 696867a commit c9f7cea
Show file tree
Hide file tree
Showing 15 changed files with 603 additions and 0 deletions.
73 changes: 73 additions & 0 deletions docs/pages/experiments/accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as React from 'react';
import * as Accordion from '@base_ui/react/Accordion';

export default function App() {
return (
<div className="App AccordionDemo">
<pre>Plain HTML</pre>
<div role="region" className="MyAccordion-root" aria-label="My Accordion Component">
<div className="MyAccordion-section">
<h3 className="MyAccordion-heading">
<button
type="button"
aria-controls="Panel1"
aria-expanded="true"
className="MyAccordion-trigger"
id="Trigger1"
>
Panel 1
</button>
</h3>
<div id="Panel1" className="MyAccordion-panel" role="region" aria-labelledby="Trigger1">
This the contents of Panel 1
</div>
</div>

<div className="MyAccordion-section">
<h3 className="MyAccordion-heading">
<button
type="button"
aria-controls="Panel2"
aria-expanded="false"
className="MyAccordion-trigger"
id="Trigger2"
>
Panel 2
</button>
</h3>
<div id="Panel2" className="MyAccordion-panel" role="region" aria-labelledby="Trigger2">
This the contents of Panel 2
</div>
</div>
</div>

<br />
<br />
<hr />
<br />
<br />

<pre>Base UI</pre>

<Accordion.Root className="MyAccordion-root">
<Accordion.Section className="MyAccordion-section">
<Accordion.Heading className="MyAccordion-heading">
<Accordion.Trigger className="MyAccordion-trigger">Trigger 1</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel className="MyAccordion-panel">
This is the contents of Accordion.Panel 1
</Accordion.Panel>
</Accordion.Section>

<Accordion.Section className="MyAccordion-section">
<Accordion.Heading className="MyAccordion-heading">
<Accordion.Trigger className="MyAccordion-trigger">Trigger 2</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel className="MyAccordion-panel">
This is the contents of Accordion.Panel 2
</Accordion.Panel>
</Accordion.Section>
</Accordion.Root>
</div>
);
}
33 changes: 33 additions & 0 deletions packages/mui-base/src/Accordion/Heading/AccordionHeading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';
import * as React from 'react';
import { BaseUIComponentProps } from '../../utils/types';
import { useComponentRenderer } from '../../utils/useComponentRenderer';
import type { AccordionSection } from '../Section/AccordionSection';
import { useAccordionSectionContext } from '../Section/AccordionSectionContext';
import { accordionStyleHookMapping } from '../Section/styleHooks';

const AccordionHeading = React.forwardRef(function AccordionHeading(
props: AccordionHeading.Props,
forwardedRef: React.ForwardedRef<HTMLHeadingElement>,
) {
const { render, className, ...otherProps } = props;

const { ownerState } = useAccordionSectionContext();

const { renderElement } = useComponentRenderer({
render: render ?? 'h3',
ownerState,
className,
ref: forwardedRef,
extraProps: otherProps,
customStyleHookMapping: accordionStyleHookMapping,
});

return renderElement();
});

export { AccordionHeading };

export namespace AccordionHeading {
export interface Props extends BaseUIComponentProps<'h3', AccordionSection.OwnerState> {}
}
55 changes: 55 additions & 0 deletions packages/mui-base/src/Accordion/Panel/AccordionPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import { BaseUIComponentProps } from '../../utils/types';
import { useComponentRenderer } from '../../utils/useComponentRenderer';
import { useCollapsibleContext } from '../../Collapsible/Root/CollapsibleContext';
import { useCollapsibleContent } from '../../Collapsible/Content/useCollapsibleContent';
import type { AccordionSection } from '../Section/AccordionSection';
import { useAccordionSectionContext } from '../Section/AccordionSectionContext';
import { accordionStyleHookMapping } from '../Section/styleHooks';

export const AccordionPanel = React.forwardRef(function AccordionPanel(
props: AccordionPanel.Props,
forwardedRef: React.ForwardedRef<HTMLButtonElement>,
) {
const { className, htmlHidden, render, ...otherProps } = props;

const { animated, mounted, open, contentId, setContentId, setMounted, setOpen } =
useCollapsibleContext();

const { getRootProps, height } = useCollapsibleContent({
animated,
htmlHidden,
id: contentId,
mounted,
open,
ref: forwardedRef,
setContentId,
setMounted,
setOpen,
});

const { ownerState } = useAccordionSectionContext();

const { renderElement } = useComponentRenderer({
propGetter: getRootProps,
render: render ?? 'div',
ownerState,
className,
extraProps: {
...otherProps,
style: {
'--accordion-content-height': height ? `${height}px` : undefined,
},
},
customStyleHookMapping: accordionStyleHookMapping,
});

return renderElement();
});

export namespace AccordionPanel {
export interface Props
extends BaseUIComponentProps<'div', AccordionSection.OwnerState>,
Pick<useCollapsibleContent.Parameters, 'htmlHidden'> {}
}
73 changes: 73 additions & 0 deletions packages/mui-base/src/Accordion/Root/AccordionRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import { FloatingList } from '@floating-ui/react';
import { BaseUIComponentProps } from '../../utils/types';
import { useComponentRenderer } from '../../utils/useComponentRenderer';
import { useAccordionRoot } from './useAccordionRoot';
import { AccordionRootContext } from './AccordionRootContext';

const AccordionRoot = React.forwardRef(function AccordionRoot(
props: AccordionRoot.Props,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const { animated, disabled, defaultValue, value, className, render, ...otherProps } = props;

const { getRootProps, ...accordion } = useAccordionRoot({
animated,
disabled,
defaultValue,
value,
});

const ownerState: AccordionRoot.OwnerState = React.useMemo(
() => ({
value: accordion.value,
disabled: accordion.disabled,
// transitionStatus: accordion.transitionStatus,
}),
[accordion.value, accordion.disabled],
);

const contextValue: AccordionRoot.Context = React.useMemo(
() => ({
...accordion,
ownerState,
}),
[accordion, ownerState],
);

const { renderElement } = useComponentRenderer({
propGetter: getRootProps,
render: render ?? 'div',
className,
ownerState,
ref: forwardedRef,
extraProps: otherProps,
customStyleHookMapping: {
value: () => null,
},
});

return (
<AccordionRootContext.Provider value={contextValue}>
<FloatingList elementsRef={accordion.accordionSectionRefs}>{renderElement()}</FloatingList>
</AccordionRootContext.Provider>
);
});

export { AccordionRoot };

export namespace AccordionRoot {
export interface Context extends Omit<useAccordionRoot.ReturnValue, 'getRootProps'> {
ownerState: OwnerState;
}

export interface OwnerState {
value: useAccordionRoot.Value;
disabled: boolean;
}

export interface Props
extends useAccordionRoot.Parameters,
BaseUIComponentProps<any, OwnerState> {}
}
22 changes: 22 additions & 0 deletions packages/mui-base/src/Accordion/Root/AccordionRootContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import type { AccordionRoot } from './AccordionRoot';

/**
* @ignore - internal component.
*/
export const AccordionRootContext = React.createContext<AccordionRoot.Context | undefined>(
undefined,
);

if (process.env.NODE_ENV !== 'production') {
AccordionRootContext.displayName = 'AccordionRootContext';
}

export function useAccordionRootContext() {
const context = React.useContext(AccordionRootContext);
if (context === undefined) {
throw new Error('useAccordionRootContext must be used inside a Accordion component');
}
return context;
}
Empty file.
107 changes: 107 additions & 0 deletions packages/mui-base/src/Accordion/Root/useAccordionRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use client';
import * as React from 'react';
import { mergeReactProps } from '../../utils/mergeReactProps';
import { useControlled } from '../../utils/useControlled';

export function useAccordionRoot(
parameters: useAccordionRoot.Parameters,
): useAccordionRoot.ReturnValue {
const { animated = true, defaultValue, value: valueParam, disabled = false } = parameters;

const accordionSectionRefs = React.useRef<(HTMLElement | null)[]>([]);

const [value, setValue] = useControlled({
controlled: valueParam,
default: valueParam ?? defaultValue ?? [],
name: 'Accordion',
state: 'value',
});
// console.log(value);

const handleOpenChange = React.useCallback(
(newValue: number | string, nextOpen: boolean) => {
// console.group('useAccordionRoot handleOpenChange');
// console.log('newValue', newValue, 'nextOpen', nextOpen, 'openValues', value);
if (nextOpen) {
const nextOpenValues = value.slice();
nextOpenValues.push(newValue);
setValue(nextOpenValues);
} else {
const nextOpenValues = value.filter((v) => v !== newValue);
setValue(nextOpenValues);
}
// console.groupEnd();
},
[value],
);

const getRootProps = React.useCallback(
(externalProps = {}) =>
mergeReactProps(externalProps, {
role: 'region',
}),
[],
);

return React.useMemo(
() => ({
getRootProps,
accordionSectionRefs,
animated,
disabled,
handleOpenChange,
value,
}),
[getRootProps, accordionSectionRefs, animated, disabled, handleOpenChange, value],
);
}

export namespace useAccordionRoot {
export type Value = readonly (string | number)[];

export interface Parameters {
/**
* If `true`, the component supports CSS/JS-based animations and transitions.
* @default true
*/
animated?: boolean;
/**
* The value of the currently open `Accordion.Section`
* This is the controlled counterpart of `defaultValue`.
*/
value?: Value;
/**
* The default value representing the currently open `Accordion.Section`
* This is the uncontrolled counterpart of `value`.
* @default 0
*/
defaultValue?: Value;
/**
* Callback fired when an Accordion section is opened or closed.
* The value representing the involved section is provided as an argument.
*/
onOpenChange?: (value: Value) => void;
/**
* If `true`, the component is disabled.
* @default false
*/
disabled?: boolean;
}

export interface ReturnValue {
getRootProps: (
externalProps?: React.ComponentPropsWithRef<'div'>,
) => React.ComponentPropsWithRef<'div'>;
accordionSectionRefs: React.MutableRefObject<(HTMLElement | null)[]>;
animated: boolean;
/**
* The disabled state of the Accordion
*/
disabled: boolean;
handleOpenChange: (value: number | string, nextOpen: boolean) => void;
/**
* The open state of the Accordion
*/
value: Value;
}
}
Loading

0 comments on commit c9f7cea

Please sign in to comment.