@awsui/components-react
Version:
On July 19th, 2022, we launched [Cloudscape Design System](https://cloudscape.design). Cloudscape is an evolution of AWS-UI. It consists of user interface guidelines, front-end components, design resources, and development tools for building intuitive, en
391 lines • 22.1 kB
JavaScript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { useContainerQuery } from '@awsui/component-toolkit';
import { getAnalyticsMetadataAttribute } from '@awsui/component-toolkit/internal/analytics-metadata';
import { InternalButton } from '../button/internal';
import { useInternalI18n } from '../i18n/context';
import { getAllFocusables } from '../internal/components/focus-lock/utils';
import Tooltip from '../internal/components/tooltip';
import { SingleTabStopNavigationProvider, useSingleTabStopNavigation, } from '../internal/context/single-tab-stop-navigation-context';
import { hasModifierKeys, isPlainLeftClick } from '../internal/events';
import useHiddenDescription from '../internal/hooks/use-hidden-description';
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { KeyCode } from '../internal/keycode';
import { circleIndex } from '../internal/utils/circle-index';
import handleKey from '../internal/utils/handle-key';
import { hasHorizontalOverflow, hasInlineEndOverflow, hasInlineStartOverflow, onPaginationClick, scrollIntoView, } from './scroll-utils';
import analyticsSelectors from './analytics-metadata/styles.css.js';
import styles from './styles.css.js';
import testUtilStyles from './test-classes/styles.css.js';
const tabSelector = `.${styles['tabs-tab-link']}`;
const focusedTabSelector = `.${styles['tabs-tab-focused']}`;
const focusableTabSelector = `.${styles['tabs-tab-focusable']}`;
function dismissButton({ dismissLabel, dismissDisabled, onDismiss, tabId, }) {
return (React.createElement(InternalButton, { onClick: onDismiss, variant: "icon", iconName: "close", formAction: "none", ariaLabel: dismissLabel, disabled: dismissDisabled, className: clsx(testUtilStyles['tab-dismiss-button'], analyticsSelectors['tab-dismiss-button']), "data-testid": `awsui-tab-dismiss-button-${tabId}` }));
}
export function TabHeaderBar({ onChange, activeTabId, tabs, variant, idNamespace, ariaLabel, ariaLabelledby, i18nStrings, keyboardActivationMode, actions, }) {
const headerBarRef = useRef(null);
const activeTabHeaderRef = useRef(null);
const inlineStartOverflowButton = useRef(null);
const i18n = useInternalI18n('tabs');
const isVisualRefresh = useVisualRefresh();
const containerObjectRef = useRef(null);
const [widthChange, containerMeasureRef] = useContainerQuery(rect => rect.contentBoxWidth);
const containerRef = useMergeRefs(containerObjectRef, containerMeasureRef);
const tabRefs = useRef(new Map());
const [horizontalOverflow, setHorizontalOverflow] = useState(false);
const [inlineStartOverflow, setInlineStartOverflow] = useState(false);
const [inlineEndOverflow, setInlineEndOverflow] = useState(false);
const [focusedTabId, setFocusedTabId] = useState(activeTabId);
const [previousActiveTabId, setPreviousActiveTabId] = useState(activeTabId);
const hasActionOrDismissible = tabs.some(tab => tab.action || tab.dismissible);
const tabActionAttributes = hasActionOrDismissible
? {
role: 'application',
'aria-roledescription': i18n('i18nStrings.tabsWithActionsAriaRoleDescription', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.tabsWithActionsAriaRoleDescription),
}
: {
role: 'tablist',
};
useEffect(() => {
if (headerBarRef.current) {
setHorizontalOverflow(hasHorizontalOverflow(headerBarRef.current, inlineStartOverflowButton));
setInlineStartOverflow(hasInlineStartOverflow(headerBarRef.current));
setInlineEndOverflow(hasInlineEndOverflow(headerBarRef.current));
}
}, [widthChange, tabs]);
const scrollIntoViewIfPossible = (smooth) => {
if (!activeTabId) {
return;
}
const activeTabRef = tabRefs.current.get(activeTabId);
if (activeTabRef && headerBarRef.current) {
scrollIntoView(activeTabRef, headerBarRef.current, smooth);
}
};
useEffect(() => {
// Delay scrollIntoView as the position is depending on parent elements
// (effects are called inside-out in the component tree).
// Wait one frame to allow parents to complete it's calculation.
requestAnimationFrame(() => {
scrollIntoViewIfPossible(false);
});
// Non-smooth scrolling should not be called upon activeId change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [horizontalOverflow, widthChange, tabs.length]);
useEffect(() => {
scrollIntoViewIfPossible(true);
// Smooth scrolling should only be called upon activeId change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]);
useEffect(() => {
var _a, _b;
/*
When the selected tab changes and we are currently already focused on a tab,
move the focus to the newly selected tab.
*/
if ((_a = headerBarRef.current) === null || _a === void 0 ? void 0 : _a.contains(document.activeElement)) {
if (document.activeElement !== activeTabHeaderRef.current) {
(_b = activeTabHeaderRef.current) === null || _b === void 0 ? void 0 : _b.focus({ preventScroll: true });
}
}
}, [activeTabId]);
const onScroll = () => {
if (headerBarRef.current) {
setInlineStartOverflow(hasInlineStartOverflow(headerBarRef.current));
setInlineEndOverflow(hasInlineEndOverflow(headerBarRef.current));
}
};
const classes = clsx({
[styles['tabs-header']]: true,
[styles['tabs-header-with-divider']]: variant === 'default' || isVisualRefresh,
});
const leftButtonClasses = clsx({
[styles['pagination-button']]: true,
[styles['pagination-button-left']]: true,
[styles['pagination-button-left-scrollable']]: inlineStartOverflow,
});
const rightButtonClasses = clsx({
[styles['pagination-button']]: true,
[styles['pagination-button-right']]: true,
[styles['pagination-button-right-scrollable']]: inlineEndOverflow,
});
const navigationAPI = useRef(null);
function getNextFocusTarget() {
var _a, _b;
if (!containerObjectRef.current) {
return null;
}
const tabElements = Array.from(containerObjectRef.current.querySelectorAll(tabSelector));
return (_b = (_a = tabElements.find(tab => tab.matches(focusedTabSelector))) !== null && _a !== void 0 ? _a : tabElements.find(tab => !tab.disabled)) !== null && _b !== void 0 ? _b : null;
}
function onUnregisterActive(focusableElement) {
var _a;
const isFocusableActionOrDismissible = !focusableElement.classList.contains(styles['tabs-tab-link']);
if (!isFocusableActionOrDismissible) {
const nextFocusTarget = (_a = navigationAPI.current) === null || _a === void 0 ? void 0 : _a.getFocusTarget();
const tabLinkButton = nextFocusTarget === null || nextFocusTarget === void 0 ? void 0 : nextFocusTarget.querySelector(`.${styles['tabs-tab-link']}`);
tabLinkButton === null || tabLinkButton === void 0 ? void 0 : tabLinkButton.focus();
}
}
useEffect(() => {
var _a;
(_a = navigationAPI.current) === null || _a === void 0 ? void 0 : _a.updateFocusTarget();
});
function onFocus() {
var _a;
(_a = navigationAPI.current) === null || _a === void 0 ? void 0 : _a.updateFocusTarget();
}
function onBlur() {
var _a;
(_a = navigationAPI.current) === null || _a === void 0 ? void 0 : _a.updateFocusTarget();
}
function onKeyDown(event) {
const focusTarget = document.activeElement;
const specialKeys = [
KeyCode.right,
KeyCode.left,
KeyCode.end,
KeyCode.home,
KeyCode.pageUp,
KeyCode.pageDown,
KeyCode.space,
];
const isActionOpen = document.querySelector(`.${styles['tabs-tab-action']} [aria-expanded="true"]`);
const isDismissOrActionFocused = !(focusTarget === null || focusTarget === void 0 ? void 0 : focusTarget.classList.contains(styles['tabs-tab-link']));
if (isActionOpen) {
return;
}
if (event.key === 'Tab' && !event.shiftKey && isDismissOrActionFocused) {
event.preventDefault();
const panelId = `${idNamespace}-${activeTabId}-panel`;
const panel = document.getElementById(panelId);
panel === null || panel === void 0 ? void 0 : panel.focus();
}
if (hasModifierKeys(event) || specialKeys.indexOf(event.keyCode) === -1) {
return;
}
if (!containerObjectRef.current || !focusTarget) {
return;
}
event.preventDefault();
const focusables = getFocusablesFrom(containerObjectRef.current);
const activeIndex = document.activeElement instanceof HTMLElement ? focusables.indexOf(document.activeElement) : -1;
handleKey(event, {
onHome: () => focusElement(focusables[0]),
onEnd: () => focusElement(focusables[focusables.length - 1]),
onInlineStart: () => focusElement(focusables[circleIndex(activeIndex - 1, [0, focusables.length - 1])]),
onInlineEnd: () => focusElement(focusables[circleIndex(activeIndex + 1, [0, focusables.length - 1])]),
onPageDown: () => inlineEndOverflow && onPaginationClick(headerBarRef, 'forward'),
onPageUp: () => inlineStartOverflow && onPaginationClick(headerBarRef, 'backward'),
onActivate: () => {
var _a;
return focusedTabId &&
focusedTabId !== activeTabId &&
onChange({ activeTabId: focusedTabId, activeTabHref: (_a = tabs.find(tab => tab.id === focusedTabId)) === null || _a === void 0 ? void 0 : _a.href });
},
});
}
function focusElement(element) {
var _a, _b;
element.focus();
// If focusable element is a tab - fire the onChange for it.
const tabsById = tabs.reduce((map, tab) => map.set(tab.id, tab), new Map());
for (const [tabId, focusTargetTabTriggerElement] of tabRefs.current.entries()) {
const focusTargetTabLabelElement = focusTargetTabTriggerElement === null || focusTargetTabTriggerElement === void 0 ? void 0 : focusTargetTabTriggerElement.querySelector(`.${styles['tabs-tab-link']}`);
if (tabId !== activeTabId && focusTargetTabLabelElement === element) {
setPreviousActiveTabId(tabId);
setFocusedTabId(tabId);
if (!((_a = tabsById.get(tabId)) === null || _a === void 0 ? void 0 : _a.disabled) && keyboardActivationMode === 'automatic') {
onChange({ activeTabId: tabId, activeTabHref: (_b = tabsById.get(tabId)) === null || _b === void 0 ? void 0 : _b.href });
}
break;
}
}
}
// List all non-disabled and registered focusables: those are eligible for keyboard navigation.
function getFocusablesFrom(target) {
function isElementRegistered(element) {
var _a, _b;
return (_b = (_a = navigationAPI.current) === null || _a === void 0 ? void 0 : _a.isRegistered(element)) !== null && _b !== void 0 ? _b : false;
}
function isElementFocusable(element) {
if (element instanceof HTMLButtonElement) {
return !element.disabled || element.closest(focusableTabSelector);
}
return element.matches(focusableTabSelector);
}
return getAllFocusables(target).filter(el => isElementRegistered(el) && isElementFocusable(el));
}
const TabList = hasActionOrDismissible ? 'div' : 'ul';
return (React.createElement("div", { className: classes },
React.createElement("div", { className: styles['tab-header-scroll-container'], ref: containerRef },
horizontalOverflow && (React.createElement("span", { ref: inlineStartOverflowButton, className: leftButtonClasses },
React.createElement(InternalButton, { formAction: "none", variant: "icon", iconName: "angle-left", disabled: !inlineStartOverflow, __focusable: true, onClick: () => onPaginationClick(headerBarRef, 'backward'), ariaLabel: i18n('i18nStrings.scrollLeftAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.scrollLeftAriaLabel) }))),
React.createElement(SingleTabStopNavigationProvider, { ref: navigationAPI, navigationActive: true, getNextFocusTarget: getNextFocusTarget, onUnregisterActive: onUnregisterActive },
React.createElement(TabList, Object.assign({}, tabActionAttributes, { className: clsx(styles['tabs-header-list'], analyticsSelectors['tabs-header-list']), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ref: headerBarRef, onScroll: onScroll, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: onBlur }), tabs.map(renderTabHeader))),
horizontalOverflow && (React.createElement("span", { className: rightButtonClasses },
React.createElement(InternalButton, { formAction: "none", variant: "icon", iconName: "angle-right", disabled: !inlineEndOverflow, __focusable: true, onClick: () => onPaginationClick(headerBarRef, 'forward'), ariaLabel: i18n('i18nStrings.scrollRightAriaLabel', i18nStrings === null || i18nStrings === void 0 ? void 0 : i18nStrings.scrollRightAriaLabel) })))),
actions && React.createElement("div", { className: styles['actions-container'] }, actions)));
function renderTabHeader(tab, index) {
const { dismissible, dismissLabel, dismissDisabled, action, onDismiss } = tab;
const isActive = activeTabId === tab.id && !tab.disabled;
const clickTab = (event) => {
if (tab.disabled) {
event.preventDefault();
return;
}
// if the primary mouse button is clicked with a modifier key, the browser will handle opening a new tab
const specialKey = !isPlainLeftClick(event);
if (specialKey && tab.href) {
return;
}
event.preventDefault();
// for browsers that do not focus buttons on button click
if (!tab.href) {
const clickedTabRef = tabRefs.current.get(tab.id);
if (clickedTabRef) {
if (clickedTabRef && clickedTabRef !== document.activeElement) {
clickedTabRef.focus({ preventScroll: true });
}
}
}
if (tab.id === activeTabId) {
return;
}
setFocusedTabId(tab.id);
setPreviousActiveTabId(tab.id);
onChange({ activeTabId: tab.id, activeTabHref: tab.href });
};
const classes = clsx({
[styles['tabs-tab-link']]: true,
[styles.refresh]: isVisualRefresh,
[styles['tabs-tab-active']]: activeTabId === tab.id && !tab.disabled,
[styles['tabs-tab-focused']]: focusedTabId === tab.id,
[styles['tabs-tab-active']]: isActive,
[analyticsSelectors['active-tab-header']]: isActive,
[styles['tabs-tab-disabled']]: tab.disabled,
[styles['tabs-tab-focusable']]: !tab.disabled || (tab.disabled && !!tab.disabledReason),
});
const tabHeaderContainerClasses = clsx({
[styles['tabs-tab-header-container']]: true,
[styles.refresh]: isVisualRefresh,
[styles['tabs-tab-active']]: isActive,
[styles['tabs-tab-disabled']]: tab.disabled,
[styles['tabs-tab-focusable']]: !tab.disabled || (tab.disabled && !!tab.disabledReason),
});
const tabActionClasses = clsx({
[styles['tabs-tab-action']]: true,
[styles['tabs-tab-active']]: isActive,
});
const commonProps = {
className: classes,
'aria-controls': `${idNamespace}-${tab.id}-panel`,
'data-testid': tab.id,
id: getTabElementId({ namespace: idNamespace, tabId: tab.id }),
onClick: clickTab,
};
const tabHeaderContainerAriaProps = hasActionOrDismissible
? {
role: 'group',
'aria-labelledby': commonProps.id,
}
: {};
if (!hasActionOrDismissible) {
commonProps['aria-selected'] = activeTabId === tab.id;
commonProps.role = 'tab';
}
else {
commonProps['aria-expanded'] = activeTabId === tab.id;
}
if (tab.disabled) {
commonProps['aria-disabled'] = 'true';
}
const setElement = (tabElement) => {
if (tab.id === activeTabId) {
activeTabHeaderRef.current = tabElement;
}
tabRefs.current.set(tab.id, tabElement);
};
const handleDismiss = event => {
if (!containerObjectRef.current || !onDismiss) {
return;
}
const tabElements = getFocusablesFrom(containerObjectRef.current).filter(el => el.classList.contains(styles['tabs-tab-link']));
const activeTabIndex = tabElements.findIndex(el => el.dataset.testid === tab.id);
tabElements.splice(activeTabIndex, 1);
let nextActive;
if (previousActiveTabId && previousActiveTabId !== tab.id) {
nextActive = tabElements.find(el => el.dataset.testid === previousActiveTabId);
}
else {
nextActive = tabElements[Math.min(tabElements.length - 1, activeTabIndex)];
}
if (nextActive && nextActive.dataset.testid) {
onChange({ activeTabId: nextActive.dataset.testid });
nextActive.focus();
}
onDismiss(event);
};
const TabItem = hasActionOrDismissible ? 'div' : 'li';
const analyticsDismissMetadata = {
action: 'dismiss',
detail: {
id: tab.id,
label: `.${analyticsSelectors['tab-dismiss-button']}`,
position: `${index + 1}`,
},
};
const analyticsComponentMetadataInnerContext = {
innerContext: {
tabId: tab.id,
tabLabel: `.${analyticsSelectors['tab-label']}`,
tabPosition: `${index + 1}`,
},
};
return (React.createElement(TabItem, { ref: (element) => tabRefs.current.set(tab.id, element), className: styles['tabs-tab'], role: "presentation", key: tab.id },
React.createElement("div", Object.assign({ className: tabHeaderContainerClasses }, tabHeaderContainerAriaProps, getAnalyticsMetadataAttribute({ component: analyticsComponentMetadataInnerContext })),
React.createElement(TabTrigger, { ref: setElement, tab: tab, elementProps: commonProps, activeTabId: activeTabId, index: index }),
action && React.createElement("span", { className: tabActionClasses }, action),
dismissible && (React.createElement("span", Object.assign({ className: styles['tabs-tab-dismiss'] }, getAnalyticsMetadataAttribute(analyticsDismissMetadata)), dismissButton({ dismissLabel, dismissDisabled, onDismiss: handleDismiss, tabId: tab.id }))))));
}
}
const TabTrigger = forwardRef(({ tab, elementProps, activeTabId, index }, ref) => {
const refObject = useRef(null);
const tabLabelRefObject = useRef(null);
const mergedRef = useMergeRefs(refObject, ref);
const { tabIndex } = useSingleTabStopNavigation(refObject);
const isDisabledWithReason = tab.disabled && !!tab.disabledReason;
const [showTooltip, setShowTooltip] = useState(false);
const { targetProps, descriptionEl } = useHiddenDescription(tab.disabledReason);
const children = (React.createElement(React.Fragment, null,
React.createElement("span", { className: clsx(styles['tabs-tab-label'], analyticsSelectors['tab-label']), ref: tabLabelRefObject },
React.createElement("span", null, tab.label)),
isDisabledWithReason && (React.createElement(React.Fragment, null,
descriptionEl,
showTooltip && (React.createElement(Tooltip, { className: styles['disabled-reason-tooltip'], trackRef: tabLabelRefObject, value: tab.disabledReason, onDismiss: () => setShowTooltip(false) }))))));
const handlers = {
onFocus: () => setShowTooltip(true),
onBlur: () => setShowTooltip(false),
onMouseEnter: () => setShowTooltip(true),
onMouseLeave: () => setShowTooltip(false),
};
const analyticsSelectMetadata = {
action: 'select',
detail: {
id: tab.id,
label: `.${analyticsSelectors['tab-label']}`,
position: `${index + 1}`,
originTabId: activeTabId || '',
},
};
const commonProps = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, elementProps), (isDisabledWithReason ? targetProps : {})), (isDisabledWithReason ? handlers : {})), { ref: mergedRef, tabIndex: tabIndex }), (tab.disabled || tab.id === activeTabId ? {} : getAnalyticsMetadataAttribute(analyticsSelectMetadata)));
return tab.href ? (React.createElement("a", Object.assign({}, commonProps, { href: tab.href }), children)) : (React.createElement("button", Object.assign({}, commonProps, { type: "button", disabled: tab.disabled && !isDisabledWithReason }), children));
});
export function getTabElementId({ namespace, tabId }) {
return namespace + '-' + tabId;
}
//# sourceMappingURL=tab-header-bar.js.map