@bianic-ui/tabs
Version:
Accessible Tabs component for React and Bianic UI
392 lines (352 loc) • 11 kB
JavaScript
function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
import { useClickable } from "@bianic-ui/clickable";
import { useDescendant, useDescendants } from "@bianic-ui/descendant";
import { useControllableState, useId, useSafeLayoutEffect } from "@bianic-ui/hooks";
import { callAllHandlers, createOnKeyDown, getValidChildren, isUndefined, mergeRefs, createContext } from "@bianic-ui/utils";
import { cloneElement, useState, useRef, useEffect } from "react";
/**
* Tabs hooks that provides all the states, and accessibility
* helpers to keep all things working properly.
*
* It's returned object will be passed unto a Context Provider
* so all child components can read from it.
*
* @see Docs https://bianic-ui.com/components/useTabs
*/
export function useTabs(props) {
var {
defaultIndex,
onChange,
index,
isManual,
isLazy,
orientation = "horizontal"
} = props,
htmlProps = _objectWithoutPropertiesLoose(props, ["defaultIndex", "onChange", "index", "isManual", "isLazy", "orientation"]);
/**
* We use this to keep track of the index of the focused tab.
*
* Tabs can be automatically activated, this means selection follows focus.
* When we navigate with the arrow keys, we move focus and selection to next/prev tab
*
* Tabs can also be manually activated, this means selection does not follow focus.
* When we navigate with the arrow keys, we only move focus NOT selection. The user
* will need not manually activate the tab using `Enter` or `Space`.
*
* This is why we need to keep track of the `focusedIndex` and `selectedIndex`
*/
var [focusedIndex, setFocusedIndex] = useState(defaultIndex != null ? defaultIndex : 0);
var [selectedIndex, setSelectedIndex] = useControllableState({
defaultValue: defaultIndex != null ? defaultIndex : 0,
value: index,
onChange,
shouldUpdate: (prevIndex, nextIndex) => prevIndex !== nextIndex,
propsMap: {
value: "index",
defaultValue: "defaultIndex"
}
});
/**
* Sync focused `index` with controlled `selectedIndex` (which is the `props.index`)
*/
useEffect(() => {
if (!isUndefined(index)) {
setFocusedIndex(index);
}
}, [index]);
/**
* Think of `useDescendants` as a register for the tab nodes.
*
* This manager is used to store only the tab nodes that are not disabled, and focusable.
* If we have the following code
*
* ```jsx
* <Tab>Tab 1</Tab>
* <Tab isDisabled>Tab 2</Tab>
* <Tab>Tab 3</Tab>
* ```
*
* The manager will only hold references to "Tab 1" and "Tab 3", since `Tab 2` is disabled
*/
var enabledDomContext = useDescendants();
/**
* This manager is used to store all tab nodes whether disabled or not.
* If we have the following code
*
* ```jsx
* <Tab>Tab 1</Tab>
* <Tab isDisabled>Tab 2</Tab>
* <Tab>Tab 3</Tab>
* ```
*
* The manager will only hold references to "Tab 1", "Tab 2" "Tab 3".
*
* We need this for correct indexing of tabs in event a tab is disabled
*/
var domContext = useDescendants();
/**
* generate a unique id or use user-provided id for
* the tabs widget
*/
var id = useId(props.id, "tabs");
return {
id,
selectedIndex,
focusedIndex,
setSelectedIndex,
setFocusedIndex,
isManual,
isLazy,
orientation,
enabledDomContext,
domContext,
htmlProps
};
}
var [TabsProvider, useTabsContext] = createContext({
name: "TabsContext",
errorMessage: "useTabsContext: `context` is undefined. Seems you forgot to wrap all tabs components within <Tabs />"
});
export { TabsProvider };
/**
* Tabs hook to manage multiple tab buttons,
* and ensures only one tab is selected per time.
*
* @param props props object for the tablist
*/
export function useTabList(props) {
var {
setFocusedIndex,
focusedIndex,
orientation,
enabledDomContext
} = useTabsContext();
var count = enabledDomContext.descendants.length;
/**
* Function to update the selected tab index
*/
var setIndex = index => {
var tab = enabledDomContext.descendants[index];
if (tab == null ? void 0 : tab.element) {
tab.element.focus();
setFocusedIndex(index);
}
}; // Helper functions for keyboard navigation
var nextTab = () => {
var nextIndex = (focusedIndex + 1) % count;
setIndex(nextIndex);
};
var prevTab = () => {
var prevIndex = (focusedIndex - 1 + count) % count;
setIndex(prevIndex);
};
var firstTab = () => setIndex(0);
var lastTab = () => setIndex(count - 1);
var isHorizontal = orientation === "horizontal";
var isVertical = orientation === "vertical";
var onKeyDown = createOnKeyDown({
keyMap: {
ArrowRight: () => isHorizontal && nextTab(),
ArrowLeft: () => isHorizontal && prevTab(),
ArrowDown: () => isVertical && nextTab(),
ArrowUp: () => isVertical && prevTab(),
Home: () => firstTab(),
End: () => lastTab()
}
});
return _extends({}, props, {
role: "tablist",
"aria-orientation": orientation,
onKeyDown: callAllHandlers(props.onKeyDown, onKeyDown)
});
}
/**
* Tabs hook to manage each tab button.
*
* A tab can be disabled and focusable, or both,
* hence the use of `useClickable` to handle this scenario
*/
export function useTab(props) {
var {
isDisabled,
isFocusable
} = props,
htmlProps = _objectWithoutPropertiesLoose(props, ["isDisabled", "isFocusable"]);
var {
setSelectedIndex,
isManual,
id,
setFocusedIndex,
enabledDomContext,
domContext,
selectedIndex
} = useTabsContext();
var ref = useRef(null);
/**
* Think of `useDescendant` as the function that registers tab node
* to the `enabledDomContext`, and returns it's index.
*
* Tab is registered if it's enabled or focusable
*/
var enabledIndex = useDescendant({
disabled: Boolean(isDisabled),
focusable: Boolean(isFocusable),
context: enabledDomContext,
element: ref.current
});
/**
* Registers all tabs (whether disabled or not)
*/
var index = useDescendant({
context: domContext,
element: ref.current
});
var isSelected = index === selectedIndex;
var onClick = () => {
setFocusedIndex(enabledIndex);
setSelectedIndex(index);
};
var onFocus = () => {
var isDisabledButFocusable = isDisabled && isFocusable;
var shouldSelect = !isManual && !isDisabledButFocusable;
if (shouldSelect) {
setSelectedIndex(index);
}
};
var clickable = useClickable(_extends({}, htmlProps, {
ref: mergeRefs(ref, props.ref),
isDisabled,
isFocusable,
onClick: callAllHandlers(props.onClick, onClick)
}));
var type = "button";
return _extends({}, clickable, {
id: makeTabId(id, index),
role: "tab",
tabIndex: isSelected ? 0 : -1,
type,
"aria-selected": isSelected ? true : undefined,
"aria-controls": makeTabPanelId(id, index),
onFocus: isDisabled ? undefined : callAllHandlers(props.onFocus, onFocus)
});
}
/**
* Tabs hook for managing the visibility of multiple tab panels.
*
* Since only one panel can be show at a time, we use `cloneElement`
* to inject `selected` panel to each TabPanel.
*
* It returns a cloned version of it's children with
* all functionality included.
*/
export function useTabPanels(props) {
var context = useTabsContext();
var {
id,
selectedIndex
} = context;
var validChildren = getValidChildren(props.children);
var children = validChildren.map((child, index) => /*#__PURE__*/cloneElement(child, {
isSelected: index === selectedIndex,
id: makeTabPanelId(id, index)
}));
return _extends({}, props, {
children
});
}
/**
* Tabs hook for managing the visible/hidden states
* of the tab panel.
*
* @param props props object for the tab panel
*/
export function useTabPanel(props) {
var {
isSelected,
id
} = props,
htmlProps = _objectWithoutPropertiesLoose(props, ["isSelected", "id"]);
var {
isLazy
} = useTabsContext();
return _extends({}, htmlProps, {
children: !isLazy || isSelected ? props.children : null,
role: "tabpanel",
hidden: !isSelected,
id
});
}
/**
* Tabs hook to show an animated indicators that
* follows the active tab.
*
* The way we do it is by measuring the DOM Rect (or dimensions)
* of the active tab, and return that as CSS style for
* the indicator.
*/
export function useTabIndicator() {
var context = useTabsContext();
var {
selectedIndex,
orientation,
domContext
} = context;
var isHorizontal = orientation === "horizontal";
var isVertical = orientation === "vertical"; // Get the clientRect of the selected tab
var [rect, setRect] = useState(() => {
if (isHorizontal) return {
left: 0,
width: 0
};
if (isVertical) return {
top: 0,
height: 0
};
});
var [hasMeasured, setHasMeasured] = useState(false); // Update the selected tab rect when the selectedIndex changes
useSafeLayoutEffect(() => {
var _tab$element;
if (isUndefined(selectedIndex)) return;
var tab = domContext.descendants[selectedIndex];
var tabRect = tab == null ? void 0 : (_tab$element = tab.element) == null ? void 0 : _tab$element.getBoundingClientRect(); // Horizontal Tab: Calculate width and left distance
if (isHorizontal && tabRect) {
var {
left,
width
} = tabRect;
setRect({
left,
width
});
} // Vertical Tab: Calculate height and top distance
if (isVertical && tabRect) {
var {
top,
height
} = tabRect;
setRect({
top,
height
});
} // Prevent unwanted transition from 0 to measured rect
// by setting the measured state in the next tick
var frameId = requestAnimationFrame(() => {
setHasMeasured(true);
});
return () => {
cancelAnimationFrame(frameId);
};
}, [selectedIndex, isHorizontal, isVertical, domContext.descendants]);
return _extends({
position: "absolute",
transition: hasMeasured ? "all 200ms cubic-bezier(0, 0, 0.2, 1)" : "none"
}, rect);
}
function makeTabId(id, index) {
return id + "--tab-" + index;
}
function makeTabPanelId(id, index) {
return id + "--tabpanel-" + index;
}
//# sourceMappingURL=use-tabs.js.map