From da0bebe68ba0b4f7f3cbf5e2f6fd9a054f1fd4a7 Mon Sep 17 00:00:00 2001 From: george treviranus Date: Wed, 26 Jun 2024 09:55:13 -0500 Subject: [PATCH 1/6] feat(dropdowns): enables menu item as link --- packages/dropdowns/demo/stories/data.ts | 7 ++ packages/dropdowns/src/elements/menu/Item.tsx | 114 +++++++++++++++--- packages/dropdowns/src/elements/menu/utils.ts | 2 + packages/dropdowns/src/types/index.ts | 8 +- .../src/views/combobox/StyledOption.ts | 5 +- packages/dropdowns/src/views/index.ts | 2 + .../src/views/menu/StyledHiddenLabel.ts | 25 ++++ .../dropdowns/src/views/menu/StyledItem.ts | 10 +- .../src/views/menu/StyledItemAnchor.ts | 31 +++++ .../src/views/menu/StyledItemContent.ts | 8 +- .../src/views/menu/StyledItemIcon.ts | 35 +++++- 11 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 packages/dropdowns/src/views/menu/StyledHiddenLabel.ts create mode 100644 packages/dropdowns/src/views/menu/StyledItemAnchor.ts diff --git a/packages/dropdowns/demo/stories/data.ts b/packages/dropdowns/demo/stories/data.ts index 1f1f1f00ea9..f01e29e9a75 100644 --- a/packages/dropdowns/demo/stories/data.ts +++ b/packages/dropdowns/demo/stories/data.ts @@ -21,6 +21,13 @@ export const ITEMS: Items = [ value: 'separator', isSeparator: true }, + { + value: 'item-anchor', + label: 'Item link', + href: 'https://garden.zendesk.com', + externalAnchorLabel: '(opens in new window)', + isExternal: false + }, { value: 'item-meta', label: 'Item', diff --git a/packages/dropdowns/src/elements/menu/Item.tsx b/packages/dropdowns/src/elements/menu/Item.tsx index b0872637e6c..df880bdf085 100644 --- a/packages/dropdowns/src/elements/menu/Item.tsx +++ b/packages/dropdowns/src/elements/menu/Item.tsx @@ -5,30 +5,53 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { LiHTMLAttributes, MutableRefObject, forwardRef, useMemo } from 'react'; +import React, { + AnchorHTMLAttributes, + LiHTMLAttributes, + MutableRefObject, + forwardRef, + useMemo +} from 'react'; import PropTypes from 'prop-types'; import { mergeRefs } from 'react-merge-refs'; +import { useText } from '@zendeskgarden/react-theming'; import AddIcon from '@zendeskgarden/svg-icons/src/16/plus-stroke.svg'; import NextIcon from '@zendeskgarden/svg-icons/src/16/chevron-right-stroke.svg'; import PreviousIcon from '@zendeskgarden/svg-icons/src/16/chevron-left-stroke.svg'; import CheckedIcon from '@zendeskgarden/svg-icons/src/16/check-lg-stroke.svg'; -import { IItemProps, OptionType as ItemType, OPTION_TYPE } from '../../types'; -import { StyledItem, StyledItemContent, StyledItemIcon, StyledItemTypeIcon } from '../../views'; +import NewWindowIcon from '@zendeskgarden/svg-icons/src/12/new-window-stroke.svg'; +import { IItemProps, OPTION_TYPE, OptionType } from '../../types'; +import { + StyledHiddenLabel, + StyledItem, + StyledItemAnchor, + StyledItemContent, + StyledItemIcon, + StyledItemTypeIcon +} from '../../views'; import { ItemMeta } from './ItemMeta'; import useMenuContext from '../../context/useMenuContext'; import useItemGroupContext from '../../context/useItemGroupContext'; import { ItemContext } from '../../context/useItemContext'; import { toItem } from './utils'; +/** + * 1. role='img' on `svg` is valid WAI-ARIA usage in this context. + * https://dequeuniversity.com/rules/axe/4.2/svg-img-alt + */ + const ItemComponent = forwardRef( ( { children, value, label = value, + href, isSelected, icon, isDisabled, + isExternal, + externalAnchorLabel, type, name, onClick, @@ -47,7 +70,9 @@ const ItemComponent = forwardRef( name, type, isSelected, - isDisabled + isDisabled, + href, + isExternal }), type: selectionType }; @@ -59,10 +84,29 @@ const ItemComponent = forwardRef( onMouseEnter }) as LiHTMLAttributes & { ref: MutableRefObject }; + const hasAnchor = !!href; + const hasExternalLink = hasAnchor && isExternal; + + if (hasAnchor) { + if (type && OPTION_TYPE.includes(type)) { + throw new Error(`Menu item '${value}' can't use type '${type}'`); + } else if (selectionType) { + throw new Error(`Menu item '${value}' can't use selection type '${selectionType}'`); + } + } + + const _externalAnchorLabel = useText( + ItemComponent, + { externalAnchorLabel }, + 'externalAnchorLabel', + '(opens in a new tab)', + hasExternalLink + ); + const isActive = value === focusedValue; - const renderActionIcon = (iconType?: ItemType) => { - switch (iconType) { + const renderActionIcon = (itemType?: OptionType) => { + switch (itemType) { case 'add': return ; @@ -79,25 +123,55 @@ const ItemComponent = forwardRef( const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]); + const itemChildren = ( + <> + + {renderActionIcon(type)} + + {icon && ( + + {icon} + + )} + + {children || label} + {hasExternalLink && ( + + + + )} + {hasExternalLink && {_externalAnchorLabel}} + + + ); + + const menuItemProps = { + isCompact, + isActive, + $type: type, + ...props, + ...itemProps, + ref: mergeRefs([_itemRef, ref]) + }; + return ( - - {renderActionIcon(type)} - - {icon && ( - - {icon} - + {hasAnchor ? ( + ))} + href={href} + $hasAnchor + > + {itemChildren} + + ) : ( + itemChildren )} - {children || label} ); diff --git a/packages/dropdowns/src/elements/menu/utils.ts b/packages/dropdowns/src/elements/menu/utils.ts index 1f7c664a078..544bb2ecc5a 100644 --- a/packages/dropdowns/src/elements/menu/utils.ts +++ b/packages/dropdowns/src/elements/menu/utils.ts @@ -22,7 +22,9 @@ export const toItem = ( value: props.value, label: props.label, ...(props.name && { name: props.name }), + ...(props.href && { href: props.href }), ...(props.isDisabled && { disabled: props.isDisabled }), + ...(props.isExternal && { isExternal: props.isExternal }), ...(props.isSelected && { selected: props.isSelected }), ...(props.selectionType && { type: props.selectionType }), ...(props.type === 'next' && { isNext: true }), diff --git a/packages/dropdowns/src/types/index.ts b/packages/dropdowns/src/types/index.ts index b13aed95b8e..4a66bf04399 100644 --- a/packages/dropdowns/src/types/index.ts +++ b/packages/dropdowns/src/types/index.ts @@ -278,14 +278,20 @@ export interface IMenuProps extends HTMLAttributes { } export interface IItemProps extends Omit, 'value'> { + /** Provides localized label for external anchor items */ + externalAnchorLabel?: string; /** Accepts an icon to display */ icon?: ReactElement; /** Indicates that the item is not interactive */ isDisabled?: boolean; + /** If the item is an anchor, opens the link externally */ + isExternal?: boolean; /** Determines the initial selection state for the item */ isSelected?: boolean; - /** Sets the text label of the item (defaults to `value`) */ + /** Provides the text label of the item (defaults to `value`) */ label?: string; + /** Sets the item as an anchor */ + href?: string; /** Associates the item in a radio item group */ name?: string; /** Determines the item type */ diff --git a/packages/dropdowns/src/views/combobox/StyledOption.ts b/packages/dropdowns/src/views/combobox/StyledOption.ts index fa4b1838051..6c72194c1ac 100644 --- a/packages/dropdowns/src/views/combobox/StyledOption.ts +++ b/packages/dropdowns/src/views/combobox/StyledOption.ts @@ -15,10 +15,11 @@ const COMPONENT_ID = 'dropdowns.combobox.option'; export interface IStyledOptionProps extends ThemeProps { isActive?: boolean; isCompact?: boolean; + $hasAnchor?: boolean; $type?: OptionType | 'header' | 'group'; } -const colorStyles = ({ theme, isActive, $type }: IStyledOptionProps) => { +const colorStyles = ({ theme, isActive, $type, $hasAnchor }: IStyledOptionProps) => { let backgroundColor; let boxShadow; @@ -33,7 +34,7 @@ const colorStyles = ({ theme, isActive, $type }: IStyledOptionProps) => { let foregroundVariable; - if ($type === 'add') { + if ($hasAnchor || $type === 'add') { foregroundVariable = 'foreground.primary'; } else if ($type === 'danger') { foregroundVariable = 'foreground.danger'; diff --git a/packages/dropdowns/src/views/index.ts b/packages/dropdowns/src/views/index.ts index e459f2e27a3..1f32613efde 100644 --- a/packages/dropdowns/src/views/index.ts +++ b/packages/dropdowns/src/views/index.ts @@ -31,9 +31,11 @@ export * from './combobox/StyledValue'; export * from './menu/StyledMenu'; export * from './menu/StyledFloatingMenu'; export * from './menu/StyledItem'; +export * from './menu/StyledItemAnchor'; export * from './menu/StyledItemContent'; export * from './menu/StyledItemGroup'; export * from './menu/StyledItemIcon'; export * from './menu/StyledItemMeta'; export * from './menu/StyledItemTypeIcon'; +export * from './menu/StyledHiddenLabel'; export * from './menu/StyledSeparator'; diff --git a/packages/dropdowns/src/views/menu/StyledHiddenLabel.ts b/packages/dropdowns/src/views/menu/StyledHiddenLabel.ts new file mode 100644 index 00000000000..a23abbf0329 --- /dev/null +++ b/packages/dropdowns/src/views/menu/StyledHiddenLabel.ts @@ -0,0 +1,25 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import styled from 'styled-components'; +import { retrieveComponentStyles, DEFAULT_THEME } from '@zendeskgarden/react-theming'; +import { hideVisually } from 'polished'; + +const COMPONENT_ID = 'dropdowns.menu.item_hidden_label'; + +export const StyledHiddenLabel = styled.span.attrs({ + 'data-garden-id': COMPONENT_ID, + 'data-garden-version': PACKAGE_VERSION +})` + ${hideVisually()} + + ${props => retrieveComponentStyles(COMPONENT_ID, props)}; +`; + +StyledHiddenLabel.defaultProps = { + theme: DEFAULT_THEME +}; diff --git a/packages/dropdowns/src/views/menu/StyledItem.ts b/packages/dropdowns/src/views/menu/StyledItem.ts index 73a8239c516..181d93d19d7 100644 --- a/packages/dropdowns/src/views/menu/StyledItem.ts +++ b/packages/dropdowns/src/views/menu/StyledItem.ts @@ -7,14 +7,20 @@ import styled from 'styled-components'; import { retrieveComponentStyles, DEFAULT_THEME } from '@zendeskgarden/react-theming'; -import { StyledOption } from '../combobox/StyledOption'; +import { IStyledOptionProps, StyledOption } from '../combobox/StyledOption'; const COMPONENT_ID = 'dropdowns.menu.item'; +interface IStyledItemProps extends IStyledOptionProps { + $hasAnchor?: boolean; +} + export const StyledItem = styled(StyledOption).attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION -})` +})` + padding: ${p => p.$hasAnchor && 0}; + ${props => retrieveComponentStyles(COMPONENT_ID, props)}; `; diff --git a/packages/dropdowns/src/views/menu/StyledItemAnchor.ts b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts new file mode 100644 index 00000000000..23b9e3118b5 --- /dev/null +++ b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts @@ -0,0 +1,31 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import styled from 'styled-components'; +import { retrieveComponentStyles, DEFAULT_THEME } from '@zendeskgarden/react-theming'; +import { StyledOption } from '../combobox/StyledOption'; + +const COMPONENT_ID = 'dropdowns.menu.item_anchor'; + +interface IStyledItemAnchor { + $hasAnchor: boolean; +} + +export const StyledItemAnchor = styled(StyledOption as 'a').attrs({ + 'data-garden-id': COMPONENT_ID, + 'data-garden-version': PACKAGE_VERSION, + as: 'a' +})` + flex: 1; + direction: ${props => props.theme.rtl && 'rtl'}; + + ${props => retrieveComponentStyles(COMPONENT_ID, props)}; +`; + +StyledItemAnchor.defaultProps = { + theme: DEFAULT_THEME +}; diff --git a/packages/dropdowns/src/views/menu/StyledItemContent.ts b/packages/dropdowns/src/views/menu/StyledItemContent.ts index 4db0f90401b..1028193e8f7 100644 --- a/packages/dropdowns/src/views/menu/StyledItemContent.ts +++ b/packages/dropdowns/src/views/menu/StyledItemContent.ts @@ -11,10 +11,16 @@ import { StyledOptionContent } from '../combobox/StyledOptionContent'; const COMPONENT_ID = 'dropdowns.menu.item.content'; +interface IStyledItemContentProps { + $hasExternalLink?: boolean; +} + export const StyledItemContent = styled(StyledOptionContent).attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION -})` +})` + flex-direction: ${p => p.$hasExternalLink && 'row'}; + ${props => retrieveComponentStyles(COMPONENT_ID, props)}; `; diff --git a/packages/dropdowns/src/views/menu/StyledItemIcon.ts b/packages/dropdowns/src/views/menu/StyledItemIcon.ts index b84ae305462..82ce77de43c 100644 --- a/packages/dropdowns/src/views/menu/StyledItemIcon.ts +++ b/packages/dropdowns/src/views/menu/StyledItemIcon.ts @@ -5,16 +5,47 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import styled from 'styled-components'; +import styled, { css, DefaultTheme, ThemeProps } from 'styled-components'; import { retrieveComponentStyles, DEFAULT_THEME } from '@zendeskgarden/react-theming'; import { StyledOptionIcon } from '../combobox/StyledOptionIcon'; const COMPONENT_ID = 'dropdowns.menu.item.icon'; +interface IStyledItemIconProps { + $isExternalLinkIcon?: boolean; +} + +const colorStyles = ({ $isExternalLinkIcon }: IStyledItemIconProps) => { + return css` + color: ${$isExternalLinkIcon && 'inherit'}; + `; +}; + +const sizeStyles = ({ + $isExternalLinkIcon, + theme +}: IStyledItemIconProps & ThemeProps) => { + const size = theme.iconSizes.sm; + const marginHorizontal = `${theme.space.base * 2}px`; + + return css` + align-self: ${$isExternalLinkIcon && 'center'}; + margin-${theme.rtl ? 'right' : 'left'}: ${$isExternalLinkIcon && marginHorizontal}; + width: ${$isExternalLinkIcon && size}; + height: ${$isExternalLinkIcon && size}; + `; +}; + export const StyledItemIcon = styled(StyledOptionIcon).attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION -})` +})` + transform: ${p => p.$isExternalLinkIcon && p.theme.rtl && 'scaleX(-1)'}; + + ${sizeStyles} + + ${colorStyles} + ${props => retrieveComponentStyles(COMPONENT_ID, props)}; `; From a2fb9cdbd79f385c8edad38ed4047bb86fe54213 Mon Sep 17 00:00:00 2001 From: Florent Mathieu Date: Mon, 21 Apr 2025 11:35:37 -1000 Subject: [PATCH 2/6] refactor(menu): remove external link icon, fix keyboard behavior on link, clean up --- packages/dropdowns/src/elements/menu/Item.tsx | 89 +++++++------------ packages/dropdowns/src/types/index.ts | 2 - .../src/views/combobox/StyledOption.ts | 5 +- packages/dropdowns/src/views/index.ts | 1 - .../src/views/menu/StyledHiddenLabel.ts | 25 ------ .../dropdowns/src/views/menu/StyledItem.ts | 10 +-- .../src/views/menu/StyledItemAnchor.ts | 16 +--- .../src/views/menu/StyledItemContent.ts | 10 +-- .../src/views/menu/StyledItemIcon.ts | 37 +------- 9 files changed, 46 insertions(+), 149 deletions(-) delete mode 100644 packages/dropdowns/src/views/menu/StyledHiddenLabel.ts diff --git a/packages/dropdowns/src/elements/menu/Item.tsx b/packages/dropdowns/src/elements/menu/Item.tsx index e9dea97206e..48734793e76 100644 --- a/packages/dropdowns/src/elements/menu/Item.tsx +++ b/packages/dropdowns/src/elements/menu/Item.tsx @@ -14,15 +14,13 @@ import React, { } from 'react'; import PropTypes from 'prop-types'; import { mergeRefs } from 'react-merge-refs'; -import { useText } from '@zendeskgarden/react-theming'; import AddIcon from '@zendeskgarden/svg-icons/src/16/plus-stroke.svg'; import NextIcon from '@zendeskgarden/svg-icons/src/16/chevron-right-stroke.svg'; import PreviousIcon from '@zendeskgarden/svg-icons/src/16/chevron-left-stroke.svg'; import CheckedIcon from '@zendeskgarden/svg-icons/src/16/check-lg-stroke.svg'; -import NewWindowIcon from '@zendeskgarden/svg-icons/src/12/new-window-stroke.svg'; + import { IItemProps, OPTION_TYPE, OptionType } from '../../types'; import { - StyledHiddenLabel, StyledItem, StyledItemAnchor, StyledItemContent, @@ -35,6 +33,21 @@ import useItemGroupContext from '../../context/useItemGroupContext'; import { ItemContext } from '../../context/useItemContext'; import { toItem } from './utils'; +const optionType = new Set(OPTION_TYPE); + +const renderActionIcon = (itemType?: OptionType) => { + switch (itemType) { + case 'add': + return ; + case 'next': + return ; + case 'previous': + return ; + default: + return ; + } +}; + /** * 1. role='img' on `svg` is valid WAI-ARIA usage in this context. * https://dequeuniversity.com/rules/axe/4.2/svg-img-alt @@ -50,8 +63,7 @@ const ItemComponent = forwardRef( isSelected, icon, isDisabled, - isExternal, - externalAnchorLabel, + isExternal = true, type, name, onClick, @@ -85,42 +97,15 @@ const ItemComponent = forwardRef( }) as LiHTMLAttributes & { ref: MutableRefObject }; const hasAnchor = !!href; - const hasExternalLink = hasAnchor && isExternal; if (hasAnchor) { - if (type && OPTION_TYPE.includes(type)) { + if (type && optionType.has(type)) { throw new Error(`Menu item '${value}' can't use type '${type}'`); } else if (selectionType) { throw new Error(`Menu item '${value}' can't use selection type '${selectionType}'`); } } - const _externalAnchorLabel = useText( - ItemComponent, - { externalAnchorLabel }, - 'externalAnchorLabel', - '(opens in a new tab)', - hasExternalLink - ); - - const isActive = value === focusedValue; - - const renderActionIcon = (itemType?: OptionType) => { - switch (itemType) { - case 'add': - return ; - - case 'next': - return ; - - case 'previous': - return ; - - default: - return ; - } - }; - const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]); const itemChildren = ( @@ -133,21 +118,13 @@ const ItemComponent = forwardRef( {icon} )} - - {children || label} - {!!hasExternalLink && ( - - - - )} - {!!hasExternalLink && {_externalAnchorLabel}} - + {children || label} ); const menuItemProps = { - isCompact, - isActive, + $isCompact: isCompact, + $isActive: value === focusedValue, $type: type, ...props, ...itemProps, @@ -156,23 +133,21 @@ const ItemComponent = forwardRef( return ( - - {hasAnchor ? ( + {hasAnchor ? ( +
  • ))} + {...(menuItemProps as AnchorHTMLAttributes)} href={href} - $hasAnchor + target={isExternal ? '_blank' : undefined} + // legacy browsers safeguards + rel={isExternal ? 'noopener noreferrer' : undefined} > {itemChildren} - ) : ( - itemChildren - )} - +
  • + ) : ( + {itemChildren} + )}
    ); } @@ -181,9 +156,11 @@ const ItemComponent = forwardRef( ItemComponent.displayName = 'Item'; ItemComponent.propTypes = { + href: PropTypes.string, icon: PropTypes.any, isDisabled: PropTypes.bool, isSelected: PropTypes.bool, + isExternal: PropTypes.bool, label: PropTypes.string, name: PropTypes.string, type: PropTypes.oneOf(OPTION_TYPE), diff --git a/packages/dropdowns/src/types/index.ts b/packages/dropdowns/src/types/index.ts index 4f6feb52e47..3c77a92a292 100644 --- a/packages/dropdowns/src/types/index.ts +++ b/packages/dropdowns/src/types/index.ts @@ -282,8 +282,6 @@ export interface IMenuProps extends HTMLAttributes { } export interface IItemProps extends Omit, 'value'> { - /** Provides localized label for external anchor items */ - externalAnchorLabel?: string; /** Accepts an icon to display */ icon?: ReactElement; /** Indicates that the item is not interactive */ diff --git a/packages/dropdowns/src/views/combobox/StyledOption.ts b/packages/dropdowns/src/views/combobox/StyledOption.ts index 6aa04f97fbf..dad9ceb2333 100644 --- a/packages/dropdowns/src/views/combobox/StyledOption.ts +++ b/packages/dropdowns/src/views/combobox/StyledOption.ts @@ -13,13 +13,12 @@ import { OptionType } from '../../types'; const COMPONENT_ID = 'dropdowns.combobox.option'; export interface IStyledOptionProps extends ThemeProps { - $hasAnchor?: boolean; $isActive?: boolean; $isCompact?: boolean; $type?: OptionType | 'header' | 'group'; } -const colorStyles = ({ theme, $hasAnchor, $isActive, $type }: IStyledOptionProps) => { +const colorStyles = ({ theme, $isActive, $type }: IStyledOptionProps) => { let backgroundColor; let boxShadow; @@ -34,7 +33,7 @@ const colorStyles = ({ theme, $hasAnchor, $isActive, $type }: IStyledOptionProps let foregroundVariable; - if ($hasAnchor || $type === 'add') { + if ($type === 'add') { foregroundVariable = 'foreground.primary'; } else if ($type === 'danger') { foregroundVariable = 'foreground.danger'; diff --git a/packages/dropdowns/src/views/index.ts b/packages/dropdowns/src/views/index.ts index 90776485a91..5b4e1cfc944 100644 --- a/packages/dropdowns/src/views/index.ts +++ b/packages/dropdowns/src/views/index.ts @@ -38,5 +38,4 @@ export * from './menu/StyledItemGroup'; export * from './menu/StyledItemIcon'; export * from './menu/StyledItemMeta'; export * from './menu/StyledItemTypeIcon'; -export * from './menu/StyledHiddenLabel'; export * from './menu/StyledSeparator'; diff --git a/packages/dropdowns/src/views/menu/StyledHiddenLabel.ts b/packages/dropdowns/src/views/menu/StyledHiddenLabel.ts deleted file mode 100644 index a23abbf0329..00000000000 --- a/packages/dropdowns/src/views/menu/StyledHiddenLabel.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright Zendesk, Inc. - * - * Use of this source code is governed under the Apache License, Version 2.0 - * found at http://www.apache.org/licenses/LICENSE-2.0. - */ - -import styled from 'styled-components'; -import { retrieveComponentStyles, DEFAULT_THEME } from '@zendeskgarden/react-theming'; -import { hideVisually } from 'polished'; - -const COMPONENT_ID = 'dropdowns.menu.item_hidden_label'; - -export const StyledHiddenLabel = styled.span.attrs({ - 'data-garden-id': COMPONENT_ID, - 'data-garden-version': PACKAGE_VERSION -})` - ${hideVisually()} - - ${props => retrieveComponentStyles(COMPONENT_ID, props)}; -`; - -StyledHiddenLabel.defaultProps = { - theme: DEFAULT_THEME -}; diff --git a/packages/dropdowns/src/views/menu/StyledItem.ts b/packages/dropdowns/src/views/menu/StyledItem.ts index 5e6363e74dc..ddac8a8c915 100644 --- a/packages/dropdowns/src/views/menu/StyledItem.ts +++ b/packages/dropdowns/src/views/menu/StyledItem.ts @@ -7,19 +7,13 @@ import styled from 'styled-components'; import { componentStyles } from '@zendeskgarden/react-theming'; -import { IStyledOptionProps, StyledOption } from '../combobox/StyledOption'; +import { StyledOption } from '../combobox/StyledOption'; const COMPONENT_ID = 'dropdowns.menu.item'; -interface IStyledItemProps extends IStyledOptionProps { - $hasAnchor?: boolean; -} - export const StyledItem = styled(StyledOption).attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION -})` - padding: ${p => p.$hasAnchor && 0}; - +})` ${componentStyles}; `; diff --git a/packages/dropdowns/src/views/menu/StyledItemAnchor.ts b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts index 23b9e3118b5..9fedcfd3acc 100644 --- a/packages/dropdowns/src/views/menu/StyledItemAnchor.ts +++ b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts @@ -6,26 +6,18 @@ */ import styled from 'styled-components'; -import { retrieveComponentStyles, DEFAULT_THEME } from '@zendeskgarden/react-theming'; +import { componentStyles, getColor } from '@zendeskgarden/react-theming'; import { StyledOption } from '../combobox/StyledOption'; const COMPONENT_ID = 'dropdowns.menu.item_anchor'; -interface IStyledItemAnchor { - $hasAnchor: boolean; -} - export const StyledItemAnchor = styled(StyledOption as 'a').attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION, as: 'a' -})` - flex: 1; +})` direction: ${props => props.theme.rtl && 'rtl'}; + color: ${({ theme }) => getColor({ theme, variable: 'foreground.primary' })}; - ${props => retrieveComponentStyles(COMPONENT_ID, props)}; + ${componentStyles}; `; - -StyledItemAnchor.defaultProps = { - theme: DEFAULT_THEME -}; diff --git a/packages/dropdowns/src/views/menu/StyledItemContent.ts b/packages/dropdowns/src/views/menu/StyledItemContent.ts index affcffdf751..fc7f32cf709 100644 --- a/packages/dropdowns/src/views/menu/StyledItemContent.ts +++ b/packages/dropdowns/src/views/menu/StyledItemContent.ts @@ -11,15 +11,9 @@ import { StyledOptionContent } from '../combobox/StyledOptionContent'; const COMPONENT_ID = 'dropdowns.menu.item.content'; -interface IStyledItemContentProps { - $hasExternalLink?: boolean; -} - export const StyledItemContent = styled(StyledOptionContent).attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION -})` - flex-direction: ${p => p.$hasExternalLink && 'row'}; - +})` ${componentStyles}; -})`; +`; diff --git a/packages/dropdowns/src/views/menu/StyledItemIcon.ts b/packages/dropdowns/src/views/menu/StyledItemIcon.ts index fc11eb96803..e4968e3fec6 100644 --- a/packages/dropdowns/src/views/menu/StyledItemIcon.ts +++ b/packages/dropdowns/src/views/menu/StyledItemIcon.ts @@ -5,46 +5,15 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import styled, { css, DefaultTheme, ThemeProps } from 'styled-components'; +import styled from 'styled-components'; import { componentStyles } from '@zendeskgarden/react-theming'; import { StyledOptionIcon } from '../combobox/StyledOptionIcon'; const COMPONENT_ID = 'dropdowns.menu.item.icon'; -interface IStyledItemIconProps { - $isExternalLinkIcon?: boolean; -} - -const colorStyles = ({ $isExternalLinkIcon }: IStyledItemIconProps) => { - return css` - color: ${$isExternalLinkIcon && 'inherit'}; - `; -}; - -const sizeStyles = ({ - $isExternalLinkIcon, - theme -}: IStyledItemIconProps & ThemeProps) => { - const size = theme.iconSizes.sm; - const marginHorizontal = `${theme.space.base * 2}px`; - - return css` - align-self: ${$isExternalLinkIcon && 'center'}; - margin-${theme.rtl ? 'right' : 'left'}: ${$isExternalLinkIcon && marginHorizontal}; - width: ${$isExternalLinkIcon && size}; - height: ${$isExternalLinkIcon && size}; - `; -}; - export const StyledItemIcon = styled(StyledOptionIcon).attrs({ 'data-garden-id': COMPONENT_ID, 'data-garden-version': PACKAGE_VERSION -})` - transform: ${p => p.$isExternalLinkIcon && p.theme.rtl && 'scaleX(-1)'}; - - ${sizeStyles} - - ${colorStyles} - +})` ${componentStyles}; -})`; +`; From cf77156c0098ea4080631364e56c038cd24cc8f9 Mon Sep 17 00:00:00 2001 From: Florent Mathieu Date: Mon, 21 Apr 2025 12:06:53 -1000 Subject: [PATCH 3/6] test(Menu): add Item with link specs --- packages/dropdowns/src/elements/menu/Item.tsx | 14 ++-- .../dropdowns/src/elements/menu/Menu.spec.tsx | 64 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/dropdowns/src/elements/menu/Item.tsx b/packages/dropdowns/src/elements/menu/Item.tsx index 48734793e76..fb71f5be2f6 100644 --- a/packages/dropdowns/src/elements/menu/Item.tsx +++ b/packages/dropdowns/src/elements/menu/Item.tsx @@ -89,13 +89,6 @@ const ItemComponent = forwardRef( type: selectionType }; - const { ref: _itemRef, ...itemProps } = getItemProps({ - item, - onClick, - onKeyDown, - onMouseEnter - }) as LiHTMLAttributes & { ref: MutableRefObject }; - const hasAnchor = !!href; if (hasAnchor) { @@ -106,6 +99,13 @@ const ItemComponent = forwardRef( } } + const { ref: _itemRef, ...itemProps } = getItemProps({ + item, + onClick, + onKeyDown, + onMouseEnter + }) as LiHTMLAttributes & { ref: MutableRefObject }; + const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]); const itemChildren = ( diff --git a/packages/dropdowns/src/elements/menu/Menu.spec.tsx b/packages/dropdowns/src/elements/menu/Menu.spec.tsx index 9166c4470ba..bf74269191f 100644 --- a/packages/dropdowns/src/elements/menu/Menu.spec.tsx +++ b/packages/dropdowns/src/elements/menu/Menu.spec.tsx @@ -686,4 +686,68 @@ describe('Menu', () => { expect(button).toHaveAttribute('data-garden-id', 'buttons.button'); }); }); + + describe('Item link behavior', () => { + it('renders with href as anchor tag', async () => { + const { getByTestId } = render( + + + Example Link + + + ); + await floating(); + const item = getByTestId('item'); + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href', 'https://example.com'); + expect(item).toHaveAttribute('target', '_blank'); + expect(item).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders with isExternal=false correctly', async () => { + const { getByTestId } = render( + + + Internal Link + + + ); + await floating(); + const item = getByTestId('item'); + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href', 'https://example.com'); + expect(item).not.toHaveAttribute('target'); + expect(item).not.toHaveAttribute('rel'); + }); + + it('throws error when href is used with a selection type', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + render( + + + + + + ); + }).toThrow(/can't use selection type/u); + + consoleSpy.mockRestore(); + }); + + it('throws error when href is used with option type', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + render( + + + + ); + }).toThrow(/can't use type/u); + + consoleSpy.mockRestore(); + }); + }); }); From 15e0ba4f51cb1194f703f24c770ce471eb825449 Mon Sep 17 00:00:00 2001 From: Florent Mathieu Date: Mon, 21 Apr 2025 12:07:26 -1000 Subject: [PATCH 4/6] chore: clean up story data --- packages/dropdowns/demo/stories/data.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/dropdowns/demo/stories/data.ts b/packages/dropdowns/demo/stories/data.ts index 9b9b16ed892..7e4c0a0d3ae 100644 --- a/packages/dropdowns/demo/stories/data.ts +++ b/packages/dropdowns/demo/stories/data.ts @@ -26,9 +26,7 @@ export const ITEMS: Items = [ { value: 'item-anchor', label: 'Item link', - href: 'https://garden.zendesk.com', - externalAnchorLabel: '(opens in new window)', - isExternal: false + href: 'https://garden.zendesk.com' }, { value: 'item-meta', From c4b0891375ed374ab7d890e347c00bd21d651d79 Mon Sep 17 00:00:00 2001 From: Florent Mathieu Date: Tue, 22 Apr 2025 14:20:07 -1000 Subject: [PATCH 5/6] chore: PR feedback --- packages/dropdowns/demo/stories/data.ts | 3 ++- packages/dropdowns/src/elements/menu/Item.tsx | 2 +- packages/dropdowns/src/elements/menu/Menu.spec.tsx | 2 +- packages/dropdowns/src/types/index.ts | 2 +- packages/dropdowns/src/views/menu/StyledItemAnchor.ts | 5 +++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/dropdowns/demo/stories/data.ts b/packages/dropdowns/demo/stories/data.ts index 7e4c0a0d3ae..800bdfbb1e7 100644 --- a/packages/dropdowns/demo/stories/data.ts +++ b/packages/dropdowns/demo/stories/data.ts @@ -26,7 +26,8 @@ export const ITEMS: Items = [ { value: 'item-anchor', label: 'Item link', - href: 'https://garden.zendesk.com' + href: 'https://garden.zendesk.com', + isExternal: true }, { value: 'item-meta', diff --git a/packages/dropdowns/src/elements/menu/Item.tsx b/packages/dropdowns/src/elements/menu/Item.tsx index fb71f5be2f6..f85d07c8469 100644 --- a/packages/dropdowns/src/elements/menu/Item.tsx +++ b/packages/dropdowns/src/elements/menu/Item.tsx @@ -63,7 +63,7 @@ const ItemComponent = forwardRef( isSelected, icon, isDisabled, - isExternal = true, + isExternal, type, name, onClick, diff --git a/packages/dropdowns/src/elements/menu/Menu.spec.tsx b/packages/dropdowns/src/elements/menu/Menu.spec.tsx index bf74269191f..093c0576773 100644 --- a/packages/dropdowns/src/elements/menu/Menu.spec.tsx +++ b/packages/dropdowns/src/elements/menu/Menu.spec.tsx @@ -691,7 +691,7 @@ describe('Menu', () => { it('renders with href as anchor tag', async () => { const { getByTestId } = render( - + Example Link diff --git a/packages/dropdowns/src/types/index.ts b/packages/dropdowns/src/types/index.ts index 3c77a92a292..c6e21dd8917 100644 --- a/packages/dropdowns/src/types/index.ts +++ b/packages/dropdowns/src/types/index.ts @@ -286,7 +286,7 @@ export interface IItemProps extends Omit, 'value icon?: ReactElement; /** Indicates that the item is not interactive */ isDisabled?: boolean; - /** If the item is an anchor, opens the link externally */ + /** Opens the `href` externally */ isExternal?: boolean; /** Determines the initial selection state for the item */ isSelected?: boolean; diff --git a/packages/dropdowns/src/views/menu/StyledItemAnchor.ts b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts index 9fedcfd3acc..e88625c8c7c 100644 --- a/packages/dropdowns/src/views/menu/StyledItemAnchor.ts +++ b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts @@ -6,7 +6,7 @@ */ import styled from 'styled-components'; -import { componentStyles, getColor } from '@zendeskgarden/react-theming'; +import { componentStyles } from '@zendeskgarden/react-theming'; import { StyledOption } from '../combobox/StyledOption'; const COMPONENT_ID = 'dropdowns.menu.item_anchor'; @@ -17,7 +17,8 @@ export const StyledItemAnchor = styled(StyledOption as 'a').attrs({ as: 'a' })` direction: ${props => props.theme.rtl && 'rtl'}; - color: ${({ theme }) => getColor({ theme, variable: 'foreground.primary' })}; + text-decoration: none; + color: unset; ${componentStyles}; `; From 81aa849303563ab62c130b397e5fe4e77df58bb0 Mon Sep 17 00:00:00 2001 From: Florent Mathieu Date: Mon, 28 Apr 2025 07:44:17 -1000 Subject: [PATCH 6/6] docs(dropdowns menu): test story within shadowRoot --- package-lock.json | 8 +- packages/dropdowns/demo/stories/MenuStory.tsx | 121 +++++++++++------- packages/dropdowns/package.json | 2 +- packages/dropdowns/src/elements/menu/Menu.tsx | 8 +- zendeskgarden-container-menu-0.6.0.tgz | Bin 0 -> 9353 bytes 5 files changed, 86 insertions(+), 53 deletions(-) create mode 100644 zendeskgarden-container-menu-0.6.0.tgz diff --git a/package-lock.json b/package-lock.json index 2bc2f7e0712..a6bb282a2af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12197,9 +12197,9 @@ } }, "node_modules/@zendeskgarden/container-menu": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@zendeskgarden/container-menu/-/container-menu-0.5.1.tgz", - "integrity": "sha512-ctbuQGHSjmsGqKmJ9uyk1TvUjA4Im6xF64VpLwJhYwsmQV4ddp3/tMxu3t2gaB+cG9GsjJLzGVOuTfjwhVQlJg==", + "version": "0.6.0", + "resolved": "file:zendeskgarden-container-menu-0.6.0.tgz", + "integrity": "sha512-L8kESo082EAm6phyie45ygGElBOlyE7ZJpkqzDdrhkOkx8o+5QAn91tIJkdQtiWd7C+o94nJ4y+XCYrpcJWPLA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.8.4", @@ -51904,7 +51904,7 @@ "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@zendeskgarden/container-combobox": "^2.0.0", - "@zendeskgarden/container-menu": "^0.5.1", + "@zendeskgarden/container-menu": "file:../../zendeskgarden-container-menu-0.6.0.tgz", "@zendeskgarden/container-utilities": "^2.0.0", "@zendeskgarden/react-buttons": "^9.5.4", "@zendeskgarden/react-forms": "^9.5.4", diff --git a/packages/dropdowns/demo/stories/MenuStory.tsx b/packages/dropdowns/demo/stories/MenuStory.tsx index 9059d8564ee..154114aa158 100644 --- a/packages/dropdowns/demo/stories/MenuStory.tsx +++ b/packages/dropdowns/demo/stories/MenuStory.tsx @@ -6,11 +6,15 @@ */ import React from 'react'; +import { createPortal } from 'react-dom'; +import { StyleSheetManager } from 'styled-components'; + import { StoryFn } from '@storybook/react'; import LeafIcon from '@zendeskgarden/svg-icons/src/16/leaf-stroke.svg'; import CartIcon from '@zendeskgarden/svg-icons/src/16/shopping-cart-stroke.svg'; import { Grid } from '@zendeskgarden/react-grid'; import { IMenuProps, Item, ItemGroup, Separator, Menu } from '@zendeskgarden/react-dropdowns'; +import { IGardenTheme, ThemeProvider } from '@zendeskgarden/react-theming'; import { IconButton } from '@zendeskgarden/react-buttons'; import { ButtonType, IItem, Items } from './types'; @@ -29,50 +33,77 @@ interface IArgs extends IMenuProps { label: string; } -export const MenuStory: StoryFn = ({ button, items, label, ...args }) => ( - - - -
    - ( - - - - ) - } - > - {items.map(item => { - if ('items' in item) { - return ( - : undefined} - > - {item.items.map(groupItem => ( - - ))} - - ); - } +const Portal = ({ children, target }: any) => { + if (!target) return null; + + return createPortal(children, target); +}; + +export const MenuStory: StoryFn = ({ button, items, label, ...args }) => { + const ref = React.useRef(null); + const [document, setDocument] = React.useState(undefined); + + React.useEffect(() => { + if (ref.current && !ref.current.shadowRoot) { + const shadowRoot = ref.current?.attachShadow({ mode: 'open' }); - if ('isSeparator' in item) { - return ; - } + setDocument(shadowRoot); + } + }, []); - return ; - })} - -
    -
    -
    -
    -); + return ( +
    + + + ({ ...theme, document })}> + + + +
    + ( + + + + ) + } + > + {items.map(item => { + if ('items' in item) { + return ( + : undefined} + > + {item.items.map(groupItem => ( + + ))} + + ); + } + + if ('isSeparator' in item) { + return ; + } + + return ; + })} + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/packages/dropdowns/package.json b/packages/dropdowns/package.json index 7f4d60dc9ee..1846d4e6cac 100644 --- a/packages/dropdowns/package.json +++ b/packages/dropdowns/package.json @@ -23,7 +23,7 @@ "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@zendeskgarden/container-combobox": "^2.0.0", - "@zendeskgarden/container-menu": "^0.5.1", + "@zendeskgarden/container-menu": "file:../../zendeskgarden-container-menu-0.6.0.tgz", "@zendeskgarden/container-utilities": "^2.0.0", "@zendeskgarden/react-buttons": "^9.5.4", "@zendeskgarden/react-forms": "^9.5.4", diff --git a/packages/dropdowns/src/elements/menu/Menu.tsx b/packages/dropdowns/src/elements/menu/Menu.tsx index 5381ebb3f58..88dcfb0ce51 100644 --- a/packages/dropdowns/src/elements/menu/Menu.tsx +++ b/packages/dropdowns/src/elements/menu/Menu.tsx @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { mergeRefs } from 'react-merge-refs'; import { ThemeContext } from 'styled-components'; import { useMenu } from '@zendeskgarden/container-menu'; -import { DEFAULT_THEME, useWindow } from '@zendeskgarden/react-theming'; +import { DEFAULT_THEME, useDocument, useWindow } from '@zendeskgarden/react-theming'; import { Button, IButtonProps } from '@zendeskgarden/react-buttons'; import { IMenuProps, PLACEMENT } from '../../types'; import { MenuContext } from '../../context/useMenuContext'; @@ -45,7 +45,8 @@ export const Menu = forwardRef( const items = toItems(children); /* istanbul ignore next */ const theme = useContext(ThemeContext) || DEFAULT_THEME; - const environment = useWindow(theme); + const _window = useWindow(theme); + const document = useDocument(theme); const { isExpanded, @@ -56,8 +57,9 @@ export const Menu = forwardRef( getItemGroupProps, getSeparatorProps } = useMenu({ + document, rtl: theme.rtl, - environment, + window: _window, defaultFocusedValue, focusedValue: _focusedValue, defaultExpanded, diff --git a/zendeskgarden-container-menu-0.6.0.tgz b/zendeskgarden-container-menu-0.6.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6d056ecaf3b3144fd22499c4941dd876db8023a8 GIT binary patch literal 9353 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