Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/puny-paws-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@leafygreen-ui/icon': minor
---

Updated Icon to include <title> element when title is added
18 changes: 12 additions & 6 deletions packages/icon-button/src/IconButton/IconButton.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { axe } from 'jest-axe';

import EllipsisIcon from '@leafygreen-ui/icon/dist/Ellipsis';

import IconButton from '..';
import { IconButton } from '..';

const onClick = jest.fn();
const className = 'test-icon-button-class';
Expand Down Expand Up @@ -84,13 +84,19 @@ describe('packages/icon-button', () => {
expect(iconButton.tagName.toLowerCase()).toBe('a');
});

test(`when '${titleText}' is set directly as the title child icon, the rendered icon includes the title attribute, '${titleText}'`, () => {
const iconWithTitle = (
<EllipsisIcon data-testid="icon-test-id" title={titleText} />
test(`when '${titleText}' is set directly as the title child icon, the rendered icon includes a title element with '${titleText}'`, () => {
const { container } = render(
<IconButton aria-label="Ellipsis">
<EllipsisIcon title={titleText} />
</IconButton>,
);
const { icon } = renderIconButton({ children: iconWithTitle });

expect(icon.getAttribute('title')).toBe(titleText);
const icon = container.querySelector('[role="img"]');
expect(icon).toBeTruthy();

const titleElement = icon?.querySelector('title');
expect(titleElement).toBeTruthy();
expect(titleElement?.textContent).toBe(titleText);
});

/* eslint-disable jest/no-disabled-tests*/
Expand Down
1 change: 1 addition & 0 deletions packages/icon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"dependencies": {
"@leafygreen-ui/emotion": "workspace:^",
"@leafygreen-ui/hooks": "workspace:^",
"lodash": "^4.17.21"
},
"devDependencies": {
Expand Down
23 changes: 6 additions & 17 deletions packages/icon/scripts/prebuild/indexTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,19 @@ import { FileObject } from './prebuild.types';

export async function indexTemplate(svgFiles: Array<FileObject>) {
const imports = svgFiles
.map(({ name }) => `import ${name} from './${name}.svg';`)
.map(({ name }) => `import ${name} from '../generated/${name}';`)
.join('\n');

const _glyphs = `{
${svgFiles.map(({ name }) => `${name}`).join(',\n')}
}`;
const glyphsList = svgFiles.map(({ name }) => `${name}`).join(',\n ');

return `
import { createGlyphComponent } from '../createGlyphComponent';
import { LGGlyph } from '../types';

// Glyphs
${imports}

const _glyphs = ${_glyphs} as const;
export const glyphs = {
${glyphsList}
} as const;

export type GlyphName = keyof typeof _glyphs;

const glyphKeys = Object.keys(_glyphs) as Array<GlyphName>;

export const glyphs = glyphKeys.reduce((acc, name) => {
acc[name] = createGlyphComponent(name, _glyphs[name]);

return acc;
}, {} as Record<GlyphName, LGGlyph.Component>);
export type GlyphName = keyof typeof glyphs;
`;
}
40 changes: 37 additions & 3 deletions packages/icon/scripts/prebuild/svgrTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ interface ASTParts extends Record<string, any> {
}

export function svgrTemplate(
{ template }: BabelAPI,
{ state: { componentName } }: SVGROptions,
api: BabelAPI,
opts: SVGROptions,
{ imports, jsx, exports }: ASTParts,
) {
const { template, types: t } = api;
const { componentName } = opts.state;
const typeScriptTpl = template.smart({ plugins: ['jsx', 'typescript'] });

const jsxAttributes = typeScriptTpl.ast`
Expand All @@ -48,9 +50,40 @@ export function svgrTemplate(
jsx.openingElement.attributes[2],
);

// Convert self-closing svg to have children
jsx.openingElement.selfClosing = false;

// Create closing element using Babel types
jsx.closingElement = t.jsxClosingElement(
t.jsxIdentifier(jsx.openingElement.name.name),
);

// Create the title element JSX manually using Babel types
// {title && <title id={titleId}>{title}</title>}
const titleJSXElement = t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier('title'), [
t.jsxAttribute(
t.jsxIdentifier('id'),
t.jsxExpressionContainer(t.identifier('titleId')),
),
]),
t.jsxClosingElement(t.jsxIdentifier('title')),
[t.jsxExpressionContainer(t.identifier('title'))],
false,
);

// Wrap title in conditional expression: {title && <title>...</title>}
const conditionalTitleExpression = t.jsxExpressionContainer(
t.logicalExpression('&&', t.identifier('title'), titleJSXElement),
);

// Add the conditional title as a child, followed by the original children
jsx.children = [conditionalTitleExpression, ...jsx.children];

return typeScriptTpl(`
%%imports%%
import { css, cx } from '@leafygreen-ui/emotion';
import { useIdAllocator } from '@leafygreen-ui/hooks';
import { generateAccessibleProps, sizeMap } from '../glyphCommon';
import { LGGlyph } from '../types';

Expand All @@ -66,6 +99,7 @@ export function svgrTemplate(
role = 'img',
...props
}: ${componentName}Props) => {
const titleId = useIdAllocator({ prefix: 'icon-title' });
const fillStyle = css\`
color: \${fill};
\`;
Expand All @@ -74,7 +108,7 @@ export function svgrTemplate(
flex-shrink: 0;
\`;

const accessibleProps = generateAccessibleProps(role, '${componentName}', { title, ['aria-label']: ariaLabel, ['aria-labelledby']: ariaLabelledby })
const accessibleProps = generateAccessibleProps(role, '${componentName}', { title, titleId, ['aria-label']: ariaLabel, ['aria-labelledby']: ariaLabelledby })

return %%jsx%%;
}
Expand Down
25 changes: 23 additions & 2 deletions packages/icon/src/Icon.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,32 @@ describe('Generated glyphs', () => {
expect(editIcon.getAttribute('aria-labelledby')).toBe('Test label');
});

test('when title is supplied it overrides default label', () => {
test('when title is supplied it renders a title element and aria-labelledby', () => {
render(<EditIcon title="Test title" />);
const editIcon = screen.getByRole('img');
expect(editIcon.getAttribute('aria-label')).toBe(null);
expect(editIcon.getAttribute('title')).toBe('Test title');
// Should have aria-labelledby instead of title attribute
const ariaLabelledBy = editIcon.getAttribute('aria-labelledby');
expect(ariaLabelledBy).not.toBe(null);
// Should find a title element with matching ID containing the text
const titleElement = editIcon.querySelector('title');
expect(titleElement).not.toBe(null);
expect(titleElement?.textContent).toBe('Test title');
expect(titleElement?.id).toBe(ariaLabelledBy);
});

test('when both title and aria-labelledby are supplied they are combined', () => {
render(<EditIcon title="Test title" aria-labelledby="external-label" />);
const editIcon = screen.getByRole('img');
expect(editIcon.getAttribute('aria-label')).toBe(null);
const ariaLabelledBy = editIcon.getAttribute('aria-labelledby');
// Should contain both the title ID and the external label
expect(ariaLabelledBy).toContain('external-label');
const titleElement = editIcon.querySelector('title');
expect(titleElement).not.toBe(null);
expect(titleElement?.textContent).toBe('Test title');
// The aria-labelledby should reference both
expect(ariaLabelledBy).toBe(`${titleElement?.id} external-label`);
});

test('when role="presentation", aria-hidden is true', () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/icon/src/Icon.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const meta: StoryMetaType<typeof Icon> = {
parameters: {
default: 'LiveExample',
controls: {
exclude: [...storybookExcludedControlParams, 'title', 'data-testid'],
exclude: [...storybookExcludedControlParams, 'data-testid'],
},
},
args: {
Expand All @@ -33,6 +33,11 @@ const meta: StoryMetaType<typeof Icon> = {
fill: {
control: 'color',
},
title: {
control: 'text',
description: 'The title of the icon for accessibility',
defaultValue: undefined,
},
},
};

Expand Down
3 changes: 3 additions & 0 deletions packages/icon/src/createGlyphComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';

import { css, cx } from '@leafygreen-ui/emotion';
import { useIdAllocator } from '@leafygreen-ui/hooks';

import { generateAccessibleProps, Size, sizeMap } from './glyphCommon';
import { LGGlyph, SVGR } from './types';
Expand All @@ -26,6 +27,7 @@ export function createGlyphComponent(
role = 'img',
...rest
}: LGGlyph.ComponentProps) => {
const titleId = useIdAllocator({ prefix: 'icon-title' });
const fillStyle = css`
color: ${fill};
`;
Expand All @@ -51,6 +53,7 @@ export function createGlyphComponent(
role={role}
{...generateAccessibleProps(role, glyphName, {
title,
titleId,
['aria-label']: ariaLabel,
['aria-labelledby']: ariaLabelledby,
})}
Expand Down
9 changes: 7 additions & 2 deletions packages/icon/src/generated/AIModel.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/icon/src/generated/ActivityFeed.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/icon/src/generated/AddFile.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading