UNPKG

@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
// 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