diff --git a/docs/src/pages/[platform]/components/link/LinkPropControls.tsx b/docs/src/pages/[platform]/components/link/LinkPropControls.tsx index 7d90fc69aa6..beac13db913 100644 --- a/docs/src/pages/[platform]/components/link/LinkPropControls.tsx +++ b/docs/src/pages/[platform]/components/link/LinkPropControls.tsx @@ -10,6 +10,10 @@ import { export interface LinkPropControlsProps extends LinkProps { setColor: (value: React.SetStateAction) => void; setIsExternal: (value: React.SetStateAction) => void; + setHideIcon: (value: React.SetStateAction) => void; + setLinkIconPosition: ( + value: React.SetStateAction + ) => void; setTextDecoration: ( value: React.SetStateAction ) => void; @@ -25,6 +29,10 @@ export const LinkPropControls: LinkPropControlsInterface = ({ setColor, isExternal, setIsExternal, + hideIcon, + setHideIcon, + linkIconPosition, + setLinkIconPosition, textDecoration, setTextDecoration, children, @@ -49,8 +57,8 @@ export const LinkPropControls: LinkPropControlsInterface = ({ > - @@ -58,9 +66,36 @@ export const LinkPropControls: LinkPropControlsInterface = ({ setIsExternal(e.target.checked)} + onChange={(e) => { + setIsExternal(e.target.checked); + setHideIcon(false); + setLinkIconPosition('right'); + }} label="isExternal" /> + + {isExternal ? ( + <> + setHideIcon(e.target.checked)} + label="hideIcon" + /> + + {!hideIcon ? ( + + setLinkIconPosition(event.target.value as string) + } + label="linkIconPosition" + > + + + + ) : null} + + ) : null} ); }; diff --git a/docs/src/pages/[platform]/components/link/demo.tsx b/docs/src/pages/[platform]/components/link/demo.tsx index 854a4019f3c..d8248807e3f 100644 --- a/docs/src/pages/[platform]/components/link/demo.tsx +++ b/docs/src/pages/[platform]/components/link/demo.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Link, LinkProps } from '@aws-amplify/ui-react'; - import { Demo } from '@/components/Demo'; import { LinkPropControls } from './LinkPropControls'; import { useLinkProps } from './useLinkProps'; @@ -15,6 +14,12 @@ const propsToCode = (props: LinkProps) => { ? `\n textDecoration="${props.textDecoration}"` : '') + (props.isExternal ? `\n isExternal={${props.isExternal}}` : '') + + (props.isExternal && props.hideIcon + ? `\n hideIcon={${props.hideIcon}}` + : '') + + (props.isExternal && !props.hideIcon && props.linkIconPosition !== 'right' + ? `\n linkIconPosition="${props.linkIconPosition}"` + : '') + '\n>' + `\n ${props.children}\n` ); @@ -22,8 +27,10 @@ const propsToCode = (props: LinkProps) => { const defaultLinkProps = { isExternal: false, - color: '#007EB9', + hideIcon: false, + color: '#007eb9', textDecoration: 'none', + linkIconPosition: 'right', children: 'My Demo Link', }; @@ -41,7 +48,9 @@ export const LinkDemo = () => { href="https://ui.docs.amplify.aws/react/components/link" color={linkProps.color} isExternal={linkProps.isExternal} + hideIcon={linkProps.hideIcon} textDecoration={linkProps.textDecoration} + linkIconPosition={linkProps.linkIconPosition} > {linkProps.children} diff --git a/docs/src/pages/[platform]/components/link/useLinkProps.tsx b/docs/src/pages/[platform]/components/link/useLinkProps.tsx index da691cc2950..a19e4b27de3 100644 --- a/docs/src/pages/[platform]/components/link/useLinkProps.tsx +++ b/docs/src/pages/[platform]/components/link/useLinkProps.tsx @@ -1,7 +1,6 @@ import { Link, LinkProps } from '@aws-amplify/ui-react'; import * as React from 'react'; import { demoState } from '@/utils/demoState'; - import { LinkPropControlsProps } from './LinkPropControls'; interface UseLinkProps { @@ -12,6 +11,12 @@ export const useLinkProps: UseLinkProps = (initialValues) => { const [isExternal, setIsExternal] = React.useState( initialValues.isExternal ); + const [hideIcon, setHideIcon] = React.useState( + initialValues.hideIcon + ); + const [linkIconPosition, setLinkIconPosition] = React.useState< + LinkProps['linkIconPosition'] + >(initialValues.linkIconPosition); const [color, setColor] = React.useState( initialValues.color ); @@ -25,16 +30,22 @@ export const useLinkProps: UseLinkProps = (initialValues) => { React.useEffect(() => { demoState.set(Link.displayName, { isExternal, + hideIcon, + linkIconPosition, color, textDecoration, children, }); - }, [isExternal, color, textDecoration, children]); + }, [isExternal, hideIcon, linkIconPosition, color, textDecoration, children]); return React.useMemo( () => ({ isExternal, setIsExternal, + hideIcon, + setHideIcon, + linkIconPosition, + setLinkIconPosition, color, setColor, textDecoration, @@ -45,6 +56,10 @@ export const useLinkProps: UseLinkProps = (initialValues) => { [ isExternal, setIsExternal, + hideIcon, + setHideIcon, + linkIconPosition, + setLinkIconPosition, color, setColor, textDecoration, diff --git a/packages/react/__tests__/__snapshots__/exports.ts.snap b/packages/react/__tests__/__snapshots__/exports.ts.snap index fcf7a7415c7..31ce436d0d8 100644 --- a/packages/react/__tests__/__snapshots__/exports.ts.snap +++ b/packages/react/__tests__/__snapshots__/exports.ts.snap @@ -1540,6 +1540,15 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "backgroundImage": { "type": "string", }, + "badgeContent": { + "type": "string", + }, + "badgePosition": { + "type": "string", + }, + "badgeVariation": { + "type": "string", + }, "basis": { "type": "string", }, @@ -1619,6 +1628,9 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "grow": { "type": "string", }, + "hasBadge": { + "type": "boolean", + }, "height": { "type": "string", }, @@ -6493,6 +6505,9 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "height": { "type": "string", }, + "hideIcon": { + "type": "boolean", + }, "href": { "type": "string", }, @@ -6517,6 +6532,9 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "lineHeight": { "type": "string", }, + "linkIconPosition": { + "type": "string", + }, "margin": { "type": "string", }, @@ -7237,6 +7255,15 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "backgroundImage": { "type": "string", }, + "badgeContent": { + "type": "string", + }, + "badgePosition": { + "type": "string", + }, + "badgeVariation": { + "type": "string", + }, "basis": { "type": "string", }, @@ -7312,6 +7339,9 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "grow": { "type": "string", }, + "hasBadge": { + "type": "boolean", + }, "height": { "type": "string", }, @@ -7526,6 +7556,15 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "backgroundImage": { "type": "string", }, + "badgeContent": { + "type": "string", + }, + "badgePosition": { + "type": "string", + }, + "badgeVariation": { + "type": "string", + }, "basis": { "type": "string", }, @@ -7601,6 +7640,9 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "grow": { "type": "string", }, + "hasBadge": { + "type": "boolean", + }, "height": { "type": "string", }, @@ -15315,6 +15357,15 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "backgroundImage": { "type": "string", }, + "badgeContent": { + "type": "string", + }, + "badgePosition": { + "type": "string", + }, + "badgeVariation": { + "type": "string", + }, "basis": { "type": "string", }, @@ -15393,6 +15444,9 @@ exports[`primitive catalog should match primitives catalog snapshot 1`] = ` "grow": { "type": "string", }, + "hasBadge": { + "type": "boolean", + }, "height": { "type": "string", }, diff --git a/packages/react/src/primitives/Button/Button.tsx b/packages/react/src/primitives/Button/Button.tsx index 2cf9c1eb42e..7ef44e9e46c 100644 --- a/packages/react/src/primitives/Button/Button.tsx +++ b/packages/react/src/primitives/Button/Button.tsx @@ -1,8 +1,5 @@ import * as React from 'react'; -import { classNames } from '@aws-amplify/ui'; - -import { ComponentClassName } from '@aws-amplify/ui'; - +import { classNames, ComponentClassName } from '@aws-amplify/ui'; import { classNameModifier, classNameModifierByFlag } from '../shared/utils'; import { BaseButtonProps, @@ -17,6 +14,7 @@ import { useFieldset } from '../Fieldset/useFieldset'; import { Flex } from '../Flex'; import { Loader } from '../Loader'; import { View } from '../View'; +import { Badge } from '../Badge'; // These variations support colorThemes. 'undefined' accounts for our // 'default' variation which is not named. @@ -34,6 +32,10 @@ const ButtonPrimitive: Primitive = ( size, type = 'button', variation, + hasBadge = false, + badgeContent = '', + badgePosition = 'top-right', + badgeVariation, ...rest }, ref @@ -70,6 +72,16 @@ const ButtonPrimitive: Primitive = ( className ); + const badgeClasses = classNames( + ComponentClassName.ButtonBadge, + classNameModifier(ComponentClassName.ButtonBadge, badgePosition), + // Add padding to the badge if the button is not a link + classNameModifier( + ComponentClassName.ButtonBadge, + variation === 'link' ? badgePosition : `${badgePosition}--padding` + ) + ); + return ( = ( type={type} {...rest} > + {hasBadge ? ( + + {badgeContent} + + ) : null} + {isLoading ? ( diff --git a/packages/react/src/primitives/Icon/icons/IconBoxArrowUpRight.tsx b/packages/react/src/primitives/Icon/icons/IconBoxArrowUpRight.tsx new file mode 100644 index 00000000000..1d8dc0d140f --- /dev/null +++ b/packages/react/src/primitives/Icon/icons/IconBoxArrowUpRight.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { classNames, ComponentClassName } from '@aws-amplify/ui'; +import { View } from '../../View'; +import { InternalIcon } from './types'; + +/** + * @internal For internal Amplify UI use only. May be removed in a future release. + */ + +export const IconBoxArrowUpRight: InternalIcon = (props) => { + const { className, ...rest } = props; + + return ( + + + + + + + ); +}; diff --git a/packages/react/src/primitives/Link/Link.tsx b/packages/react/src/primitives/Link/Link.tsx index 3b9cbf55a42..12e74189a2a 100644 --- a/packages/react/src/primitives/Link/Link.tsx +++ b/packages/react/src/primitives/Link/Link.tsx @@ -1,7 +1,5 @@ import * as React from 'react'; -import { classNames } from '@aws-amplify/ui'; - -import { ComponentClassName } from '@aws-amplify/ui'; +import { ComponentClassName, classNames } from '@aws-amplify/ui'; import { BaseLinkProps, LinkProps, @@ -10,11 +8,23 @@ import { } from '../types'; import { View } from '../View'; import { primitiveWithForwardRef } from '../utils/primitiveWithForwardRef'; +import { IconBoxArrowUpRight } from '../Icon/icons/IconBoxArrowUpRight'; +import { classNameModifier } from '@aws-amplify/ui'; const LinkPrimitive: Primitive = ( - { as = 'a', children, className, isExternal, ...rest }, + { + as = 'a', + children, + className, + isExternal, + linkIconPosition, + hideIcon = false, + ...rest + }, ref ) => { + const shouldDisplayIcon = isExternal && !hideIcon; + return ( = ( target={isExternal ? '_blank' : undefined} {...rest} > - {children} + {linkIconPosition !== 'left' && children} + {shouldDisplayIcon && ( + + )} + {linkIconPosition === 'left' && children} ); }; /** - * [📖 Docs](https://ui.docs.amplify.aws/react/components/link) + * [:book: Docs](https://ui.docs.amplify.aws/react/components/link) */ + export const Link: ForwardRefPrimitive = primitiveWithForwardRef(LinkPrimitive); diff --git a/packages/react/src/primitives/Link/__tests__/Link.test.tsx b/packages/react/src/primitives/Link/__tests__/Link.test.tsx index 0f27bde9abd..11723edc9f4 100644 --- a/packages/react/src/primitives/Link/__tests__/Link.test.tsx +++ b/packages/react/src/primitives/Link/__tests__/Link.test.tsx @@ -7,7 +7,6 @@ import { Link } from '../Link'; import { Text } from '../../Text/Text'; import { Flex } from '../../Flex'; import { Heading } from '../../Heading'; - import { BrowserRouter as Router, Link as ReactRouterLink, @@ -34,7 +33,6 @@ function SampleRoutingApp() { About - } /> } /> @@ -77,6 +75,25 @@ describe('Link:', () => { expect(link).toHaveAttribute('rel', 'noopener noreferrer'); }); + it('should render svg icon when link is external', async () => { + render({linkText}); + + const link = await screen.findByText(linkText); + expect(link.children.length).toBe(1); + expect(link.children[0].tagName).toBe('SPAN'); + }); + + it('should not render svg icon when link is external and hideIcon props is set', async () => { + render( + + {linkText} + + ); + + const link = await screen.findByText(linkText); + expect(link.children.length).toBe(0); + }); + it('can render the Link tag as other components', async () => { render({linkText}); diff --git a/packages/react/src/primitives/types/button.ts b/packages/react/src/primitives/types/button.ts index 44ae607470a..af90fd9bd7a 100644 --- a/packages/react/src/primitives/types/button.ts +++ b/packages/react/src/primitives/types/button.ts @@ -1,6 +1,7 @@ import { Sizes } from './base'; import { ElementType, PrimitiveProps, BaseViewProps } from './view'; import { FlexContainerStyleProps } from './flex'; +import { BadgeVariations } from './badge'; export type ButtonSizes = Sizes; export type ButtonTypes = 'button' | 'reset' | 'submit'; @@ -18,6 +19,12 @@ export type ButtonColorTheme = | 'success' | 'overlay'; +export type ButtonBadgePosition = + | 'top-right' + | 'top-left' + | 'bottom-right' + | 'bottom-left'; + /** @deprecated For internal use only */ export interface BaseButtonProps extends BaseViewProps, @@ -80,6 +87,30 @@ export interface BaseButtonProps * Changes the visual weight of the button. */ variation?: ButtonVariations; + + /** + * @description + * If true the button will have a badge + */ + hasBadge?: boolean; + + /** + * @description + * The label to show in the badge associated with the button + */ + badgeContent?: string; + + /** + * @description + * The badge position + */ + badgePosition?: ButtonBadgePosition; + + /** + * @description + * The badge variation + */ + badgeVariation?: BadgeVariations; } export type ButtonProps = diff --git a/packages/react/src/primitives/types/link.ts b/packages/react/src/primitives/types/link.ts index 8f17a751e65..e83b2c24262 100644 --- a/packages/react/src/primitives/types/link.ts +++ b/packages/react/src/primitives/types/link.ts @@ -9,6 +9,20 @@ export interface LinkOptions { */ isExternal?: boolean; + /** + * @description + * string litteral value indicating the position of the icon + * position the icon to the left or to the right of the link text + */ + linkIconPosition?: 'left' | 'right'; + + /** + * @description + * Boolean value indicating to hide the icon + * hide the icon from the link if true + */ + hideIcon?: boolean; + /** * @description * a stringifier that returns a string containing the whole URL, and allows the href to be updated. diff --git a/packages/ui/src/icons/box_arrow_up_right.svg b/packages/ui/src/icons/box_arrow_up_right.svg new file mode 100644 index 00000000000..4379fbbde16 --- /dev/null +++ b/packages/ui/src/icons/box_arrow_up_right.svg @@ -0,0 +1,16 @@ + + + + diff --git a/packages/ui/src/theme/css/component/button.scss b/packages/ui/src/theme/css/component/button.scss index d6f7a7c7b40..a90fb086847 100644 --- a/packages/ui/src/theme/css/component/button.scss +++ b/packages/ui/src/theme/css/component/button.scss @@ -37,6 +37,7 @@ padding-inline-end: var(--amplify-components-button-padding-inline-end); transition: all var(--amplify-components-button-transition-duration); user-select: none; + position: relative; // These are the internal css variables that are used to update the values that are updated by multiple modifiers // Disabled @@ -1238,6 +1239,45 @@ align-items: var(--amplify-components-button-loader-wrapper-align-items); gap: var(--amplify-components-button-loader-wrapper-gap); } + + &__badge { + position: absolute; + gap: var(--amplify-components-button-loader-wrapper-gap); + // The badge padding has the same value so that an empty badge is a circle instead of a pill shape + padding: var(--amplify-components-badge-padding-vertical); + } + + &__badge--top-right { + top: 0px; + right: 0px; + &--padding { + transform: translate(50%, -50%); + } + } + + &__badge--top-left { + top: 0px; + left: 0px; + &--padding { + transform: translate(-50%, -50%); + } + } + + &__badge--bottom-right { + bottom: 0px; + right: 0px; + &--padding { + transform: translate(50%, 50%); + } + } + + &__badge--bottom-left { + bottom: 0px; + left: 0px; + &--padding { + transform: translate(-50%, 50%); + } + } } @media (prefers-reduced-motion: reduce) { diff --git a/packages/ui/src/theme/css/component/link.scss b/packages/ui/src/theme/css/component/link.scss index d4a0e080425..b6d9d4fecfa 100644 --- a/packages/ui/src/theme/css/component/link.scss +++ b/packages/ui/src/theme/css/component/link.scss @@ -4,6 +4,10 @@ // all:unset removes this, so we have to add it back. cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--amplify-space-xxxs); + &:visited { color: var(--amplify-components-link-visited-color); text-decoration: var(--amplify-components-link-visited-text-decoration); @@ -20,4 +24,7 @@ color: var(--amplify-components-link-hover-color); text-decoration: var(--amplify-components-link-hover-text-decoration); } + &--link-icon { + scale: 0.8; + } } diff --git a/packages/ui/src/types/primitives/componentClassName.ts b/packages/ui/src/types/primitives/componentClassName.ts index 8c8ce03850d..5ef262fcf5c 100644 --- a/packages/ui/src/types/primitives/componentClassName.ts +++ b/packages/ui/src/types/primitives/componentClassName.ts @@ -26,6 +26,7 @@ export const ComponentClassName = { Button: 'amplify-button', ButtonGroup: 'amplify-buttongroup', ButtonLoaderWrapper: 'amplify-button__loader-wrapper', + ButtonBadge: 'amplify-button__badge', Card: 'amplify-card', Checkbox: 'amplify-checkbox', CheckboxButton: 'amplify-checkbox__button',