Skip to content

Commit 2463aa4

Browse files
authored
[LG-3416] feat: Icon - added title element (#3245)
* [LG-3416] feat: Icon - updated title * updated title * added changeset * replaced useId with useIdallocator, rm title attr * fixed test * updated functions, added gen index file * updated aimodel svg * updated file * fixed test * fixed tests * updated changeset * udpated test name * updated changeset * updated iconButton test * rm comments * updated comments
1 parent 11cbe4f commit 2463aa4

File tree

202 files changed

+1649
-448
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

202 files changed

+1649
-448
lines changed

.changeset/many-plums-scream.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@leafygreen-ui/text-input': minor
3+
'@leafygreen-ui/select': minor
4+
---
5+
6+
Updates testUtils files to accommodate changes to the Icon package

.changeset/puny-paws-yell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@leafygreen-ui/icon': minor
3+
---
4+
5+
Updated Icon to include <title> element when title is added. Deprecated the `createGlyphComponent`, and the design system is instead using glyphs from the `generated` folder

packages/icon-button/src/IconButton/IconButton.spec.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { axe } from 'jest-axe';
44

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

7-
import IconButton from '..';
7+
import { IconButton } from '..';
88

99
const onClick = jest.fn();
1010
const className = 'test-icon-button-class';
@@ -84,13 +84,14 @@ describe('packages/icon-button', () => {
8484
expect(iconButton.tagName.toLowerCase()).toBe('a');
8585
});
8686

87-
test(`when '${titleText}' is set directly as the title child icon, the rendered icon includes the title attribute, '${titleText}'`, () => {
87+
test(`shows a title element with '${titleText}' when the title prop is set`, () => {
8888
const iconWithTitle = (
8989
<EllipsisIcon data-testid="icon-test-id" title={titleText} />
9090
);
9191
const { icon } = renderIconButton({ children: iconWithTitle });
92-
93-
expect(icon.getAttribute('title')).toBe(titleText);
92+
expect(icon).toBeInTheDocument();
93+
const title = icon.querySelector('title');
94+
expect(title?.textContent).toBe(titleText);
9495
});
9596

9697
/* eslint-disable jest/no-disabled-tests*/

packages/icon/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
},
3737
"dependencies": {
3838
"@leafygreen-ui/emotion": "workspace:^",
39+
"@leafygreen-ui/hooks": "workspace:^",
3940
"lodash": "^4.17.21"
4041
},
4142
"devDependencies": {

packages/icon/scripts/build/compare-checksum.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export function getAllIcons(): Array<string> {
1313
const iconNames = fsx
1414
.readdirSync(ICON_DIR)
1515
.filter(f => /\.tsx?$/.test(f))
16-
.map(f => path.basename(f, path.extname(f)));
16+
.map(f => path.basename(f, path.extname(f)))
17+
.filter(name => name !== 'index'); // Exclude the index file
1718
return iconNames;
1819
}
1920

packages/icon/scripts/prebuild/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import fs from 'fs';
77
import path from 'path';
88

99
import { getChecksum } from './checksum';
10-
import { indexTemplate } from './indexTemplate';
10+
import { generatedIndexTemplate, indexTemplate } from './indexTemplate';
1111
import { FileObject, PrebuildOptions } from './prebuild.types';
1212
import { svgrTemplate } from './svgrTemplate';
1313

@@ -28,6 +28,7 @@ async function buildSvgFiles(options: PrebuildOptions): Promise<void> {
2828
options?.verbose && console.log('Processing SVG files...\n');
2929
await Promise.all(svgFiles.map(processFile));
3030
await createIndexFile(svgFiles, options);
31+
await createGeneratedIndexFile(svgFiles, outputDir, options);
3132
}
3233

3334
/**
@@ -91,6 +92,21 @@ async function createIndexFile(
9192
fs.writeFileSync(indexPath, formattedIndexContent, { encoding: 'utf8' });
9293
}
9394

95+
/**
96+
* Create the index file for the generated component exports
97+
*/
98+
async function createGeneratedIndexFile(
99+
svgFiles: Array<FileObject>,
100+
outputDir: string,
101+
options?: PrebuildOptions,
102+
) {
103+
const indexPath = path.resolve(outputDir, 'index.ts');
104+
options?.verbose && console.log('Writing generated index file...', indexPath);
105+
const indexContent = await generatedIndexTemplate(svgFiles);
106+
const formattedIndexContent = await formatLG(indexContent, indexPath);
107+
fs.writeFileSync(indexPath, formattedIndexContent, { encoding: 'utf8' });
108+
}
109+
94110
/**
95111
* Annotate the generated file with script and checksum information
96112
*/

packages/icon/scripts/prebuild/indexTemplate.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,31 @@ export async function indexTemplate(svgFiles: Array<FileObject>) {
55
.map(({ name }) => `import ${name} from './${name}.svg';`)
66
.join('\n');
77

8-
const _glyphs = `{
9-
${svgFiles.map(({ name }) => `${name}`).join(',\n')}
10-
}`;
8+
const glyphsList = svgFiles.map(({ name }) => `${name}`).join(',\n ');
119

1210
return `
13-
import { createGlyphComponent } from '../createGlyphComponent';
14-
import { LGGlyph } from '../types';
15-
1611
// Glyphs
1712
${imports}
1813
19-
const _glyphs = ${_glyphs} as const;
20-
21-
export type GlyphName = keyof typeof _glyphs;
22-
23-
const glyphKeys = Object.keys(_glyphs) as Array<GlyphName>;
24-
25-
export const glyphs = glyphKeys.reduce((acc, name) => {
26-
acc[name] = createGlyphComponent(name, _glyphs[name]);
27-
28-
return acc;
29-
}, {} as Record<GlyphName, LGGlyph.Component>);
14+
export const glyphs = {
15+
${glyphsList}
16+
} as const;
17+
18+
export type GlyphName = keyof typeof glyphs;
3019
`;
3120
}
21+
22+
export async function generatedIndexTemplate(svgFiles: Array<FileObject>) {
23+
const exports = svgFiles
24+
.map(({ name }) => `export { default as ${name} } from './${name}';`)
25+
.join('\n');
26+
27+
return `/**
28+
* This is a generated file. Do not modify it manually.
29+
*
30+
* @script packages/icon/scripts/prebuild/index.ts
31+
*/
32+
33+
${exports}
34+
`;
35+
}

packages/icon/scripts/prebuild/svgrTemplate.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ interface ASTParts extends Record<string, any> {
2020
}
2121

2222
export function svgrTemplate(
23-
{ template }: BabelAPI,
24-
{ state: { componentName } }: SVGROptions,
23+
api: BabelAPI,
24+
opts: SVGROptions,
2525
{ imports, jsx, exports }: ASTParts,
2626
) {
27+
const { template, types: t } = api;
28+
const { componentName } = opts.state;
2729
const typeScriptTpl = template.smart({ plugins: ['jsx', 'typescript'] });
2830

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

53+
// Convert self-closing svg to have children
54+
jsx.openingElement.selfClosing = false;
55+
56+
// Create closing element using Babel types
57+
jsx.closingElement = t.jsxClosingElement(
58+
t.jsxIdentifier(jsx.openingElement.name.name),
59+
);
60+
61+
// Create the title element JSX manually using Babel types
62+
// {title && <title id={titleId}>{title}</title>}
63+
const titleJSXElement = t.jsxElement(
64+
t.jsxOpeningElement(t.jsxIdentifier('title'), [
65+
t.jsxAttribute(
66+
t.jsxIdentifier('id'),
67+
t.jsxExpressionContainer(t.identifier('titleId')),
68+
),
69+
]),
70+
t.jsxClosingElement(t.jsxIdentifier('title')),
71+
[t.jsxExpressionContainer(t.identifier('title'))],
72+
false,
73+
);
74+
75+
// Wrap title in conditional expression: {title && <title>...</title>}
76+
const conditionalTitleExpression = t.jsxExpressionContainer(
77+
t.logicalExpression('&&', t.identifier('title'), titleJSXElement),
78+
);
79+
80+
// Add the conditional title as a child, followed by the original children
81+
jsx.children = [conditionalTitleExpression, ...jsx.children];
82+
5183
return typeScriptTpl(`
5284
%%imports%%
5385
import { css, cx } from '@leafygreen-ui/emotion';
86+
import { useIdAllocator } from '@leafygreen-ui/hooks';
5487
import { generateAccessibleProps, sizeMap } from '../glyphCommon';
5588
import { LGGlyph } from '../types';
5689
@@ -66,6 +99,7 @@ export function svgrTemplate(
6699
role = 'img',
67100
...props
68101
}: ${componentName}Props) => {
102+
const titleId = useIdAllocator({ prefix: 'icon-title' });
69103
const fillStyle = css\`
70104
color: \${fill};
71105
\`;
@@ -74,7 +108,7 @@ export function svgrTemplate(
74108
flex-shrink: 0;
75109
\`;
76110
77-
const accessibleProps = generateAccessibleProps(role, '${componentName}', { title, ['aria-label']: ariaLabel, ['aria-labelledby']: ariaLabelledby })
111+
const accessibleProps = generateAccessibleProps(role, '${componentName}', { title, titleId, ['aria-label']: ariaLabel, ['aria-labelledby']: ariaLabelledby })
78112
79113
return %%jsx%%;
80114
}

packages/icon/src/Icon.spec.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ const glyphPaths = fs
2929
const generatedFilesDirectory = path.resolve(__dirname, './generated');
3030
const baseNameToGeneratedFilePath: Record<string, string> = {};
3131

32-
fs.readdirSync(generatedFilesDirectory).forEach(filePath => {
33-
baseNameToGeneratedFilePath[getBaseName(filePath)] = filePath;
34-
});
32+
fs.readdirSync(generatedFilesDirectory)
33+
.filter(filePath => /.*\.tsx$/.test(filePath))
34+
.forEach(filePath => {
35+
baseNameToGeneratedFilePath[getBaseName(filePath)] = filePath;
36+
});
3537

3638
const MyTestSVGRGlyph: SVGR.Component = props => (
3739
<svg data-testid="my-glyph" {...props}></svg>
@@ -276,7 +278,7 @@ describe('Generated glyphs', () => {
276278
});
277279

278280
describe('accessible props handled correctly', () => {
279-
test('when no prop is supplied, aria-label is genereated', () => {
281+
test('when no prop is supplied, aria-label is generated', () => {
280282
render(<EditIcon />);
281283
const editIcon = screen.getByRole('img');
282284
expect(editIcon.getAttribute('aria-label')).toBe('Edit Icon');
@@ -295,11 +297,32 @@ describe('Generated glyphs', () => {
295297
expect(editIcon.getAttribute('aria-labelledby')).toBe('Test label');
296298
});
297299

298-
test('when title is supplied it overrides default label', () => {
300+
test('when title is supplied it renders a title element and aria-labelledby', () => {
299301
render(<EditIcon title="Test title" />);
300302
const editIcon = screen.getByRole('img');
301303
expect(editIcon.getAttribute('aria-label')).toBe(null);
302-
expect(editIcon.getAttribute('title')).toBe('Test title');
304+
// Should have aria-labelledby instead of title attribute
305+
const ariaLabelledBy = editIcon.getAttribute('aria-labelledby');
306+
expect(ariaLabelledBy).not.toBe(null);
307+
// Should find a title element with matching ID containing the text
308+
const titleElement = editIcon.querySelector('title');
309+
expect(titleElement).not.toBe(null);
310+
expect(titleElement?.textContent).toBe('Test title');
311+
expect(titleElement?.id).toBe(ariaLabelledBy);
312+
});
313+
314+
test('when both title and aria-labelledby are supplied they are combined', () => {
315+
render(<EditIcon title="Test title" aria-labelledby="external-label" />);
316+
const editIcon = screen.getByRole('img');
317+
expect(editIcon.getAttribute('aria-label')).toBe(null);
318+
const ariaLabelledBy = editIcon.getAttribute('aria-labelledby');
319+
// Should contain both the title ID and the external label
320+
expect(ariaLabelledBy).toContain('external-label');
321+
const titleElement = editIcon.querySelector('title');
322+
expect(titleElement).not.toBe(null);
323+
expect(titleElement?.textContent).toBe('Test title');
324+
// The aria-labelledby should reference both
325+
expect(ariaLabelledBy).toBe(`${titleElement?.id} external-label`);
303326
});
304327

305328
test('when role="presentation", aria-hidden is true', () => {

packages/icon/src/Icon.stories.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const meta: StoryMetaType<typeof Icon> = {
1717
parameters: {
1818
default: 'LiveExample',
1919
controls: {
20-
exclude: [...storybookExcludedControlParams, 'title', 'data-testid'],
20+
exclude: [...storybookExcludedControlParams, 'data-testid'],
2121
},
2222
},
2323
args: {
@@ -33,6 +33,11 @@ const meta: StoryMetaType<typeof Icon> = {
3333
fill: {
3434
control: 'color',
3535
},
36+
title: {
37+
control: 'text',
38+
description: 'The title of the icon for accessibility',
39+
defaultValue: undefined,
40+
},
3641
},
3742
};
3843

0 commit comments

Comments
 (0)