diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index f41cc69220fe..848f7d0018f5 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -8767,9 +8767,18 @@ Map { ], "type": "oneOf", }, + "leftOverflowButtonProps": Object { + "type": "object", + }, "light": Object { "type": "bool", }, + "rightOverflowButtonProps": Object { + "type": "object", + }, + "scrollDebounceWait": Object { + "type": "number", + }, "scrollIntoView": Object { "type": "bool", }, diff --git a/packages/react/src/components/Tabs/Tabs.js b/packages/react/src/components/Tabs/Tabs.js index 6c6137f0c7f4..0ee9570acb4c 100644 --- a/packages/react/src/components/Tabs/Tabs.js +++ b/packages/react/src/components/Tabs/Tabs.js @@ -390,7 +390,7 @@ export default class Tabs extends React.Component { }; handleOverflowNavMouseDown = (event, { direction }) => { - // disregard mouse buttons aside from LMB + // disregard mouse buttons aside from left mouse button if (event.buttons !== 1) { return; } diff --git a/packages/react/src/components/Tabs/next/Tabs-test.js b/packages/react/src/components/Tabs/next/Tabs-test.js index 83566e243f22..2ffaf140517a 100644 --- a/packages/react/src/components/Tabs/next/Tabs-test.js +++ b/packages/react/src/components/Tabs/next/Tabs-test.js @@ -27,9 +27,12 @@ describe('Tabs', () => { }); it('should set a className from props on outermost element in TabList', () => { - render( + const { container } = render( - + Tab Label 1 Tab Label 2 Tab Label 3 @@ -42,7 +45,7 @@ describe('Tabs', () => { ); - expect(screen.getByRole('tablist')).toHaveClass('custom-class'); + expect(container.firstChild).toHaveClass('custom-class'); }); }); diff --git a/packages/react/src/components/Tabs/next/Tabs.js b/packages/react/src/components/Tabs/next/Tabs.js index 11175ce1eac6..752286d2faff 100644 --- a/packages/react/src/components/Tabs/next/Tabs.js +++ b/packages/react/src/components/Tabs/next/Tabs.js @@ -5,16 +5,21 @@ * LICENSE file in the root directory of this source tree. */ -import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect } from 'react'; +import { ChevronLeft16, ChevronRight16 } from '@carbon/icons-react'; import cx from 'classnames'; +import debounce from 'lodash.debounce'; +import PropTypes from 'prop-types'; +import React, { useCallback, useState, useRef, useEffect } from 'react'; import { Tooltip } from '../../Tooltip/next'; -import { keys, match, matches } from '../../../internal/keyboard'; -import { usePrefix } from '../../../internal/usePrefix'; -import { useId } from '../../../internal/useId'; -import { getInteractiveContent } from '../../../internal/useNoInteractiveChildren'; import { useControllableState } from '../../../internal/useControllableState'; +import { useEffectOnce } from '../../../internal/useEffectOnce'; +import { useId } from '../../../internal/useId'; +import useIsomorphicEffect from '../../../internal/useIsomorphicEffect'; import { useMergedRefs } from '../../../internal/useMergedRefs'; +import { getInteractiveContent } from '../../../internal/useNoInteractiveChildren'; +import { usePrefix } from '../../../internal/usePrefix'; +import { keys, match, matches } from '../../../internal/keyboard'; +import { usePressable } from './usePressable'; // Used to manage the overall state of the Tabs const TabsContext = React.createContext(); @@ -82,22 +87,6 @@ Tabs.propTypes = { selectedIndex: PropTypes.number, }; -function useEffectOnce(callback) { - const savedCallback = useRef(callback); - const effectGuard = useRef(false); - - useEffect(() => { - savedCallback.current = callback; - }); - - useEffect(() => { - if (effectGuard.current !== true) { - effectGuard.current = true; - savedCallback.current(); - } - }, []); -} - /** * Get the next index for a given keyboard event given a count of the total * items and the current index @@ -123,10 +112,13 @@ function TabList({ 'aria-label': label, children, className: customClassName, - light, - scrollIntoView, contained = false, iconSize, + leftOverflowButtonProps, + light, + rightOverflowButtonProps, + scrollDebounceWait = 200, + scrollIntoView, ...rest }) { const { @@ -137,6 +129,10 @@ function TabList({ } = React.useContext(TabsContext); const prefix = usePrefix(); const ref = useRef(null); + const previousButton = useRef(null); + const nextButton = useRef(null); + const [isScrollable, setIsScrollable] = useState(false); + const [scrollLeft, setScrollLeft] = useState(false); const className = cx(`${prefix}--tabs`, customClassName, { [`${prefix}--tabs--contained`]: contained, [`${prefix}--tabs--light`]: light, @@ -144,12 +140,50 @@ function TabList({ [`${prefix}--tabs__icon--lg`]: iconSize === 'lg', }); + // Previous Button + // VISIBLE IF: + // SCROLLABLE + // AND SCROLL_LEFT > 0 + const buttonWidth = 44; + const isPreviousButtonVisible = ref.current + ? isScrollable && scrollLeft > 0 + : false; + // Next Button + // VISIBLE IF: + // SCROLLABLE + // AND SCROLL_LEFT + CLIENT_WIDTH < SCROLL_WIDTH + const isNextButtonVisible = ref.current + ? scrollLeft + buttonWidth + ref.current.clientWidth < + ref.current.scrollWidth + : false; + const previousButtonClasses = cx( + `${prefix}--tab--overflow-nav-button`, + `${prefix}--tab--overflow-nav-button--previous`, + { + [`${prefix}--tab--overflow-nav-button--hidden`]: !isPreviousButtonVisible, + } + ); + const nextButtonClasses = cx( + `${prefix}--tab--overflow-nav-button`, + `${prefix}--tab--overflow-nav-button--next`, + { + [`${prefix}--tab--overflow-nav-button--hidden`]: !isNextButtonVisible, + } + ); + const tabs = []; + const debouncedOnScroll = useCallback(() => { + return debounce((event) => { + setScrollLeft(event.target.scrollLeft); + }, scrollDebounceWait); + }, [scrollDebounceWait]); function onKeyDown(event) { if ( matches(event, [keys.ArrowRight, keys.ArrowLeft, keys.Home, keys.End]) ) { + event.preventDefault(); + const activeTabs = tabs.filter((tab) => { return !tab.current.disabled; }); @@ -194,26 +228,131 @@ function TabList({ } }); - return ( - // eslint-disable-next-line jsx-a11y/interactive-supports-focus -
- {React.Children.map(children, (child, index) => { - const ref = React.createRef(); - tabs.push(ref); - return ( - - {React.cloneElement(child, { - ref, - })} - + useIsomorphicEffect(() => { + if (ref.current) { + setIsScrollable(ref.current.scrollWidth > ref.current.clientWidth); + } + + function handler() { + if (ref.current) { + setIsScrollable(ref.current.scrollWidth > ref.current.clientWidth); + } + } + + const debouncedHandler = debounce(handler, 200); + window.addEventListener('resize', debouncedHandler); + return () => { + debouncedHandler.cancel(); + window.removeEventListener('resize', debouncedHandler); + }; + }, []); + + // updates scroll location for all scroll behavior. + useIsomorphicEffect(() => { + ref.current.scrollLeft = scrollLeft; + }, [scrollLeft]); + + useIsomorphicEffect(() => { + const tab = + activation === 'manual' ? tabs[activeIndex] : tabs[selectedIndex]; + if (tab) { + // The width of the "scroll buttons" + + // The start and end position of the selected tab + const { width: tabWidth } = tab.current.getBoundingClientRect(); + const start = tab.current.offsetLeft; + const end = tab.current.offsetLeft + tabWidth; + + // The start and end of the visible area for the tabs + const visibleStart = ref.current.scrollLeft + buttonWidth; + const visibleEnd = + ref.current.scrollLeft + ref.current.clientWidth - buttonWidth; + + // The beginning of the tab is clipped and not visible + if (start < visibleStart) { + setScrollLeft(start - buttonWidth); + } + + // The end of teh tab is clipped and not visible + if (end > visibleEnd) { + setScrollLeft(end + buttonWidth - ref.current.clientWidth); + } + } + }, [activation, activeIndex, selectedIndex]); + + usePressable(previousButton, { + onPress({ longPress }) { + if (!longPress) { + setScrollLeft( + Math.max( + scrollLeft - (ref.current.scrollWidth / tabs.length) * 1.5, + 0 + ) ); - })} + } + }, + onLongPress() { + return createLongPressBehavior(ref, 'backward', setScrollLeft); + }, + }); + + usePressable(nextButton, { + onPress({ longPress }) { + if (!longPress) { + setScrollLeft( + Math.min( + scrollLeft + (ref.current.scrollWidth / tabs.length) * 1.5, + ref.current.scrollWidth - ref.current.clientWidth + ) + ); + } + }, + onLongPress() { + return createLongPressBehavior(ref, 'forward', setScrollLeft); + }, + }); + + return ( +
+ + {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} +
+ {React.Children.map(children, (child, index) => { + const ref = React.createRef(); + tabs.push(ref); + return ( + + {React.cloneElement(child, { + ref, + })} + + ); + })} +
+
); } @@ -241,19 +380,39 @@ TabList.propTypes = { * Specify an optional className to be added to the container node */ className: PropTypes.string, + /** * Specify whether component is contained type */ - contained: PropTypes.bool, + /** * If using `IconTab`, specify the size of the icon being used. */ iconSize: PropTypes.oneOf(['default', 'lg']), + + /** + * Provide the props that describe the left overflow button + */ + leftOverflowButtonProps: PropTypes.object, + /** * Specify whether or not to use the light component variant */ light: PropTypes.bool, + + /** + * Provide the props that describe the right overflow button + */ + rightOverflowButtonProps: PropTypes.object, + + /** + * Optionally provide a delay (in milliseconds) passed to the lodash + * debounce of the onScroll handler. This will impact the responsiveness + * of scroll arrow buttons rendering when scrolling to the first or last tab. + */ + scrollDebounceWait: PropTypes.number, + /** * Choose whether or not to automatically scroll to newly selected tabs * on component rerender @@ -261,6 +420,50 @@ TabList.propTypes = { scrollIntoView: PropTypes.bool, }; +/** + * Helper function to setup the behavior when a button is "long pressed". This + * function will take a ref to the tablist, a direction, and a setter for + * scrollLeft and will update the scroll position within a + * requestAnimationFrame. + * + * It returns a cleanup function to be run when the long press is + * deactivated + * + * @param {RefObject} ref + * @param {'forward' | 'backward'} direction + * @param {Function} setScrollLeft + * @returns {Function} + */ +function createLongPressBehavior(ref, direction, setScrollLeft) { + // We manually override the scroll behavior to be "auto". If it is set as + // smooth, this animation does not update correctly + let defaultScrollBehavior = ref.current.style['scroll-behavior']; + ref.current.style['scroll-behavior'] = 'auto'; + + const scrollDelta = direction === 'forward' ? 5 : -5; + let frameId = null; + + function tick() { + ref.current.scrollLeft = ref.current.scrollLeft + scrollDelta; + frameId = requestAnimationFrame(tick); + } + + frameId = requestAnimationFrame(tick); + + return () => { + // Restore the previous scroll behavior + ref.current.style['scroll-behavior'] = defaultScrollBehavior; + + // Make sure that our `scrollLeft` value is in sync with the existing + // `ref` after our requestAnimationFrame loop above + setScrollLeft(ref.current.scrollLeft); + + if (frameId) { + cancelAnimationFrame(frameId); + } + }; +} + const Tab = React.forwardRef(function Tab( { as: BaseComponent = 'button', @@ -323,26 +526,32 @@ Tab.propTypes = { * Provide a custom element to render instead of the default button */ as: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]), + /** * Provide child elements to be rendered inside of `Tab`. */ children: PropTypes.node, + /** * Specify an optional className to be added to your Tab */ className: PropTypes.string, + /** * Whether your Tab is disabled. */ disabled: PropTypes.bool, + /** * Provide a handler that is invoked when a user clicks on the control */ onClick: PropTypes.func, + /** * Provide a handler that is invoked on the key down event for the control */ onKeyDown: PropTypes.func, + /* * An optional parameter to allow overriding the anchor rendering. * Useful for using Tab along with react-router or other client @@ -459,6 +668,7 @@ TabPanel.propTypes = { * Provide child elements to be rendered inside of `TabPanel`. */ children: PropTypes.node, + /** * Specify an optional className to be added to TabPanel. */ @@ -481,7 +691,3 @@ TabPanels.propTypes = { }; export { Tabs, Tab, IconTab, TabPanel, TabPanels, TabList }; - -// TO DO: implement horizontal scroll and the following props: -// leftOverflowButtonProps -// rightOverflowButtonProps diff --git a/packages/react/src/components/Tabs/next/Tabs.stories.js b/packages/react/src/components/Tabs/next/Tabs.stories.js index 77cc087d6929..b31ff3c472df 100644 --- a/packages/react/src/components/Tabs/next/Tabs.stories.js +++ b/packages/react/src/components/Tabs/next/Tabs.stories.js @@ -41,24 +41,61 @@ export default { }; export const Default = () => ( - - - Tab Label 1 - Tab Label 2 - Tab Label 3 - Tab Label 4 with a very long long label - Tab Label 5 - - - - Tab Panel 1 - - Tab Panel 2 - Tab Panel 3 - Tab Panel 4 - Tab Panel 5 - - +
+ + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long label + Tab Label 5 + Tab Label 6 + Tab Label 7 + Tab Label 8 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + Tab Panel 6 + Tab Panel 7 + Tab Panel 8 + + +
+); + +export const Manual = () => ( +
+ + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long label + Tab Label 5 + Tab Label 6 + Tab Label 7 + Tab Label 8 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + Tab Panel 6 + Tab Panel 7 + Tab Panel 8 + + +
); export const Icon20Only = () => ( diff --git a/packages/react/src/components/Tabs/next/usePressable.js b/packages/react/src/components/Tabs/next/usePressable.js new file mode 100644 index 000000000000..eb09269d911a --- /dev/null +++ b/packages/react/src/components/Tabs/next/usePressable.js @@ -0,0 +1,126 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect, useRef, useState } from 'react'; + +export function usePressable( + ref, + { onPress, onPressIn, onPressOut, onLongPress, delayLongPressMs = 500 } = {} +) { + const savedOnPress = useRef(onPress); + const savedOnPressIn = useRef(onPressIn); + const savedOnPressOut = useRef(onPressOut); + const savedOnLongPress = useRef(onLongPress); + const [pendingLongPress, setPendingLongPress] = useState(false); + const [longPress, setLongPress] = useState(false); + const state = useRef({ + longPress: false, + }); + + useEffect(() => { + savedOnPress.current = onPress; + }, [onPress]); + + useEffect(() => { + savedOnPressIn.current = onPressIn; + }, [onPressIn]); + + useEffect(() => { + savedOnPressOut.current = onPressOut; + }, [onPressOut]); + + useEffect(() => { + savedOnLongPress.current = onLongPress; + }, [onLongPress]); + + useEffect(() => { + const { current: element } = ref; + + // Fired when a pointer becomes active buttons state. + function onPointerDown() { + setPendingLongPress(true); + savedOnPressIn.current?.(); + } + + // Fired when a pointer is no longer active buttons state. + function onPointerUp() { + setPendingLongPress(false); + setLongPress(false); + savedOnPressOut.current?.(state.current); + } + + // A browser fires this event if it concludes the pointer + // will no longer be able to generate events (for example + // the related device is deactivated). + function onPointerCancel() { + setPendingLongPress(false); + setLongPress(false); + savedOnPressOut.current?.(); + state.current.longPress = false; + } + + // Fired when a pointer is moved out of the hit test + // boundaries of an element. For pen devices, this event + // is fired when the stylus leaves the hover range + // detectable by the digitizer. + function onPointerLeave() { + setPendingLongPress(false); + setLongPress(false); + savedOnPressOut.current?.(); + state.current.longPress = false; + } + + function onClick() { + setLongPress(false); + setPendingLongPress(false); + savedOnPress.current?.(state.current); + state.current.longPress = false; + } + + function onTouchStart(event) { + // We prevent the default event on touchstart so that text selection is + // disabled on iOS Safari when interacting with a "pressable" element + event.preventDefault(); + } + + element.addEventListener('touchstart', onTouchStart); + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointercancel', onPointerCancel); + element.addEventListener('pointerleave', onPointerLeave); + element.addEventListener('click', onClick); + + return () => { + element.removeEventListener('touchstart', onTouchStart); + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointercancel', onPointerCancel); + element.removeEventListener('pointerleave', onPointerLeave); + element.removeEventListener('click', onClick); + }; + }, [ref]); + + useEffect(() => { + if (pendingLongPress) { + const timeoutId = setTimeout(() => { + setPendingLongPress(false); + setLongPress(true); + }, delayLongPressMs); + + return () => { + clearTimeout(timeoutId); + }; + } + }, [pendingLongPress, delayLongPressMs]); + + useEffect(() => { + if (longPress) { + state.current.longPress = true; + return savedOnLongPress.current?.(); + } + }, [longPress]); +} diff --git a/packages/react/src/internal/useEffectOnce.js b/packages/react/src/internal/useEffectOnce.js new file mode 100644 index 000000000000..bbf520e43b35 --- /dev/null +++ b/packages/react/src/internal/useEffectOnce.js @@ -0,0 +1,30 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect, useRef } from 'react'; + +/** + * A custom hook which will call the given callback exactly once when your + * component is initially rendered and effects are first called + * + * @param {Function} callback + */ +export function useEffectOnce(callback) { + const savedCallback = useRef(callback); + const effectGuard = useRef(false); + + useEffect(() => { + savedCallback.current = callback; + }); + + useEffect(() => { + if (effectGuard.current !== true) { + effectGuard.current = true; + savedCallback.current(); + } + }, []); +} diff --git a/packages/styles/scss/components/tabs/_tabs.scss b/packages/styles/scss/components/tabs/_tabs.scss index dd61e3d1ed0f..032f36dce706 100644 --- a/packages/styles/scss/components/tabs/_tabs.scss +++ b/packages/styles/scss/components/tabs/_tabs.scss @@ -40,29 +40,25 @@ $icon-tab-size: custom-property.get-var('icon-tab-size', rem(40px)); @include reset; @include type-style('body-compact-01'); + position: relative; display: flex; width: 100%; height: auto; min-height: rem(40px); + max-height: 4rem; color: $text-primary; &.#{$prefix}--tabs--contained { min-height: rem(48px); } - .#{$prefix}--tabs__nav { + .#{$prefix}--tab--list { display: flex; - overflow: auto hidden; - width: auto; - max-width: 100%; - flex-direction: row; - padding: 0; - margin: 0; - list-style: none; - outline: 0; - // hide scroll bars + width: 100%; + overflow-x: auto; + scroll-behavior: smooth; scrollbar-width: none; - transition: max-height $duration-fast-01 motion(standard, productive); + will-change: scroll-position; &::-webkit-scrollbar { display: none; @@ -72,37 +68,121 @@ $icon-tab-size: custom-property.get-var('icon-tab-size', rem(40px)); //----------------------------- // Overflow Nav Buttons //----------------------------- - .#{$prefix}--tabs__overflow-indicator--left, - .#{$prefix}--tabs__overflow-indicator--right { + .#{$prefix}--tab--overflow-nav-button { + @include button-reset.reset; + + display: flex; + width: $spacing-08; + flex-shrink: 0; + align-items: center; + justify-content: center; + background-color: $background; + + &:focus { + @include focus-outline('outline'); + } + } + + .#{$prefix}--tab--overflow-nav-button--hidden { + display: none; + } + + &.#{$prefix}--tabs--contained .#{$prefix}--tab--overflow-nav-button { + width: $spacing-09; + margin: 0; + background-color: $layer-accent; + } + + .#{$prefix}--tab--overflow-nav-button svg { + fill: $icon-primary; + } + + .#{$prefix}--tab--overflow-nav-button--next { + position: absolute; + top: 0; + right: 0; + bottom: 0; + } + + .#{$prefix}--tab--overflow-nav-button--next::before { + position: absolute; z-index: 1; + left: -$spacing-03; width: $spacing-03; - flex: 1 0 auto; + height: 100%; + background: linear-gradient( + to right, + rgba(255, 255, 255, 0), + $background + ); + content: ''; } - .#{$prefix}--tabs__overflow-indicator--left { - margin-right: -$spacing-03; - background-image: linear-gradient(to left, transparent, $background); + &.#{$prefix}--tabs--contained + .#{$prefix}--tab--overflow-nav-button--next::before { + background-image: linear-gradient( + to right, + rgba(255, 255, 255, 0), + $layer-accent + ); } - .#{$prefix}--tabs__overflow-indicator--right { - margin-left: -$spacing-03; - background-image: linear-gradient(to right, transparent, $background); + .#{$prefix}--tab--overflow-nav-button--previous { + position: absolute; + top: 0; + bottom: 0; + left: 0; + } + + .#{$prefix}--tab--overflow-nav-button--previous::before { + position: absolute; + z-index: 1; + right: -$spacing-03; + width: $spacing-03; + height: 100%; + background: linear-gradient(to left, rgba(255, 255, 255, 0), $background); + content: ''; + } + + &.#{$prefix}--tabs--contained + .#{$prefix}--tab--overflow-nav-button--previous::before { + background-image: linear-gradient( + to left, + rgba(255, 255, 255, 0), + $layer-accent + ); } .#{$prefix}--tabs--light .#{$prefix}--tabs__overflow-indicator--left { - background-image: linear-gradient(to left, transparent, $layer); + background-image: linear-gradient( + to left, + rgba(255, 255, 255, 0), + $layer + ); } .#{$prefix}--tabs--light .#{$prefix}--tabs__overflow-indicator--right { - background-image: linear-gradient(to right, transparent, $layer); + background-image: linear-gradient( + to right, + rgba(255, 255, 255, 0), + $layer + ); } &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--left { - background-image: linear-gradient(to left, transparent, $layer-accent); + background-image: linear-gradient( + to left, + rgba(255, 255, 255, 0), + $layer-accent + ); } &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--right { - background-image: linear-gradient(to right, transparent, $layer-accent); + background-image: linear-gradient( + to right, + rgba(255, 255, 255, 0), + $layer-accent + ); } // Safari-only media query @@ -146,34 +226,6 @@ $icon-tab-size: custom-property.get-var('icon-tab-size', rem(40px)); } } - .#{$prefix}--tab--overflow-nav-button { - @include button-reset.reset; - - display: flex; - width: $spacing-08; - flex-shrink: 0; - align-items: center; - justify-content: center; - - &:focus { - @include focus-outline('outline'); - } - } - - .#{$prefix}--tab--overflow-nav-button--hidden { - display: none; - } - - &.#{$prefix}--tabs--contained .#{$prefix}--tab--overflow-nav-button { - width: $spacing-09; - margin: 0; - background-color: $layer-accent; - } - - .#{$prefix}--tab--overflow-nav-button svg { - fill: $icon-primary; - } - //----------------------------- // Item //----------------------------- @@ -181,6 +233,7 @@ $icon-tab-size: custom-property.get-var('icon-tab-size', rem(40px)); @include reset; display: flex; + flex: 1 0 auto; padding: 0; cursor: pointer; transition: background-color $duration-fast-01