Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[tabs] Modernize implementation #751

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/data/api/tabs-root.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"props": {
"className": { "type": { "name": "union", "description": "func<br>&#124;&nbsp;string" } },
"defaultValue": { "type": { "name": "any" } },
"defaultValue": { "type": { "name": "any" }, "default": "0" },
"direction": {
"type": { "name": "enum", "description": "'ltr'<br>&#124;&nbsp;'rtl'" },
"default": "'ltr'"
Expand Down
2 changes: 1 addition & 1 deletion docs/data/translations/api-docs/tab/tab.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"render": { "description": "A function to customize rendering of the component." },
"value": {
"description": "You can provide your own value. Otherwise, it falls back to the child position index."
"description": "The value of the Tab. When not specified, the value is the child position index."
}
},
"classDescriptions": {}
Expand Down
4 changes: 2 additions & 2 deletions docs/data/translations/api-docs/tabs-root/tabs-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
"description": "Class names applied to the element or a function that returns them based on the component&#39;s state."
},
"defaultValue": {
"description": "The default value. Use when the component is not controlled."
"description": "The default value. Use when the component is not controlled. When the value is <code>null</code>, no Tab will be selected."
},
"direction": { "description": "The direction of the text." },
"onValueChange": { "description": "Callback invoked when new value is being set." },
"orientation": { "description": "The component orientation (layout flow direction)." },
"render": { "description": "A function to customize rendering of the component." },
"value": {
"description": "The value of the currently selected <code>Tab</code>. If you don&#39;t want any selected <code>Tab</code>, you can set this prop to <code>null</code>."
"description": "The value of the currently selected <code>Tab</code>. Use when the component is controlled. When the value is <code>null</code>, no Tab will be selected."
}
},
"classDescriptions": {}
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/generated/tab.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"value": {
"type": "any",
"description": "You can provide your own value. Otherwise, it falls back to the child position index."
"description": "The value of the Tab.\nWhen not specified, the value is the child position index."
}
}
}
5 changes: 3 additions & 2 deletions docs/reference/generated/tabs-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
},
"defaultValue": {
"type": "any",
"description": "The default value. Use when the component is not controlled."
"default": "0",
"description": "The default value. Use when the component is not controlled.\nWhen the value is `null`, no Tab will be selected."
},
"direction": {
"type": "'ltr' | 'rtl'",
Expand All @@ -30,7 +31,7 @@
},
"value": {
"type": "any",
"description": "The value of the currently selected `Tab`.\nIf you don't want any selected `Tab`, you can set this prop to `null`."
"description": "The value of the currently selected `Tab`. Use when the component is controlled.\nWhen the value is `null`, no Tab will be selected."
}
}
}
41 changes: 26 additions & 15 deletions packages/react/src/Composite/Item/CompositeItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,26 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer';
import { useForkRef } from '../../utils/useForkRef';
import { useCompositeRootContext } from '../Root/CompositeRootContext';
import { useCompositeItem } from './useCompositeItem';
import { refType } from '../../utils/proptypes';
import type { BaseUIComponentProps } from '../../utils/types';

/**
* @ignore - internal component.
*/
const CompositeItem = React.forwardRef(function CompositeItem(
props: CompositeItem.Props,
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const { render, className, ...otherProps } = props;
function CompositeItem<Metadata>(props: CompositeItem.Props<Metadata>) {
const { render, className, itemRef, metadata, ...otherProps } = props;

const { activeIndex } = useCompositeRootContext();
const { getItemProps, ref, index } = useCompositeItem();
const { highlightedIndex } = useCompositeRootContext();
const { getItemProps, ref, index } = useCompositeItem({ metadata });

const ownerState: CompositeItem.OwnerState = React.useMemo(
() => ({
active: index === activeIndex,
highlighted: index === highlightedIndex,
}),
[index, activeIndex],
[index, highlightedIndex],
);

const mergedRef = useForkRef(forwardedRef, ref);
const mergedRef = useForkRef(itemRef, ref);

const { renderElement } = useComponentRenderer({
propGetter: getItemProps,
Expand All @@ -38,16 +36,23 @@ const CompositeItem = React.forwardRef(function CompositeItem(
});

return renderElement();
});
}

namespace CompositeItem {
export interface OwnerState {
active: boolean;
highlighted: boolean;
}

export interface Props extends BaseUIComponentProps<'div', OwnerState> {}
export interface Props<Metadata>
extends Omit<BaseUIComponentProps<'div', OwnerState>, 'itemRef'> {
// the itemRef name collides with https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/itemref
itemRef?: React.RefObject<HTMLElement | null>;
metadata?: Metadata;
}
}

export { CompositeItem };

CompositeItem.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
Expand All @@ -61,10 +66,16 @@ CompositeItem.propTypes /* remove-proptypes */ = {
* 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]),
/**
* @ignore
*/
itemRef: refType,
/**
* @ignore
*/
metadata: PropTypes.any,
/**
* A function to customize rendering of the component.
*/
render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
} as any;

export { CompositeItem };
18 changes: 11 additions & 7 deletions packages/react/src/Composite/Item/useCompositeItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,30 @@ import { useCompositeRootContext } from '../Root/CompositeRootContext';
import { useCompositeListItem } from '../List/useCompositeListItem';
import { mergeReactProps } from '../../utils/mergeReactProps';

export interface UseCompositeItemParameters<Metadata> {
metadata?: Metadata;
}

/**
*
* API:
*
* - [useCompositeItem API](https://mui.com/base-ui/api/use-composite-item/)
*/
export function useCompositeItem() {
const { activeIndex, onActiveIndexChange } = useCompositeRootContext();
const { ref, index } = useCompositeListItem();
const isActive = activeIndex === index;
export function useCompositeItem<Metadata>(params: UseCompositeItemParameters<Metadata> = {}) {
const { highlightedIndex, onHighlightedIndexChange } = useCompositeRootContext();
const { ref, index } = useCompositeListItem(params);
const isHighlighted = highlightedIndex === index;

const getItemProps = React.useCallback(
(externalProps = {}) =>
mergeReactProps<'div'>(externalProps, {
tabIndex: isActive ? 0 : -1,
tabIndex: isHighlighted ? 0 : -1,
onFocus() {
onActiveIndexChange(index);
onHighlightedIndexChange(index);
},
}),
[isActive, index, onActiveIndexChange],
[isHighlighted, index, onHighlightedIndexChange],
);

return React.useMemo(
Expand Down
48 changes: 34 additions & 14 deletions packages/react/src/Composite/List/CompositeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { fastObjectShallowCompare } from '../../utils/fastObjectShallowCompare';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
import { CompositeListContext } from './CompositeListContext';

Expand All @@ -22,12 +23,22 @@ function sortByDocumentPosition(a: Node, b: Node) {
return 0;
}

function areMapsEqual(map1: Map<Node, number | null>, map2: Map<Node, number | null>) {
export type CompositeMetadata<CustomMetadata> = { index?: number | null } & CustomMetadata;

function areMapsEqual<Metadata>(
map1: Map<Node, CompositeMetadata<Metadata> | null>,
map2: Map<Node, CompositeMetadata<Metadata> | null>,
) {
if (map1.size !== map2.size) {
return false;
}
for (const [key, value] of map1.entries()) {
if (value !== map2.get(key)) {
const value2 = map2.get(key);
// compare the index before comparing everything else
if (value?.index !== value2?.index) {
return false;
}
if (value2 !== undefined && !fastObjectShallowCompare(value, value2)) {
return false;
}
}
Expand All @@ -38,13 +49,13 @@ function areMapsEqual(map1: Map<Node, number | null>, map2: Map<Node, number | n
* Provides context for a list of items in a composite component.
* @ignore - internal component.
*/
function CompositeList(props: CompositeList.Props) {
const { children, elementsRef, labelsRef } = props;
function CompositeList<Metadata>(props: CompositeList.Props<Metadata>) {
const { children, elementsRef, labelsRef, onMapChange } = props;

const [map, setMap] = React.useState(() => new Map<Node, number | null>());
const [map, setMap] = React.useState(() => new Map<Node, CompositeMetadata<Metadata> | null>());

const register = React.useCallback((node: Node) => {
setMap((prevMap) => new Map(prevMap).set(node, null));
const register = React.useCallback((node: Node, metadata: Metadata) => {
setMap((prevMap) => new Map(prevMap).set(node, metadata ?? null));
}, []);

const unregister = React.useCallback((node: Node) => {
Expand All @@ -57,16 +68,20 @@ function CompositeList(props: CompositeList.Props) {

useEnhancedEffect(() => {
const newMap = new Map(map);

const nodes = Array.from(newMap.keys()).sort(sortByDocumentPosition);

nodes.forEach((node, index) => {
newMap.set(node, index);
const metadata = map.get(node) ?? ({} as CompositeMetadata<Metadata>);

newMap.set(node, { ...metadata, index });
});

if (!areMapsEqual(map, newMap)) {
setMap(newMap);
onMapChange?.(newMap);
}
}, [map]);
}, [map, onMapChange]);

const contextValue = React.useMemo(
() => ({ register, unregister, map, elementsRef, labelsRef }),
Expand All @@ -79,21 +94,24 @@ function CompositeList(props: CompositeList.Props) {
}

namespace CompositeList {
export interface Props {
export interface Props<Metadata> {
children: React.ReactNode;
/**
* A ref to the list of HTML elements, ordered by their index.
* `useListNavigation`'s `listRef` prop.
*/
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
elementsRef: React.RefObject<Array<HTMLElement | null>>;
/**
* A ref to the list of element labels, ordered by their index.
* `useTypeahead`'s `listRef` prop.
*/
labelsRef?: React.MutableRefObject<Array<string | null>>;
labelsRef?: React.RefObject<Array<string | null>>;
onMapChange?: (newMap: Map<Node, CompositeMetadata<Metadata> | null>) => void;
}
}

export { CompositeList };

CompositeList.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
Expand All @@ -115,6 +133,8 @@ CompositeList.propTypes /* remove-proptypes */ = {
labelsRef: PropTypes.shape({
current: PropTypes.arrayOf(PropTypes.string).isRequired,
}),
/**
* @ignore
*/
onMapChange: PropTypes.func,
} as any;

export { CompositeList };
12 changes: 6 additions & 6 deletions packages/react/src/Composite/List/CompositeListContext.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
'use client';
import * as React from 'react';

export interface CompositeListContextValue {
register: (node: Node) => void;
export interface CompositeListContextValue<Metadata> {
register: (node: Node, metadata: Metadata) => void;
unregister: (node: Node) => void;
map: Map<Node, number | null>;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef?: React.MutableRefObject<Array<string | null>>;
map: Map<Node, Metadata | null>;
elementsRef: React.RefObject<Array<HTMLElement | null>>;
labelsRef?: React.RefObject<Array<string | null>>;
}

export const CompositeListContext = React.createContext<CompositeListContextValue>({
export const CompositeListContext = React.createContext<CompositeListContextValue<any>>({
register: () => {},
unregister: () => {},
map: new Map(),
Expand Down
16 changes: 9 additions & 7 deletions packages/react/src/Composite/List/useCompositeListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import * as React from 'react';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
import { useCompositeListContext } from './CompositeListContext';

export interface UseCompositeListItemParameters {
export interface UseCompositeListItemParameters<Metadata> {
label?: string | null;
metadata?: Metadata;
}

interface UseCompositeListItemReturnValue {
Expand All @@ -20,10 +21,10 @@ interface UseCompositeListItemReturnValue {
*
* - [useCompositeListItem API](https://mui.com/base-ui/api/use-composite-list-item/)
*/
export function useCompositeListItem(
params: UseCompositeListItemParameters = {},
export function useCompositeListItem<Metadata>(
params: UseCompositeListItemParameters<Metadata> = {},
): UseCompositeListItemReturnValue {
const { label } = params;
const { label, metadata } = params;

const { register, unregister, map, elementsRef, labelsRef } = useCompositeListContext();

Expand All @@ -49,16 +50,17 @@ export function useCompositeListItem(
useEnhancedEffect(() => {
const node = componentRef.current;
if (node) {
register(node);
register(node, metadata);
return () => {
unregister(node);
};
}
return undefined;
}, [register, unregister]);
}, [register, unregister, metadata]);

useEnhancedEffect(() => {
const i = componentRef.current ? map.get(componentRef.current) : null;
const i = componentRef.current ? map.get(componentRef.current)?.index : null;

if (i != null) {
setIndex(i);
}
Expand Down
Loading