;
diff --git a/packages/gamut/src/Menu/__tests__/Menu.test.tsx b/packages/gamut/src/Menu/__tests__/Menu.test.tsx
index 883c39d1604..c659fad5490 100644
--- a/packages/gamut/src/Menu/__tests__/Menu.test.tsx
+++ b/packages/gamut/src/Menu/__tests__/Menu.test.tsx
@@ -106,13 +106,26 @@ describe('Menu', () => {
children: ,
});
- expect(screen.queryByTestId('menuitem-icon')).toBeNull();
+ expect(screen.queryByRole('img', { hidden: true })).toBeNull();
renderView({
children: ,
});
- screen.getByTestId('menuitem-icon');
+ screen.getByRole('img', { hidden: true });
+ });
+ it('renders two icons when provided an array of two icons', () => {
+ renderView({
+ children: (
+
+ ),
+ });
+
+ const icons = screen.getAllByRole('img', { hidden: true });
+ expect(icons).toHaveLength(2);
+ screen.getByText('Busy Town');
});
it('renders `current page` screenreader text for active link', () => {
renderView({
@@ -141,7 +154,7 @@ describe('Menu', () => {
children: ,
});
- expect(screen.queryByTestId('menuitem-icon')).toBeNull();
+ expect(screen.queryByRole('img', { hidden: true })).toBeNull();
renderView({
children: ,
diff --git a/packages/gamut/src/Tip/PreviewTip/index.tsx b/packages/gamut/src/Tip/PreviewTip/index.tsx
index 88421963e23..d309441a553 100644
--- a/packages/gamut/src/Tip/PreviewTip/index.tsx
+++ b/packages/gamut/src/Tip/PreviewTip/index.tsx
@@ -8,7 +8,8 @@ import {
useState,
} from 'react';
-import { Anchor, Text } from '../..';
+import { Text } from '../..';
+import { Anchor } from '../../Anchor';
import { FloatingTip } from '../shared/FloatingTip';
import { InlineTip } from '../shared/InlineTip';
import {
diff --git a/packages/gamut/src/helpers/__tests__/appendIconToContent.test.tsx b/packages/gamut/src/helpers/__tests__/appendIconToContent.test.tsx
new file mode 100644
index 00000000000..f324c3684f6
--- /dev/null
+++ b/packages/gamut/src/helpers/__tests__/appendIconToContent.test.tsx
@@ -0,0 +1,153 @@
+import { MiniWarningTriangleIcon, StarIcon } from '@codecademy/gamut-icons';
+import { setupRtl } from '@codecademy/gamut-tests';
+import { screen } from '@testing-library/react';
+
+import { AppendedIconProps, appendIconToContent } from '../appendIconToContent';
+
+const TestAppendIconToContent = (props: AppendedIconProps) => (
+ {appendIconToContent(props)}
+);
+
+const renderView = setupRtl(TestAppendIconToContent, {
+ children: Test content
,
+ icon: StarIcon,
+});
+
+describe('appendIconToContent', () => {
+ describe('when no icon is provided', () => {
+ it('returns only children', () => {
+ const { view } = renderView({ icon: undefined });
+
+ expect(view.getByText('Test content')).toBeInTheDocument();
+ expect(
+ screen.queryByRole('img', { hidden: true })
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when icon is provided', () => {
+ it('renders icon in the left position', () => {
+ const { view } = renderView({ iconPosition: 'left' });
+
+ const wrapper = view.getByTestId('wrapper');
+ const icon = screen.getByRole('img', { hidden: true });
+ const textNode = view.getByText('Test content');
+
+ expect(textNode).toBeInTheDocument();
+ expect(icon).toBeInTheDocument();
+
+ // Check that icon appears before text in DOM order
+ const allNodes = Array.from(wrapper.firstElementChild?.children || []);
+ const iconIndex = allNodes.indexOf(icon);
+ const textIndex = allNodes.findIndex((node) =>
+ node.textContent?.includes('Test content')
+ );
+
+ expect(iconIndex).toBeLessThan(textIndex);
+ });
+
+ it('renders icon in the right position', () => {
+ const { view } = renderView({ iconPosition: 'right' });
+
+ const wrapper = view.getByTestId('wrapper');
+ const icon = screen.getByRole('img', { hidden: true });
+ const textNode = view.getByText('Test content');
+
+ expect(textNode).toBeInTheDocument();
+ expect(icon).toBeInTheDocument();
+
+ // Check that icon appears after text in DOM order
+ const allNodes = Array.from(wrapper.firstElementChild?.children || []);
+ const iconIndex = allNodes.indexOf(icon);
+ const textIndex = allNodes.findIndex((node) =>
+ node.textContent?.includes('Test content')
+ );
+
+ expect(iconIndex).toBeGreaterThan(textIndex);
+ });
+ });
+
+ describe('layout modes', () => {
+ it('renders inline layout when isInlineIcon is true', () => {
+ const { view } = renderView({ isInlineIcon: true });
+
+ const wrapper = view.getByTestId('wrapper');
+ const layoutWrapper = wrapper.firstElementChild;
+
+ expect(view.getByText('Test content')).toBeInTheDocument();
+ expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
+
+ expect(layoutWrapper).toHaveStyle({ display: 'inline' });
+ });
+
+ it('renders flex layout when isInlineIcon is false', () => {
+ const { view } = renderView({ isInlineIcon: false });
+
+ const wrapper = view.getByTestId('wrapper');
+ const layoutWrapper = wrapper.firstElementChild;
+
+ expect(view.getByText('Test content')).toBeInTheDocument();
+ expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
+
+ expect(layoutWrapper).toHaveStyle({ display: 'flex' });
+ });
+ });
+
+ describe('when icons array is provided', () => {
+ it('renders both icons with children in between', () => {
+ const { view } = renderView({
+ icon: [StarIcon, MiniWarningTriangleIcon],
+ });
+
+ const wrapper = view.getByTestId('wrapper');
+ const icons = screen.getAllByRole('img', { hidden: true });
+ const textNode = view.getByText('Test content');
+
+ expect(textNode).toBeInTheDocument();
+ expect(icons).toHaveLength(2);
+
+ // Check that first icon appears before text and second icon appears after text in DOM order
+ const allNodes = Array.from(wrapper.firstElementChild?.children || []);
+ const firstIconIndex = allNodes.indexOf(icons[0]);
+ const secondIconIndex = allNodes.indexOf(icons[1]);
+ const textIndex = allNodes.findIndex((node) =>
+ node.textContent?.includes('Test content')
+ );
+
+ expect(firstIconIndex).toBeLessThan(textIndex);
+ expect(secondIconIndex).toBeGreaterThan(textIndex);
+ });
+ });
+
+ describe('layout modes for multi icons', () => {
+ it('renders inline layout when isInlineIcon is true with multiple icons', () => {
+ const { view } = renderView({
+ isInlineIcon: true,
+ icon: [StarIcon, MiniWarningTriangleIcon],
+ });
+
+ const wrapper = view.getByTestId('wrapper');
+ const layoutWrapper = wrapper.firstElementChild;
+
+ expect(view.getByText('Test content')).toBeInTheDocument();
+ expect(screen.getAllByRole('img', { hidden: true })).toHaveLength(2);
+
+ expect(layoutWrapper).toHaveStyle({ display: 'inline' });
+ });
+
+ it('renders flex layout when isInlineIcon is false with multiple icons', () => {
+ const { view } = renderView({
+ isInlineIcon: false,
+ icon: [StarIcon, MiniWarningTriangleIcon],
+ });
+
+ const wrapper = view.getByTestId('wrapper');
+ const layoutWrapper = wrapper.firstElementChild;
+
+ expect(view.getByText('Test content')).toBeInTheDocument();
+ expect(screen.getAllByRole('img', { hidden: true })).toHaveLength(2);
+
+ expect(layoutWrapper).toHaveStyle({ display: 'flex' });
+ });
+ });
+});
diff --git a/packages/gamut/src/helpers/appendIconToContent.tsx b/packages/gamut/src/helpers/appendIconToContent.tsx
index 77d5be48e12..534007a42e9 100644
--- a/packages/gamut/src/helpers/appendIconToContent.tsx
+++ b/packages/gamut/src/helpers/appendIconToContent.tsx
@@ -1,10 +1,10 @@
+import { GamutIconProps } from '@codecademy/gamut-icons';
+
import { Box, FlexBox } from '../Box';
-import { IconComponentType, WithChildrenProp } from '../utils';
+import { WithChildrenProp } from '../utils';
import { pixelToEm } from './pixelToEmCalc';
-export interface AppendedIconProps
- extends WithChildrenProp,
- Partial {
+interface BaseAppendedIconProps extends WithChildrenProp {
/**
* This provides the space between the icon and the children
*/
@@ -13,10 +13,6 @@ export interface AppendedIconProps
* This value adds a padding to the icon's parent container and bumps up the icon's height by the offset
*/
iconOffset?: number;
- /**
- * Can set the positioning of the icon relative to children, default is `left`
- */
- iconPosition?: 'left' | 'right';
/**
* This value determines the size of the icon
*/
@@ -26,64 +22,212 @@ export interface AppendedIconProps
*/
isInlineIcon?: boolean;
}
+export interface AppendedSingleIconProps extends BaseAppendedIconProps {
+ icon?: React.ComponentType;
+ /**
+ * Can set the positioning of the icon relative to children, default is `left`
+ */
+ iconPosition?: 'left' | 'right';
+}
-export const appendIconToContent = ({
- children,
- icon: Icon,
- iconAndTextGap = 8,
- iconOffset,
- iconPosition,
- iconSize = 12,
- isInlineIcon = false,
-}: AppendedIconProps) => {
- if (!Icon) return <>{children}>;
+export interface AppendedMultipleIconsProps extends BaseAppendedIconProps {
+ /**
+ * If there are multiple icons, this prop should be an array of two icon components.
+ */
+ icon?: [
+ React.ComponentType,
+ React.ComponentType
+ ];
+ /**
+ * This prop is not needed when there are multiple icons since both icons should be rendered on each side.
+ */
+ iconPosition?: never;
+}
- const iconSpacing = iconPosition === 'left' ? 'mr' : 'ml';
- const iconPositioning = iconPosition === 'left' ? 0 : 1;
+export type AppendedIconProps =
+ | AppendedSingleIconProps
+ | AppendedMultipleIconsProps;
- if (typeof iconOffset !== 'number') {
- iconOffset = isInlineIcon ? 2 : 4;
- }
+interface RenderStyledIconProps
+ extends Pick<
+ BaseRenderProps,
+ 'iconAndTextGap' | 'iconSize' | 'iconOffsetInEm' | 'heightOffset'
+ > {
+ icon: React.ComponentType;
+ spacing: 'mr' | 'ml';
+ isInlineIcon: boolean;
+}
- const iconOffsetInEm = pixelToEm(iconOffset);
- const heightOffset = pixelToEm(iconSize + iconOffset);
+interface BaseRenderProps {
+ children: React.ReactNode;
+ iconAndTextGap: number;
+ iconOffsetInEm: string;
+ heightOffset: string;
+ iconSize: number;
+ isInlineIcon: boolean;
+}
+
+interface RenderSingleIconProps extends BaseRenderProps {
+ icon: Required['icon'];
+ iconPosition?: 'left' | 'right';
+}
+interface RenderMultipleIconsProps extends BaseRenderProps {
+ icon: Required['icon'];
+}
- const iconProps = {
+const renderStyledIcon = ({
+ icon: Icon,
+ spacing,
+ iconAndTextGap,
+ iconOffsetInEm,
+ heightOffset,
+ iconSize,
+ isInlineIcon,
+}: RenderStyledIconProps) => {
+ const baseIconProps = {
'aria-hidden': true,
size: iconSize,
- [iconSpacing]: iconAndTextGap,
- order: iconPositioning,
+ [spacing]: iconAndTextGap,
} as const;
- const InlineCenteredIcon = (
-
+ if (isInlineIcon) {
+ return (
+
+ );
+ }
+
+ return ;
+};
+
+const wrapContent = (content: React.ReactNode, isInlineIcon: boolean) =>
+ isInlineIcon ? (
+ {content}
+ ) : (
+
+ {content}
+
);
+const appendSingleIcon = ({
+ icon: Icon,
+ children,
+ iconAndTextGap,
+ iconOffsetInEm,
+ heightOffset,
+ iconSize,
+ isInlineIcon,
+ iconPosition = 'left',
+}: RenderSingleIconProps) => {
+ const iconSpacing = iconPosition === 'left' ? 'mr' : 'ml';
+
+ const styledIcon = renderStyledIcon({
+ icon: Icon,
+ spacing: iconSpacing,
+ iconAndTextGap,
+ iconOffsetInEm,
+ heightOffset,
+ iconSize,
+ isInlineIcon,
+ });
+
const content =
iconPosition === 'left' ? (
<>
- {InlineCenteredIcon}
+ {styledIcon}
{children}
>
) : (
<>
{children}
- {InlineCenteredIcon}
+ {styledIcon}
>
);
- return isInlineIcon ? (
- {content}
- ) : (
-
- {Icon && }
+ return wrapContent(content, isInlineIcon);
+};
+
+const appendMultipleIcons = ({
+ icon: Icon,
+ children,
+ iconAndTextGap,
+ iconOffsetInEm,
+ heightOffset,
+ iconSize,
+ isInlineIcon,
+}: RenderMultipleIconsProps) => {
+ const [LeftIcon, RightIcon] = Icon;
+
+ const leftIcon = renderStyledIcon({
+ icon: LeftIcon,
+ spacing: 'mr',
+ iconAndTextGap,
+ iconOffsetInEm,
+ heightOffset,
+ iconSize,
+ isInlineIcon,
+ });
+
+ const rightIcon = renderStyledIcon({
+ icon: RightIcon,
+ spacing: 'ml',
+ iconAndTextGap,
+ iconOffsetInEm,
+ heightOffset,
+ iconSize,
+ isInlineIcon,
+ });
+
+ const content = (
+ <>
+ {leftIcon}
{children}
-
+ {rightIcon}
+ >
);
+ return wrapContent(content, isInlineIcon);
+};
+
+export const appendIconToContent = ({
+ children,
+ icon: Icon,
+ iconAndTextGap = 8,
+ iconOffset,
+ iconPosition,
+ iconSize = 12,
+ isInlineIcon = false,
+}: AppendedIconProps) => {
+ if (!Icon) return <>{children}>;
+
+ const finalIconOffset = iconOffset ?? (isInlineIcon ? 2 : 4);
+ const iconOffsetInEm = pixelToEm(finalIconOffset);
+ const heightOffset = pixelToEm(iconSize + finalIconOffset);
+
+ if (Array.isArray(Icon)) {
+ return appendMultipleIcons({
+ children,
+ heightOffset,
+ icon: Icon,
+ iconAndTextGap,
+ iconOffsetInEm,
+ iconSize,
+ isInlineIcon,
+ });
+ }
+
+ return appendSingleIcon({
+ children,
+ heightOffset,
+ icon: Icon,
+ iconAndTextGap,
+ iconOffsetInEm,
+ iconPosition,
+ iconSize,
+ isInlineIcon,
+ });
};
diff --git a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx b/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx
index a066afedc59..e9ae5876362 100644
--- a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx
+++ b/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.mdx
@@ -54,7 +54,9 @@ The examples below use `FillButton` to showcase the `primary` and `secondary` bu
## Inline icons
-Our FillButton , StrokeButton , and TextButton all support a single inline icon. You can align the icon to the left or right of the button text using the `iconPosition` prop. These icons should be from the mini set to be legible at a smaller size.
+Our FillButton , StrokeButton , and TextButton components all support leading and trailing inline icons. These icons should be from the mini set to be legible at a smaller size.
+
+If you want a single icon, provide the `icon` prop with a single Gamut icon and specify the `iconPosition` prop as either `left` or `right`. If you want to use multiple icons, pass an array of two Gamut icons to the `icon` prop and omit the `iconPosition`.
diff --git a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx b/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx
index 04c709d85e4..3d0d1ffce2d 100644
--- a/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx
+++ b/packages/styleguide/src/lib/Atoms/Buttons/Button/Button.stories.tsx
@@ -12,6 +12,7 @@ import {
MiniArrowRightIcon,
MiniDeleteIcon,
MiniRibbonIcon,
+ MiniStarIcon,
SearchIcon,
} from '@codecademy/gamut-icons';
import type { Meta, StoryObj } from '@storybook/react';
@@ -42,12 +43,15 @@ export const Secondary: Story = {
export const InlineIcons: Story = {
render: () => (
-
+
FillButton
-
- Leading icon
+
+ StrokeButton
-
+
>
),
@@ -65,7 +68,7 @@ export const Popover: Story = {
render: (args) => (
),
@@ -107,7 +116,7 @@ export const Fixed: Story = {
diff --git a/packages/styleguide/src/lib/Typography/Anchor/Anchor.examples.tsx b/packages/styleguide/src/lib/Typography/Anchor/Anchor.examples.tsx
index 5d6fbad9827..fb68d7bb189 100644
--- a/packages/styleguide/src/lib/Typography/Anchor/Anchor.examples.tsx
+++ b/packages/styleguide/src/lib/Typography/Anchor/Anchor.examples.tsx
@@ -25,7 +25,7 @@ const variants = [
export const VariantsExample = ({ useIcon }: { useIcon: boolean }) => {
return (
-
+
{
))}
-
+
+By default the icons will be NOT be inline.
+
+
+
+You can set `variant="inline"` to render the icons inline with the text. This is useful for links that are part of a paragraph or text-heavy content.
+
+
Icons are also responsive to ColorMode.
@@ -101,6 +108,12 @@ Our Anchors are polymorphic - they can be rendered as a button or a link compone
+### Disabled Anchor
+
+The `Anchor` component can be disabled by passing the `disabled` prop. Since `` elements do not support the `disabled` attribute, the `Anchor` component will render as a `