diff --git a/.changeset/old-peas-cross.md b/.changeset/old-peas-cross.md new file mode 100644 index 00000000000..0c28cf9079f --- /dev/null +++ b/.changeset/old-peas-cross.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Add a new experimental component ActionBar diff --git a/packages/react/src/UnderlineNav/UnderlineNav.tsx b/packages/react/src/UnderlineNav/UnderlineNav.tsx index e7114c1c413..d43ee03cb38 100644 --- a/packages/react/src/UnderlineNav/UnderlineNav.tsx +++ b/packages/react/src/UnderlineNav/UnderlineNav.tsx @@ -34,16 +34,16 @@ export type UnderlineNavProps = { } // 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 +export const MORE_BTN_WIDTH = 86 // The height is needed to make sure we don't have a layout shift when the more button is the only item in the nav. const MORE_BTN_HEIGHT = 45 // Needed this because passing a ref using HTMLULListElement to `Box` causes a type error -const NavigationList = styled.ul` +export const NavigationList = styled.ul` ${sx}; ` -const MoreMenuListItem = styled.li` +export const MoreMenuListItem = styled.li` display: flex; align-items: center; height: ${MORE_BTN_HEIGHT}px; @@ -113,7 +113,7 @@ const overflowEffect = ( updateListAndMenu({items, menuItems}, iconsVisible) } -const getValidChildren = (children: React.ReactNode) => { +export const getValidChildren = (children: React.ReactNode) => { return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] } diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 037ad161509..64a004651aa 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -237,6 +237,7 @@ exports[`@primer/react/deprecated should not update exports without a semver cha exports[`@primer/react/drafts should not update exports without a semver change 1`] = ` [ + "type ActionBarProps", "Blankslate", "type BlankslateProps", "callbackCancelledResult", @@ -249,6 +250,7 @@ exports[`@primer/react/drafts should not update exports without a semver change "type DataTableProps", "default", "default", + "default", "Dialog", "type DialogButtonProps", "type DialogHeaderProps", @@ -320,6 +322,7 @@ exports[`@primer/react/drafts should not update exports without a semver change exports[`@primer/react/experimental should not update exports without a semver change 1`] = ` [ + "type ActionBarProps", "Blankslate", "type BlankslateProps", "callbackCancelledResult", @@ -332,6 +335,7 @@ exports[`@primer/react/experimental should not update exports without a semver c "type DataTableProps", "default", "default", + "default", "Dialog", "type DialogButtonProps", "type DialogHeaderProps", diff --git a/packages/react/src/drafts/ActionBar/ActionBar.docs.json b/packages/react/src/drafts/ActionBar/ActionBar.docs.json new file mode 100644 index 00000000000..0a18045f525 --- /dev/null +++ b/packages/react/src/drafts/ActionBar/ActionBar.docs.json @@ -0,0 +1,59 @@ +{ + "id": "actionbar", + "name": "ActionBar", + "status": "draft", + "a11yReviewed": false, + "stories": [], + "props": [ + { + "name": "size", + "type": "'small' | 'medium' | 'large'", + "required": false, + "description": "Size of the action bar" + }, + { + "name": "aria-label", + "type": "string", + "description": "When provided, a label is added to the action bar" + }, + { + "name": "children", + "type": "React.ReactElement", + "required": true + } + ], + "subcomponents": [ + { + "name": "ActionBar.Icon", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "defaultValue": "", + "required": true, + "description": "This will be the Button description." + }, + { + "name": "icon", + "type": "Component", + "defaultValue": "", + "description": "provide an octicon. It will be placed in the center of the button" + }, + { + "name": "aria-label", + "type": "string", + "defaultValue": "", + "description": "Use an aria label to describe the functionality of the button. Please refer to [our guidance on alt text](https://primer.style/guides/accessibility/alternative-text-for-images) for tips on writing good alternative text." + }, + { + "name": "sx", + "type": "SystemStyleObject" + } + ] + }, + { + "name": "ActionBar.Divider", + "props": [] + } + ] +} diff --git a/packages/react/src/drafts/ActionBar/ActionBar.stories.tsx b/packages/react/src/drafts/ActionBar/ActionBar.stories.tsx new file mode 100644 index 00000000000..5b1d020bf07 --- /dev/null +++ b/packages/react/src/drafts/ActionBar/ActionBar.stories.tsx @@ -0,0 +1,127 @@ +import React from 'react' +import type {Meta} from '@storybook/react' +import ActionBar from '.' +import { + BoldIcon, + CodeIcon, + ItalicIcon, + SearchIcon, + LinkIcon, + FileAddedIcon, + HeadingIcon, + QuoteIcon, + ListUnorderedIcon, + ListOrderedIcon, + TasklistIcon, +} from '@primer/octicons-react' +import {MarkdownInput} from '../MarkdownEditor/_MarkdownInput' +import {ViewSwitch} from '../MarkdownEditor/_ViewSwitch' +import type {MarkdownViewMode} from '../MarkdownEditor/_ViewSwitch' +import {Box} from '../..' + +export default { + title: 'Drafts/Components/ActionBar', +} as Meta + +export const Default = () => ( + + + + + + + + + + + + + + + +) + +export const SmallActionBar = () => ( + + + + + + + + + +) + +export const CommentBox = () => { + const [view, setView] = React.useState('edit') + const loadPreview = React.useCallback(() => { + //console.log('loadPreview') + }, []) + const [value, setValue] = React.useState('') + return ( + + + + + + + + + + + + + + + + + + + + + { + setValue(e.target.value) + }} + id={'markdowninput'} + isDraggedOver={false} + minHeightLines={5} + maxHeightLines={35} + monospace={false} + pasteUrlsAsPlainText={false} + /> + + ) +} diff --git a/packages/react/src/drafts/ActionBar/ActionBar.tsx b/packages/react/src/drafts/ActionBar/ActionBar.tsx new file mode 100644 index 00000000000..189b5fc8a91 --- /dev/null +++ b/packages/react/src/drafts/ActionBar/ActionBar.tsx @@ -0,0 +1,357 @@ +import type {RefObject, MutableRefObject} from 'react' +import React, {useState, useCallback, useRef, forwardRef} from 'react' +import {KebabHorizontalIcon} from '@primer/octicons-react' +import {ActionList} from '../../ActionList' +import useIsomorphicLayoutEffect from '../../utils/useIsomorphicLayoutEffect' +import styled from 'styled-components' +import sx from '../../sx' +import {useOnEscapePress} from '../../hooks/useOnEscapePress' +import type {ResizeObserverEntry} from '../../hooks/useResizeObserver' +import {useResizeObserver} from '../../hooks/useResizeObserver' + +import {useOnOutsideClick} from '../../hooks/useOnOutsideClick' +import type {IconButtonProps} from '../../Button' +import {IconButton} from '../../Button' +import Box from '../../Box' + +type ChildSize = { + text: string + width: number +} +type ChildWidthArray = Array +type ResponsiveProps = { + items: Array + menuItems: Array +} + +const ActionBarContext = React.createContext<{ + size: Size + setChildrenWidth: React.Dispatch<{text: string; width: number}> +}>({size: 'medium', setChildrenWidth: () => null}) + +/* +small (28px), medium (32px), large (40px) +*/ +type Size = 'small' | 'medium' | 'large' + +export type ActionBarProps = { + size?: Size + 'aria-label'?: React.AriaAttributes['aria-label'] + children: React.ReactNode +} + +export type ActionBarIconButtonProps = IconButtonProps + +const NavigationList = styled.ul` + ${sx}; +` + +const MORE_BTN_HEIGHT = 45 +const GAP = 8 +const MoreMenuListItem = styled.li` + display: flex; + align-items: center; + height: ${MORE_BTN_HEIGHT}px; +` + +const ulStyles = { + display: 'flex', + listStyle: 'none', + whiteSpace: 'nowrap', + paddingY: 0, + paddingX: 0, + margin: 0, + marginBottom: '-1px', + alignItems: 'center', + gap: `${GAP}px`, + position: 'relative', +} + +const menuStyles = { + position: 'absolute', + zIndex: 1, + top: '90%', + right: '0', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24)', + borderRadius: '12px', + backgroundColor: 'canvas.overlay', + listStyle: 'none', + // Values are from ActionMenu + minWidth: '192px', + maxWidth: '640px', +} + +const MORE_BTN_WIDTH = 86 +const getNavStyles = () => ({ + display: 'flex', + paddingX: 3, + justifyContent: 'flex-end', + align: 'row', + alignItems: 'center', + maxHeight: '32px', +}) + +const menuItemStyles = { + textDecoration: 'none', +} + +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, +} + +const getValidChildren = (children: React.ReactNode) => { + return React.Children.toArray(children).filter(child => { + return React.isValidElement(child) + }) as React.ReactElement[] +} + +const calculatePossibleItems = (childWidthArray: ChildWidthArray, navWidth: number, moreMenuWidth = 0) => { + const widthToFit = navWidth - moreMenuWidth + let breakpoint = childWidthArray.length // assume all items will fit + let sumsOfChildWidth = 0 + for (const [index, childWidth] of childWidthArray.entries()) { + sumsOfChildWidth = sumsOfChildWidth + childWidth.width // + GAP + if (sumsOfChildWidth > widthToFit) { + breakpoint = index + break + } else { + continue + } + } + return breakpoint +} + +const overflowEffect = ( + navWidth: number, + moreMenuWidth: number, + childArray: Array, + childWidthArray: ChildWidthArray, + updateListAndMenu: (props: ResponsiveProps) => void, +) => { + if (childWidthArray.length === 0) { + updateListAndMenu({items: childArray, menuItems: []}) + } + const numberOfItemsPossible = calculatePossibleItems(childWidthArray, navWidth) + + const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems( + childWidthArray, + navWidth, + moreMenuWidth || MORE_BTN_WIDTH, + ) + const items: Array = [] + const menuItems: Array = [] + + // First, we check if we can fit all the items with their icons + if (childArray.length > numberOfItemsPossible) { + /* Below is an accessibility requirement. Never show only one item in the overflow menu. + * If there is only one item left to display in the overflow menu according to the calculation, + * we need to pull another item from the list into the overflow menu. + */ + const numberOfItemsInMenu = childArray.length - numberOfItemsPossibleWithMoreMenu + const numberOfListItems = + numberOfItemsInMenu === 1 ? numberOfItemsPossibleWithMoreMenu - 1 : numberOfItemsPossibleWithMoreMenu + for (const [index, child] of childArray.entries()) { + if (index < numberOfListItems) { + items.push(child) + //if the last item is a divider + } else if (childWidthArray[index].text === 'divider') { + if (index === numberOfListItems - 1 || index === numberOfListItems) { + continue + } else { + const divider = React.createElement(ActionList.Divider, {key: index}) + menuItems.push(divider) + } + } else { + menuItems.push(child) + } + } + + updateListAndMenu({items, menuItems}) + } +} + +export const ActionBar: React.FC> = props => { + const {size = 'medium', children, 'aria-label': ariaLabel} = props + const [childWidthArray, setChildWidthArray] = useState([]) + const setChildrenWidth = useCallback((size: ChildSize) => { + setChildWidthArray(arr => { + const newArr = [...arr, size] + return newArr + }) + }, []) + + const navRef = useRef(null) + const listRef = useRef(null) + const moreMenuRef = useRef(null) + const moreMenuBtnRef = useRef(null) + const containerRef = React.useRef(null) + const disclosureWidgetId = React.useId() + + const validChildren = getValidChildren(children) + // Responsive props object manages which items are in the list and which items are in the menu. + const [responsiveProps, setResponsiveProps] = useState({ + items: validChildren, + menuItems: [], + }) + + // Make sure to have the fresh props data for list items when children are changed (keeping aria-current up-to-date) + const listItems = responsiveProps.items.map(item => { + return validChildren.find(child => child.key === item.key) ?? item + }) + + // Make sure to have the fresh props data for menu items when children are changed (keeping aria-current up-to-date) + const menuItems = responsiveProps.menuItems.map(menuItem => { + return validChildren.find(child => child.key === menuItem.key) ?? menuItem + }) + + const updateListAndMenu = useCallback((props: ResponsiveProps) => { + setResponsiveProps(props) + }, []) + + useResizeObserver((resizeObserverEntries: ResizeObserverEntry[]) => { + const navWidth = resizeObserverEntries[0].contentRect.width + const moreMenuWidth = moreMenuRef.current?.getBoundingClientRect().width ?? 0 + navWidth !== 0 && overflowEffect(navWidth, moreMenuWidth, validChildren, childWidthArray, updateListAndMenu) + }, navRef as RefObject) + + const [isWidgetOpen, setIsWidgetOpen] = useState(false) + + const closeOverlay = React.useCallback(() => { + setIsWidgetOpen(false) + }, [setIsWidgetOpen]) + + const focusOnMoreMenuBtn = React.useCallback(() => { + moreMenuBtnRef.current?.focus() + }, []) + + const onAnchorClick = useCallback((event: React.MouseEvent) => { + if (event.defaultPrevented || event.button !== 0) { + return + } + setIsWidgetOpen(isWidgetOpen => !isWidgetOpen) + }, []) + + useOnEscapePress( + (event: KeyboardEvent) => { + if (isWidgetOpen) { + event.preventDefault() + closeOverlay() + focusOnMoreMenuBtn() + } + }, + [isWidgetOpen], + ) + + useOnOutsideClick({onClickOutside: closeOverlay, containerRef, ignoreClickRefs: [moreMenuBtnRef]}) + + return ( + + + + {listItems} + {menuItems.length > 0 && ( + + + + {menuItems.map((menuItem, index) => { + if (menuItem.type === ActionList.Divider) { + return + } else { + const { + children: menuItemChildren, + //'aria-current': ariaCurrent, + onSelect, + icon: Icon, + 'aria-label': ariaLabel, + } = menuItem.props + return ( + | React.KeyboardEvent, + ) => { + closeOverlay() + focusOnMoreMenuBtn() + typeof onSelect === 'function' && onSelect(event) + }} + > + {Icon ? ( + + + + ) : null} + {ariaLabel} + + ) + } + })} + + + )} + + + + ) +} + +export const ActionBarIconButton = forwardRef((props: ActionBarIconButtonProps, forwardedRef) => { + const backupRef = useRef(null) + const ref = (forwardedRef ?? backupRef) as RefObject + const {size, setChildrenWidth} = React.useContext(ActionBarContext) + useIsomorphicLayoutEffect(() => { + const text = props['aria-label'] ? props['aria-label'] : '' + const domRect = (ref as MutableRefObject).current.getBoundingClientRect() + setChildrenWidth({text, width: domRect.width}) + }, [ref, setChildrenWidth]) + return +}) + +const sizeToHeight = { + small: '24px', + medium: '28px', + large: '32px', +} +export const VerticalDivider = () => { + const ref = useRef(null) + const {size, setChildrenWidth} = React.useContext(ActionBarContext) + useIsomorphicLayoutEffect(() => { + const text = 'divider' + const domRect = (ref as MutableRefObject).current.getBoundingClientRect() + setChildrenWidth({text, width: domRect.width}) + }, [ref, setChildrenWidth]) + return ( +