diff --git a/.changeset/sweet-eggs-complain.md b/.changeset/sweet-eggs-complain.md new file mode 100644 index 00000000000..7d922deb655 --- /dev/null +++ b/.changeset/sweet-eggs-complain.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +UnderlineNav2: Introducing overflow behavior for fine and coarse pointer devices diff --git a/docs/content/drafts/UnderlineNav2.mdx b/docs/content/drafts/UnderlineNav2.mdx index 402b9054ef7..dab8814d770 100644 --- a/docs/content/drafts/UnderlineNav2.mdx +++ b/docs/content/drafts/UnderlineNav2.mdx @@ -1,15 +1,22 @@ --- title: UnderlineNav v2 +componentId: underline_nav_2 status: Draft description: Use an underlined nav to allow tab like navigation with overflow behaviour in your UI. +source: https://github.com/primer/react/tree/main/src/UnderlineNav2 +storybook: https://primer.style/react/storybook/?path=/story/layout-underlinenav --- +```js +import {UnderlineNav} from '@primer/react/drafts' +``` + ## Examples ### Simple ```jsx live drafts - + Item 1 Item 2 Item 3 @@ -19,20 +26,88 @@ description: Use an underlined nav to allow tab like navigation with overflow be ### With icons ```jsx live drafts - - - Item 1 + + + Code + + + Issues + + + Pull requests + + Discussions + + Actions + + + Projects - Item 2 ``` -### Small variant +### Overflow Behaviour + +When overflow occurs, the component first hides icons if present to optimize for space and show as many items as possible. (Only for fine pointer devices) + +#### Items without Icons ```jsx live drafts - - Item 1 - Item 2 + + + Code + + + Issues + + + Pull requests + + Discussions + + Actions + + + Projects + + Security + Insights + + Settings + + +``` + +#### Display `More` menu + +If there is still overflow, the component will behave depending on the pointer. + +```jsx live drafts + + + Code + + + Issues + + + Pull requests + + Discussions + + Actions + + + Projects + + Security + + Insights + + + Settings + + Wiki ``` @@ -44,19 +119,6 @@ description: Use an underlined nav to allow tab like navigation with overflow be - - - - + ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + })) +}) + +Object.defineProperty(window.Element.prototype, 'scrollTo', { + value: jest.fn(), + writable: true +}) describe('UnderlineNav', () => { test('selected nav', () => { const {getByText} = render( @@ -30,15 +51,47 @@ describe('UnderlineNav', () => { expect(nav.getAttribute('aria-label')).toBe('Test nav') }) - test('respect align prop', () => { + test('with icons', () => { const {container} = render( + + Code + + Issues + + Pull Request + + ) + const nav = container.getElementsByTagName('nav')[0] + expect(nav.getElementsByTagName('svg').length).toEqual(2) + }) + test('should fire onSelect on click and keypress', async () => { + const onSelect = jest.fn() + const {getByText} = render( + + Item 1 + Item 2 + Item 3 + + ) + const item = getByText('Item 1') + fireEvent.click(item) + expect(onSelect).toHaveBeenCalledTimes(1) + fireEvent.keyPress(item, {key: 'Enter', code: 13, charCode: 13}) + expect(onSelect).toHaveBeenCalledTimes(2) + }) + test('respect counter prop', () => { + const {getByText} = render( - Item 1 + + Item 1 + Item 2 Item 3 ) - const nav = container.getElementsByTagName('nav')[0] - expect(nav).toHaveStyle(`justify-content:flex-end`) + const item = getByText('Item 1').closest('a') + const counter = item?.getElementsByTagName('span')[2] + expect(counter?.className).toContain('CounterLabel') + expect(counter?.textContent).toBe('8') }) }) diff --git a/src/UnderlineNav2/UnderlineNav.tsx b/src/UnderlineNav2/UnderlineNav.tsx index 11f64403a30..50900cc5fa1 100644 --- a/src/UnderlineNav2/UnderlineNav.tsx +++ b/src/UnderlineNav2/UnderlineNav.tsx @@ -1,121 +1,159 @@ -import React, {useRef, forwardRef, useCallback, useState, MutableRefObject, RefObject} from 'react' +import React, {useRef, forwardRef, useCallback, useState, MutableRefObject, RefObject, useEffect} from 'react' import Box from '../Box' -import {merge, SxProp, BetterSystemStyleObject} from '../sx' +import sx, {merge, BetterSystemStyleObject, SxProp} from '../sx' import {UnderlineNavContext} from './UnderlineNavContext' import {ActionMenu} from '../ActionMenu' import {ActionList} from '../ActionList' import {useResizeObserver, ResizeObserverEntry} from '../hooks/useResizeObserver' -import {useFocusZone} from '../hooks/useFocusZone' -import {FocusKeys} from '@primer/behaviors' - -type Overflow = 'auto' | 'menu' | 'scroll' -type ChildWidthArray = Array<{width: number}> -type ResponsiveProps = { - items: Array - actions: Array +import {useMedia} from '../hooks/useMedia' +import {scrollIntoView} from '@primer/behaviors' +import type {ScrollIntoViewOptions} from '@primer/behaviors' +import CounterLabel from '../CounterLabel' +import {useTheme} from '../ThemeProvider' +import {ChildWidthArray, ResponsiveProps, OnScrollWithButtonEventType} from './types' + +import {moreBtnStyles, getDividerStyle, getNavStyles, ulStyles, scrollStyles, moreMenuStyles} from './styles' +import {LeftArrowButton, RightArrowButton} from './UnderlineNavArrowButton' +import styled from 'styled-components' + +export type UnderlineNavProps = { + label: string + as?: React.ElementType + align?: 'right' + sx?: SxProp + variant?: 'default' | 'small' + afterSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void + children: React.ReactNode +} +// When page is loaded, we don't have ref for the more button as it is not on the DOM yet. +// However, we need to calculate number of possible items when the more button present as well. So using the width of the more button as a constant. +const MORE_BTN_WIDTH = 86 +const ARROW_BTN_WIDTH = 36 + +const underlineNavScrollMargins: ScrollIntoViewOptions = { + startMargin: ARROW_BTN_WIDTH, + endMargin: ARROW_BTN_WIDTH, + direction: 'horizontal', + behavior: 'smooth' +} + +// Needed this because passing a ref using HTMLULListElement to `Box` causes a type error +const NavigationList = styled.ul` + ${sx}; +` + +const handleArrowBtnsVisibility = ( + scrollableList: RefObject, + callback: (scroll: {scrollLeft: number; scrollRight: number}) => void +) => { + const {scrollLeft, scrollWidth, clientWidth} = scrollableList.current as HTMLElement + const scrollRight = scrollWidth - scrollLeft - clientWidth + const scrollOffsets = {scrollLeft, scrollRight} + callback(scrollOffsets) } const overflowEffect = ( - width: number, + navWidth: number, + moreMenuWidth: number, childArray: Array, childWidthArray: ChildWidthArray, - callback: (props: ResponsiveProps) => void + noIconChildWidthArray: ChildWidthArray, + isCoarsePointer: boolean, + callback: (props: ResponsiveProps, iconsVisible: boolean) => void ) => { + let iconsVisible = true + let overflowStyles: BetterSystemStyleObject | null = {} + if (childWidthArray.length === 0) { - callback({items: childArray, actions: []}) + callback({items: childArray, actions: [], overflowStyles}, iconsVisible) } - // do this only for overflow - const numberOfItemsPossible = calculatePossibleItems(childWidthArray, width) + const numberOfItemsPossible = calculatePossibleItems(childWidthArray, navWidth) + const numberOfItemsWithoutIconPossible = calculatePossibleItems(noIconChildWidthArray, navWidth) + // We need take more menu width into account when calculating the number of items possible + const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems( + noIconChildWidthArray, + navWidth, + moreMenuWidth || MORE_BTN_WIDTH + ) const items: Array = [] const actions: Array = [] - for (const [index, child] of childArray.entries()) { - if (index < numberOfItemsPossible) { - items.push(child) + if (isCoarsePointer) { + // If it is a coarse pointer, we never show the icons even if they fit into the screen. + iconsVisible = false + items.push(...childArray) + // If we have more items than we can fit, we add the scroll styles + if (childArray.length > numberOfItemsWithoutIconPossible) { + overflowStyles = scrollStyles + } + } else { + // For fine pointer devices, first we check if we can fit all the items with icons + if (childArray.length <= numberOfItemsPossible) { + items.push(...childArray) + } else if (childArray.length <= numberOfItemsWithoutIconPossible) { + // if we can't fit all the items with icons, we check if we can fit all the items without icons + iconsVisible = false + items.push(...childArray) } else { - actions.push(child) + // if we can't fit all the items without icons, we keep the icons hidden and show the rest in the menu + iconsVisible = false + overflowStyles = moreMenuStyles + for (const [index, child] of childArray.entries()) { + if (index < numberOfItemsPossibleWithMoreMenu) { + items.push(child) + } else { + actions.push(child) + } + } } } - callback({items, actions}) -} -export type {ResponsiveProps} + callback({items, actions, overflowStyles}, iconsVisible) +} -function getValidChildren(children: React.ReactNode) { +const getValidChildren = (children: React.ReactNode) => { return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] } -function calculatePossibleItems(childWidthArray: ChildWidthArray, width: number) { +const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: number, moreMenuWidth = 0) => { + const widthToFit = navWidth - moreMenuWidth let breakpoint = childWidthArray.length - 1 let sumsOfChildWidth = 0 for (const [index, childWidth] of childWidthArray.entries()) { - if (sumsOfChildWidth > 0.5 * width) { - breakpoint = index + if (sumsOfChildWidth > widthToFit) { + breakpoint = index - 1 break } else { sumsOfChildWidth = sumsOfChildWidth + childWidth.width } } - return breakpoint -} -export type UnderlineNavProps = { - label: string - as?: React.ElementType - overflow?: Overflow - align?: 'right' - sx?: SxProp - variant?: 'default' | 'small' - afterSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void - children: React.ReactNode + return breakpoint } export const UnderlineNav = forwardRef( ( - { - as = 'nav', - overflow = 'auto', - align, - label, - sx: sxProp = {}, - afterSelect, - variant = 'default', - children - }: UnderlineNavProps, + {as = 'nav', align, label, sx: sxProp = {}, afterSelect, variant = 'default', children}: UnderlineNavProps, forwardedRef ) => { const backupRef = useRef(null) const newRef = (forwardedRef ?? backupRef) as MutableRefObject + const listRef = useRef(null) + const moreMenuRef = useRef(null) - // This might change if we decide tab through the navigation items rather than navigationg with the arrow keys. - // TBD. In the meantime keeping it as a menu with the focus trap. - // ref: https://www.w3.org/WAI/ARIA/apg/example-index/menubar/menubar-navigation.html (Keyboard Support) - useFocusZone({ - containerRef: backupRef, - bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd, - focusOutBehavior: 'wrap' - }) + const {theme} = useTheme() - const styles = { - display: 'flex', - justifyContent: align === 'right' ? 'flex-end' : 'space-between', - borderBottom: '1px solid', - borderBottomColor: 'border.muted', - align: 'row', - alignItems: 'center' - } - const overflowStyles = overflow === 'scroll' ? {overflowX: 'auto', whiteSpace: 'nowrap'} : {} - const ulStyles = { - display: 'flex', - listStyle: 'none', - padding: '0', - margin: '0', - marginBottom: '-1px', - alignItems: 'center' - } + const isCoarsePointer = useMedia('(pointer: coarse)') const [selectedLink, setSelectedLink] = useState | undefined>(undefined) + const [iconsVisible, setIconsVisible] = useState(true) + + const [scrollValues, setScrollValues] = useState<{scrollLeft: number; scrollRight: number}>({ + scrollLeft: 0, + scrollRight: 0 + }) + const afterSelectHandler = (event: React.MouseEvent | React.KeyboardEvent) => { if (!event.defaultPrevented) { if (typeof afterSelect === 'function') afterSelect(event) @@ -124,13 +162,29 @@ export const UnderlineNav = forwardRef( const [responsiveProps, setResponsiveProps] = useState({ items: getValidChildren(children), - actions: [] + actions: [], + overflowStyles: {} }) - const callback = useCallback((props: ResponsiveProps) => { + const callback = useCallback((props: ResponsiveProps, displayIcons: boolean) => { setResponsiveProps(props) + setIconsVisible(displayIcons) + }, []) + + const updateOffsetValues = useCallback((scrollOffsets: {scrollLeft: number; scrollRight: number}) => { + setScrollValues(scrollOffsets) }, []) + const scrollOnList = useCallback(() => { + handleArrowBtnsVisibility(listRef, updateOffsetValues) + }, [updateOffsetValues]) + + const onScrollWithButton: OnScrollWithButtonEventType = (event, direction) => { + if (!listRef.current) return + const ScrollAmount = direction * 200 + listRef.current.scrollBy({left: ScrollAmount, top: 0, behavior: 'smooth'}) + } + const actions = responsiveProps.actions const [childWidthArray, setChildWidthArray] = useState([]) const setChildrenWidth = useCallback(size => { @@ -140,44 +194,103 @@ export const UnderlineNav = forwardRef( }) }, []) + const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]) + const setNoIconChildrenWidth = useCallback(size => { + setNoIconChildWidthArray(arr => { + const newArr = [...arr, size] + return newArr + }) + }, []) + // resizeObserver calls this function infinitely without a useCallback const resizeObserverCallback = useCallback( (resizeObserverEntries: ResizeObserverEntry[]) => { - if (overflow === 'auto' || overflow === 'menu') { - const childArray = getValidChildren(children) - const navWidth = resizeObserverEntries[0].contentRect.width - overflowEffect(navWidth, childArray, childWidthArray, callback) - } + const childArray = getValidChildren(children) + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + overflowEffect( + navWidth, + moreMenuWidth, + childArray, + childWidthArray, + noIconChildWidthArray, + isCoarsePointer, + callback + ) + + handleArrowBtnsVisibility(listRef, updateOffsetValues) }, - [callback, childWidthArray, children, overflow] + [callback, updateOffsetValues, childWidthArray, noIconChildWidthArray, children, isCoarsePointer, moreMenuRef] ) + useResizeObserver(resizeObserverCallback, newRef as RefObject) + + useEffect(() => { + const listEl = listRef.current + // eslint-disable-next-line github/prefer-observers + listEl?.addEventListener('scroll', scrollOnList) + return () => listEl?.removeEventListener('scroll', scrollOnList) + }, [scrollOnList]) + + useEffect(() => { + // scroll the selected link into the view + if (selectedLink && selectedLink.current && listRef.current) { + scrollIntoView(selectedLink.current, listRef.current, underlineNavScrollMargins) + } + }, [selectedLink]) + return ( - - (overflowStyles, ulStyles)}> + (getNavStyles(theme, {align}), sxProp)} + aria-label={label} + ref={newRef} + > + {isCoarsePointer && ( + 0} onScrollWithButton={onScrollWithButton} /> + )} + + (responsiveProps.overflowStyles, ulStyles)} ref={listRef}> {responsiveProps.items} - + + + {isCoarsePointer && ( + 0} onScrollWithButton={onScrollWithButton} /> + )} {actions.length > 0 && ( - - {/* set margin 0 here because safari puts extra margin around the button */} - More - - - {actions.map((action, index) => { - const {children: actionElementChildren, ...actionElementProps} = action.props - return ( - - {actionElementChildren} - - ) - })} - - - + + + + More + + + {actions.map((action, index) => { + const {children: actionElementChildren, ...actionElementProps} = action.props + return ( + + + {actionElementChildren} + {actionElementProps.counter} + + + ) + })} + + + + )} diff --git a/src/UnderlineNav2/UnderlineNavArrowButton.tsx b/src/UnderlineNav2/UnderlineNavArrowButton.tsx new file mode 100644 index 00000000000..b36192aefbe --- /dev/null +++ b/src/UnderlineNav2/UnderlineNavArrowButton.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import {IconButton} from '../Button/IconButton' +import {ChevronLeftIcon, ChevronRightIcon} from '@primer/octicons-react' +import {getLeftArrowHiddenBtn, getRightArrowHiddenBtn, getLeftArrowVisibleBtn, getRightArrowVisibleBtn} from './styles' +import {OnScrollWithButtonEventType} from './types' +import {useTheme} from '../ThemeProvider' + +const LeftArrowButton = ({ + show, + onScrollWithButton +}: { + show: boolean + onScrollWithButton: OnScrollWithButtonEventType +}) => { + const {theme} = useTheme() + return ( + ) => onScrollWithButton(e, -1)} + icon={ChevronLeftIcon} + sx={show ? getLeftArrowVisibleBtn(theme) : getLeftArrowHiddenBtn(theme)} + /> + ) +} + +const RightArrowButton = ({ + show, + onScrollWithButton +}: { + show: boolean + onScrollWithButton: OnScrollWithButtonEventType +}) => { + const {theme} = useTheme() + return ( + ) => onScrollWithButton(e, 1)} + icon={ChevronRightIcon} + sx={show ? getRightArrowVisibleBtn(theme) : getRightArrowHiddenBtn(theme)} + /> + ) +} + +export {LeftArrowButton, RightArrowButton} diff --git a/src/UnderlineNav2/UnderlineNavContext.tsx b/src/UnderlineNav2/UnderlineNavContext.tsx index 1d365773583..6f614aa0864 100644 --- a/src/UnderlineNav2/UnderlineNavContext.tsx +++ b/src/UnderlineNav2/UnderlineNavContext.tsx @@ -2,13 +2,17 @@ import React, {createContext, RefObject} from 'react' export const UnderlineNavContext = createContext<{ setChildrenWidth: React.Dispatch<{width: number}> + setNoIconChildrenWidth: React.Dispatch<{width: number}> selectedLink: RefObject | undefined setSelectedLink: (ref: RefObject) => void afterSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void variant: 'default' | 'small' + iconsVisible: boolean }>({ setChildrenWidth: () => null, + setNoIconChildrenWidth: () => null, selectedLink: undefined, setSelectedLink: () => null, - variant: 'default' + variant: 'default', + iconsVisible: true }) diff --git a/src/UnderlineNav2/UnderlineNavItem.tsx b/src/UnderlineNav2/UnderlineNavItem.tsx index 302ae12b411..65df8b14409 100644 --- a/src/UnderlineNav2/UnderlineNavItem.tsx +++ b/src/UnderlineNav2/UnderlineNavItem.tsx @@ -1,11 +1,12 @@ import React, {forwardRef, useLayoutEffect, useRef, useContext, MutableRefObject, RefObject} from 'react' import Box from '../Box' -import {merge, SxProp, BetterSystemStyleObject} from '../sx' +import {merge, SxProp} from '../sx' import {IconProps} from '@primer/octicons-react' import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {UnderlineNavContext} from './UnderlineNavContext' import CounterLabel from '../CounterLabel' -import {Theme, useTheme} from '../ThemeProvider' +import {useTheme} from '../ThemeProvider' +import {getLinkStyles, wrapperStyles, iconWrapStyles, counterStyles} from './styles' // adopted from React.AnchorHTMLAttributes type LinkProps = { @@ -36,7 +37,7 @@ export type UnderlineNavItemProps = { /** * Icon before the text */ - leadingIcon?: React.FunctionComponent + icon?: React.FunctionComponent as?: React.ElementType /** * Counter @@ -55,97 +56,37 @@ export const UnderlineNavItem = forwardRef( counter, onSelect, selected: preSelected = false, - leadingIcon: LeadingIcon, + icon: Icon, ...props }, forwardedRef ) => { const backupRef = useRef(null) - const ref = forwardedRef ?? backupRef - const {setChildrenWidth, selectedLink, setSelectedLink, afterSelect, variant} = useContext(UnderlineNavContext) + const ref = (forwardedRef ?? backupRef) as RefObject + const { + setChildrenWidth, + setNoIconChildrenWidth, + selectedLink, + setSelectedLink, + afterSelect, + variant, + iconsVisible + } = useContext(UnderlineNavContext) const {theme} = useTheme() useLayoutEffect(() => { const domRect = (ref as MutableRefObject).current.getBoundingClientRect() + // might want to select this better + const icon = (ref as MutableRefObject).current.children[0].children[0] + const iconWidthWithMargin = + icon.getBoundingClientRect().width + + Number(getComputedStyle(icon).marginRight.slice(0, -2)) + + Number(getComputedStyle(icon).marginLeft.slice(0, -2)) + setChildrenWidth({width: domRect.width}) + setNoIconChildrenWidth({width: domRect.width - iconWidthWithMargin}) preSelected && selectedLink === undefined && setSelectedLink(ref as RefObject) - }, [ref, preSelected, selectedLink, setSelectedLink, setChildrenWidth]) - - const iconWrapStyles = { - alignItems: 'center', - display: 'inline-flex', - marginRight: 2 - } - - const textStyles: BetterSystemStyleObject = { - whiteSpace: 'nowrap' - } + }, [ref, preSelected, selectedLink, setSelectedLink, setChildrenWidth, setNoIconChildrenWidth]) - const wrapperStyles = { - display: 'inline-flex', - paddingY: 1, - paddingX: 2, - borderRadius: 2 - } - const smallVariantLinkStyles = { - paddingY: 1, - fontSize: 0 - } - const defaultVariantLinkStyles = { - paddingY: 2, - fontSize: 1 - } - - // eslint-disable-next-line no-shadow - const linkStyles = (theme?: Theme) => ({ - position: 'relative', - display: 'inline-flex', - color: 'fg.default', - textAlign: 'center', - textDecoration: 'none', - paddingX: 1, - ...(variant === 'small' ? smallVariantLinkStyles : defaultVariantLinkStyles), - '&:hover > div[data-component="wrapper"] ': { - backgroundColor: theme?.colors.neutral.muted, - transition: 'background .12s ease-out' - }, - '&:focus': { - outline: 0, - '& > div[data-component="wrapper"]': { - boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}` - }, - // where focus-visible is supported, remove the focus box-shadow - '&:not(:focus-visible) > div[data-component="wrapper"]': { - boxShadow: 'none' - } - }, - '&:focus-visible > div[data-component="wrapper"]': { - boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}` - }, - // renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected - '& span[data-content]::before': { - content: 'attr(data-content)', - display: 'block', - height: 0, - fontWeight: '600', - visibility: 'hidden' - }, - // selected state styles - '&::after': { - position: 'absolute', - right: '50%', - bottom: 0, - width: `calc(100% - 8px)`, - height: 2, - content: '""', - bg: selectedLink === ref ? theme?.colors.primer.border.active : 'transparent', - borderRadius: 0, - transform: 'translate(50%, -50%)' - } - }) - - const counterStyles = { - marginLeft: 2 - } const keyPressHandler = React.useCallback( event => { if (!event.defaultPrevented && [' ', 'Enter'].includes(event.key)) { @@ -157,7 +98,6 @@ export const UnderlineNavItem = forwardRef( }, [onSelect, afterSelect, ref, setSelectedLink] ) - const clickHandler = React.useCallback( event => { if (!event.defaultPrevented) { @@ -177,14 +117,14 @@ export const UnderlineNavItem = forwardRef( onKeyPress={keyPressHandler} onClick={clickHandler} {...(selectedLink === ref ? {'aria-current': 'page'} : {})} - sx={merge(linkStyles(theme), sxProp as SxProp)} + sx={merge(getLinkStyles(theme, {variant}, selectedLink, ref), sxProp as SxProp)} {...props} ref={ref} > - {LeadingIcon && ( - - + {iconsVisible && Icon && ( + + )} {children && ( @@ -192,7 +132,7 @@ export const UnderlineNavItem = forwardRef( as="span" data-component="text" data-content={children} - sx={selectedLink === ref ? {fontWeight: 600, ...{textStyles}} : {textStyles}} + sx={selectedLink === ref ? {fontWeight: 600} : {}} > {children} diff --git a/src/UnderlineNav2/examples.stories.tsx b/src/UnderlineNav2/examples.stories.tsx index 5e8a7fea4b7..3485f0b8cab 100644 --- a/src/UnderlineNav2/examples.stories.tsx +++ b/src/UnderlineNav2/examples.stories.tsx @@ -1,27 +1,23 @@ import React from 'react' -import {EyeIcon, CodeIcon, IssueOpenedIcon, GitPullRequestIcon, CommentDiscussionIcon} from '@primer/octicons-react' +import { + IconProps, + EyeIcon, + CodeIcon, + IssueOpenedIcon, + GitPullRequestIcon, + CommentDiscussionIcon, + PlayIcon, + ProjectIcon, + GraphIcon, + ShieldLockIcon, + GearIcon +} from '@primer/octicons-react' import {Meta} from '@storybook/react' import {UnderlineNav, UnderlineNavProps} from './index' import {BaseStyles, ThemeProvider} from '..' export default { title: 'Layout/UnderlineNav/examples', - argTypes: { - align: { - defaultValue: 'left', - control: { - type: 'radio', - options: ['left', 'right'] - } - }, - variant: { - defaultValue: 'default', - control: { - type: 'radio', - options: ['default', 'small'] - } - } - }, decorators: [ Story => { return ( @@ -48,17 +44,17 @@ export const DefaultNav = (args: UnderlineNavProps) => { export const withIcons = (args: UnderlineNavProps) => { return ( - Code - + Code + Issues - + Pull Requests - + Discussions - Item 1 + Item 1 ) } @@ -66,37 +62,26 @@ export const withIcons = (args: UnderlineNavProps) => { export const withCounterLabels = (args: UnderlineNavProps) => { return ( - + Code - + Issues ) } -export const rightAlign = (args: UnderlineNavProps) => { - return ( - - Item 1 - Item 2dsjsjskdjkajsdhkajsdkasj - Item 3 - - ) -} - -const items: string[] = [ - 'Item 1', - 'Looooong Item', - 'Looooooonger item', - 'Item 4', - 'Item 5', - 'Item 6', - 'Item 7', - 'Item 8', - 'Item 9', - 'Item 10' +const items: {navigation: string; icon: React.FC; counter?: number}[] = [ + {navigation: 'Code', icon: CodeIcon}, + {navigation: 'Issues', icon: IssueOpenedIcon, counter: 120}, + {navigation: 'Pull Requests', icon: GitPullRequestIcon, counter: 13}, + {navigation: 'Discussions', icon: CommentDiscussionIcon, counter: 5}, + {navigation: 'Actions', icon: PlayIcon, counter: 4}, + {navigation: 'Projects', icon: ProjectIcon, counter: 9}, + {navigation: 'Insights', icon: GraphIcon}, + {navigation: 'Settings', icon: GearIcon, counter: 10}, + {navigation: 'Security', icon: ShieldLockIcon} ] export const InternalResponsiveNav = (args: UnderlineNavProps) => { @@ -106,24 +91,13 @@ export const InternalResponsiveNav = (args: UnderlineNavProps) => { {items.map((item, index) => ( setSelectedIndex(index)} + counter={item.counter} > - {item} - - ))} - - ) -} - -export const HorizontalScrollNav = (args: UnderlineNavProps) => { - return ( - - {items.map(item => ( - - {item} + {item.navigation} ))} diff --git a/src/UnderlineNav2/styles.ts b/src/UnderlineNav2/styles.ts new file mode 100644 index 00000000000..2754d725f87 --- /dev/null +++ b/src/UnderlineNav2/styles.ts @@ -0,0 +1,206 @@ +import {Theme} from '../ThemeProvider' +import {BetterSystemStyleObject} from '../sx' +import {UnderlineNavProps} from './UnderlineNav' + +export const iconWrapStyles = { + alignItems: 'center', + display: 'inline-flex', + marginRight: 2 +} + +export const wrapperStyles = { + display: 'inline-flex', + paddingY: 1, + paddingX: 2, + borderRadius: 2 +} + +const smallVariantLinkStyles = { + paddingY: 1, + fontSize: 0 +} +const defaultVariantLinkStyles = { + paddingY: 2, + fontSize: 1 +} + +export const counterStyles = { + marginLeft: 2 +} + +export const getNavStyles = (theme?: Theme, props?: Partial>) => ({ + display: 'flex', + paddingX: 2, + justifyContent: props?.align === 'right' ? 'flex-end' : 'flex-start', + borderBottom: '1px solid', + borderBottomColor: `${theme?.colors.border.muted}`, + align: 'row', + alignItems: 'center', + position: 'relative' +}) + +export const ulStyles = { + display: 'flex', + listStyle: 'none', + padding: '0', + margin: '0', + marginBottom: '-1px', + alignItems: 'center' +} + +export const getDividerStyle = (theme?: Theme) => ({ + display: 'inline-block', + borderLeft: '1px solid', + width: '1px', + borderLeftColor: `${theme?.colors.border.muted}`, + marginRight: 1 +}) + +export const moreBtnStyles = { + //set margin 0 here because safari puts extra margin around the button, rest is to reset style to make it look like a list element + margin: 0, + border: 0, + background: 'transparent', + fontWeight: 'normal', + boxShadow: 'none', + paddingY: 1, + paddingX: 2 +} + +export const getArrowBtnStyles = (theme?: Theme) => ({ + fontWeight: 'normal', + boxShadow: 'none', + margin: 0, + border: 0, + borderRadius: 0, + paddingX: 2, + paddingY: 0, + background: theme?.colors.canvas.default, + position: 'absolute', + opacity: 1, + transition: 'opacity 250ms ease-out', + zIndex: 1, + '&:hover:not([disabled]), &:focus-visible': { + background: theme?.colors.canvas.default + } +}) + +export const getLeftArrowHiddenBtn = (theme?: Theme) => ({ + ...getArrowBtnStyles(theme), + opacity: 0, + top: 0, + bottom: 0, + left: 0 +}) + +export const getRightArrowHiddenBtn = (theme?: Theme) => ({ + ...getArrowBtnStyles(theme), + opacity: 0, + top: 0, + bottom: 0, + right: 0 +}) + +export const getLeftArrowVisibleBtn = (theme?: Theme) => ({ + ...getArrowBtnStyles(theme), + top: 0, + bottom: 0, + left: 0, + '&::after': { + content: '""', + position: 'absolute', + top: 0, + background: `linear-gradient(to left,#fff0,${theme?.colors.canvas.default})`, + height: '100%', + width: '20px', + right: '-15px', + pointerEvents: 'none' + } +}) + +export const getRightArrowVisibleBtn = (theme?: Theme) => ({ + ...getArrowBtnStyles(theme), + top: 0, + bottom: 0, + right: 0, + '&::before': { + position: 'absolute', + top: 0, + background: `linear-gradient(to right,#fff0,${theme?.colors.canvas.default})`, + content: '""', + height: '100%', + width: '20px', + left: '-15px', + pointerEvents: 'none' + } +}) + +export const getLinkStyles = ( + theme?: Theme, + props?: Partial>, + selectedLink?: React.RefObject, + ref?: React.RefObject +) => ({ + position: 'relative', + display: 'inline-flex', + color: 'fg.default', + textAlign: 'center', + textDecoration: 'none', + paddingX: 1, + ...(props?.variant === 'small' ? smallVariantLinkStyles : defaultVariantLinkStyles), + '@media (hover:hover)': { + '&:hover > div[data-component="wrapper"] ': { + backgroundColor: theme?.colors.neutral.muted, + transition: 'background .12s ease-out' + } + }, + '&:focus': { + outline: 0, + '& > div[data-component="wrapper"]': { + boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}` + }, + // where focus-visible is supported, remove the focus box-shadow + '&:not(:focus-visible) > div[data-component="wrapper"]': { + boxShadow: 'none' + } + }, + '&:focus-visible > div[data-component="wrapper"]': { + boxShadow: `inset 0 0 0 2px ${theme?.colors.accent.fg}` + }, + // renders a visibly hidden "copy" of the label in bold, reserving box space for when label becomes bold on selected + '& span[data-content]::before': { + content: 'attr(data-content)', + display: 'block', + height: 0, + fontWeight: '600', + visibility: 'hidden', + whiteSpace: 'nowrap' + }, + // selected state styles + '&::after': { + position: 'absolute', + right: '50%', + bottom: 0, + width: `calc(100% - 8px)`, + height: 2, + content: '""', + bg: selectedLink === ref ? theme?.colors.primer.border.active : 'transparent', + borderRadius: 0, + transform: 'translate(50%, -50%)' + } +}) + +export const scrollStyles: BetterSystemStyleObject = { + whiteSpace: 'nowrap', + overflowX: 'auto', + // Hiding scrollbar on Firefox + scrollbarWidth: 'none', + // Hiding scrollbar on IE 10+ + msOverflowStyle: 'none', + // Hiding scrollbar on Chrome, Safari and Opera + '&::-webkit-scrollbar': { + display: 'none' + } +} + +export const moreMenuStyles: BetterSystemStyleObject = {whiteSpace: 'nowrap'} diff --git a/src/UnderlineNav2/types.ts b/src/UnderlineNav2/types.ts index e69de29bb2d..635213764e3 100644 --- a/src/UnderlineNav2/types.ts +++ b/src/UnderlineNav2/types.ts @@ -0,0 +1,9 @@ +import {BetterSystemStyleObject} from '../sx' +export type ChildWidthArray = Array<{width: number}> +export type ResponsiveProps = { + items: Array + actions: Array + overflowStyles: BetterSystemStyleObject +} + +export type OnScrollWithButtonEventType = (event: React.MouseEvent, direction: -1 | 1) => void