@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
170 lines • 9.48 kB
JavaScript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useContainerQuery } from '@awsui/component-toolkit';
import { useMergeRefs } from '@awsui/component-toolkit/internal';
import { useContainerBreakpoints } from '../internal/hooks/container-queries';
import styles from './styles.css.js';
// A small buffer to make calculations more lenient against browser lag or padding adjustments.
const RESPONSIVENESS_BUFFER = 20;
export function useTopNavigation({ identity, search, utilities }) {
// Refs and breakpoints
const mainRef = useRef(null);
const virtualRef = useRef(null);
const [breakpoint, breakpointRef] = useContainerBreakpoints(['xxs', 's']);
// Responsiveness state
// The component works by calculating the possible resize states that it can
// be in, and having a state variable to track which state we're currently in.
const hasSearch = !!search;
const hasTitleWithLogo = identity && !!identity.logo && !!identity.title;
const responsiveStates = useMemo(() => {
return generateResponsiveStateKeys(utilities, hasSearch, hasTitleWithLogo);
}, [utilities, hasSearch, hasTitleWithLogo]);
// To hide/show elements dynamically, we need to know how much space they take up,
// even if they're not being rendered. The top navigation elements are hidden/resized
// based on the available size or if a search bar is open, and they need to be available
// for calculations so we know where to toggle them. So we render a second, more stable
// top-nav off screen to do these calculations against.
//
// We can't "affix" these values to pixels because they can depend on spacing tokens.
// It's easier to render all of these utilities separately rather than figuring out
// spacing token values, icon sizes, text widths, etc.
const [responsiveState, setResponsiveState] = useState();
const recalculateFit = useCallback(() => {
var _a, _b, _c, _d;
if (!(mainRef === null || mainRef === void 0 ? void 0 : mainRef.current) || !virtualRef.current) {
setResponsiveState(responsiveStates[0]);
return;
}
// Get available width from the visible top navigation.
const availableWidth = getContentBoxWidth(mainRef.current.querySelector(`.${styles['padding-box']}`));
if (availableWidth === 0) {
// Likely in an SSR or Jest situation.
setResponsiveState(responsiveStates[0]);
return;
}
const sizeConfiguration = {
hasSearch,
availableWidth,
// Get widths from the hidden top navigation
fullIdentityWidth: virtualRef.current.querySelector(`.${styles.identity}`).getBoundingClientRect().width,
titleWidth: (_b = (_a = virtualRef.current.querySelector(`.${styles.title}`)) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect().width) !== null && _b !== void 0 ? _b : 0,
searchSlotWidth: (_d = (_c = virtualRef.current.querySelector(`.${styles.search}`)) === null || _c === void 0 ? void 0 : _c.getBoundingClientRect().width) !== null && _d !== void 0 ? _d : 0,
searchUtilityWidth: virtualRef.current.querySelector('[data-utility-special="search"]').getBoundingClientRect()
.width,
utilitiesLeftPadding: parseFloat(getComputedStyle(virtualRef.current.querySelector(`.${styles.utilities}`)).paddingLeft || '0px'),
utilityWithLabelWidths: Array.prototype.slice
.call(virtualRef.current.querySelectorAll(`[data-utility-hide="false"]`))
.map((element) => element.getBoundingClientRect().width),
utilityWithoutLabelWidths: Array.prototype.slice
.call(virtualRef.current.querySelectorAll(`[data-utility-hide="true"]`))
.map((element) => element.getBoundingClientRect().width),
menuTriggerUtilityWidth: virtualRef.current
.querySelector('[data-utility-special="menu-trigger"]')
.getBoundingClientRect().width,
};
setResponsiveState(determineBestResponsiveState(responsiveStates, sizeConfiguration));
}, [responsiveStates, hasSearch]);
const [, containerQueryRef] = useContainerQuery(() => {
recalculateFit();
}, [recalculateFit]);
// Due to being rendered in a portal, the virtual navigation isn't rendered
// at the same time as the main one.
const onVirtualMount = useCallback((element) => {
virtualRef.current = element;
recalculateFit();
}, [recalculateFit]);
// Search slot expansion on small screens
const [isSearchMinimized, setSearchMinimized] = useState(true);
const isSearchExpanded = !isSearchMinimized && hasSearch && (responsiveState === null || responsiveState === void 0 ? void 0 : responsiveState.hideSearch);
// If the search was expanded, and then the screen resized so that the
// expansion is no longer necessary. So we implicitly minimize it.
useEffect(() => {
if (!(responsiveState === null || responsiveState === void 0 ? void 0 : responsiveState.hideSearch)) {
setSearchMinimized(true);
}
}, [responsiveState]);
// If the search is expanded after clicking on the search utility, move
// the focus to the input. Since this is a user-controlled slot, we're just
// assuming that it contains an input, though it's a pretty safe guess.
useEffect(() => {
var _a, _b;
if (isSearchExpanded) {
(_b = (_a = mainRef === null || mainRef === void 0 ? void 0 : mainRef.current) === null || _a === void 0 ? void 0 : _a.querySelector(`.${styles.search} input`)) === null || _b === void 0 ? void 0 : _b.focus();
}
}, [isSearchExpanded, mainRef]);
const mergedMainRef = useMergeRefs(mainRef, containerQueryRef, breakpointRef);
return {
mainRef: mergedMainRef,
virtualRef: onVirtualMount,
responsiveState: responsiveState !== null && responsiveState !== void 0 ? responsiveState : responsiveStates[0],
breakpoint: breakpoint !== null && breakpoint !== void 0 ? breakpoint : 'default',
isSearchExpanded: !!isSearchExpanded,
onSearchUtilityClick: () => setSearchMinimized(isSearchMinimized => !isSearchMinimized),
};
}
/**
* Get the width of the content box (assuming the element's box-sizing is border-box).
*/
function getContentBoxWidth(element) {
const style = getComputedStyle(element);
return (parseFloat(style.width || '0px') - parseFloat(style.paddingLeft || '0px') - parseFloat(style.paddingRight || '0px'));
}
/**
* Generates the series of responsive steps that can be performed on the header in order.
*/
export function generateResponsiveStateKeys(utilities, canHideSearch, canHideTitle) {
const states = [{}];
if (utilities.some(utility => utility.text)) {
states.push({ hideUtilityText: true });
}
if (canHideSearch) {
states.push({
hideUtilityText: true,
hideSearch: true,
});
}
const hiddenUtilties = [];
for (let i = 0; i < utilities.length; i++) {
if (!utilities[i].disableUtilityCollapse) {
hiddenUtilties.push(i);
states.push({
hideUtilityText: true,
hideSearch: canHideSearch || undefined,
hideUtilities: hiddenUtilties.length > 0 ? hiddenUtilties.slice() : undefined,
});
}
}
if (canHideTitle) {
states.push({
hideUtilityText: true,
hideSearch: canHideSearch || undefined,
hideUtilities: hiddenUtilties.length > 0 ? hiddenUtilties.slice() : undefined,
hideTitle: true,
});
}
return states;
}
/**
* Determines the best responsive state configuration of the top navigation, based on the given list of possible responsive states
* and the current sizes of all elements inside the navigation bar.
*/
export function determineBestResponsiveState(possibleStates, sizes) {
const { hasSearch, availableWidth, utilitiesLeftPadding, fullIdentityWidth, titleWidth, searchSlotWidth, searchUtilityWidth, utilityWithLabelWidths, utilityWithoutLabelWidths, menuTriggerUtilityWidth, } = sizes;
// Iterate through each state and calculate its expected required width.
for (const state of possibleStates) {
const searchWidth = hasSearch ? (state.hideSearch ? searchUtilityWidth : searchSlotWidth) : 0;
const utilitiesWidth = (state.hideUtilityText ? utilityWithoutLabelWidths : utilityWithLabelWidths)
.filter((_width, i) => !state.hideUtilities || state.hideUtilities.indexOf(i) === -1)
.reduce((sum, width) => sum + width, 0);
const menuTriggerWidth = state.hideUtilities ? menuTriggerUtilityWidth : 0;
const identityWidth = state.hideTitle ? fullIdentityWidth - titleWidth : fullIdentityWidth;
const expectedInnerWidth = identityWidth + searchWidth + utilitiesLeftPadding + utilitiesWidth + menuTriggerWidth;
if (expectedInnerWidth <= availableWidth - RESPONSIVENESS_BUFFER) {
return state;
}
}
// If nothing matches, pick the smallest possible state.
return possibleStates[possibleStates.length - 1];
}
//# sourceMappingURL=use-top-navigation.js.map