Skip to content

Commit

Permalink
[tabs] Modernize implementation (#751)
Browse files Browse the repository at this point in the history
  • Loading branch information
mj12albert authored Nov 25, 2024
1 parent 345ce7a commit 0abbedb
Show file tree
Hide file tree
Showing 52 changed files with 1,230 additions and 4,614 deletions.
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

0 comments on commit 0abbedb

Please sign in to comment.