Skip to content

Commit 130279f

Browse files
committed
feat(lint): add prefer-aria-label-in-compact-containers rule
- Introduced a new ESLint rule to enforce the use of aria-label on form controls within compact containers (e.g., toolbars and page headers) Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 669552f commit 130279f

10 files changed

Lines changed: 264 additions & 13 deletions

File tree

‎projects/internals/tools/src/api/service.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('ApiService', () => {
5757
expect((ApiService.getasToolMethod<unknown>).metadata.name).toBe('get');
5858
expect((ApiService.getasToolMethod<unknown>).metadata.command).toBe('get');
5959
expect((ApiService.getasToolMethod<unknown>).metadata.description).toContain(
60-
'Get documentation known components or attributes by name (nve-*). Limit: 5'
60+
'Get documentation known components or attributes by name (nve-*). Limit: 3'
6161
);
6262
expect((ApiService.getasToolMethod<unknown>).metadata.inputSchema?.properties?.names).toBeDefined();
6363
expect((ApiService.getasToolMethod<unknown>).metadata.inputSchema?.required).toContain('names');

‎projects/internals/tools/src/api/service.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { service, tool } from '../internal/tools.js';
88
import{getElementImports,markdownDescription}from'../internal/utils.js';
99
import{eslintSchema}from'../internal/schema.js';
1010

11-
constMAX_RESULT_LIMIT=5;
11+
constMAX_RESULT_LIMIT=3;
1212

1313
constlistToolHelpfulTip=
1414
'Tip: Use the list tool to get a summary list of all available components and attribute APIs.';

‎projects/internals/tools/src/examples/service.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('ExampleService', () => {
2929
expect((ExamplesService.searchasToolMethod<unknown>).metadata.name).toBe('search');
3030
expect((ExamplesService.searchasToolMethod<unknown>).metadata.command).toBe('search');
3131
expect((ExamplesService.searchasToolMethod<unknown>).metadata.description).toBe(
32-
'Search Elements (nve-*) pattern usage examples by name, element type, or keywords. Returns up to 5 matching examples with full template code. Hint: use the list tool to get a list of all available examples and patterns first if unsure of what to search.'
32+
'Search Elements (nve-*) pattern usage examples by name, element type, or keywords. Returns up to 3 matching examples with full template code. Hint: use the list tool to get a list of all available examples and patterns first if unsure of what to search.'
3333
);
3434
expect((ExamplesService.searchasToolMethod<unknown>).metadata.inputSchema?.properties?.query).toBeDefined();
3535
});
@@ -57,7 +57,7 @@ describe('ExampleService', () => {
5757
expect((ExamplesService.searchasToolMethod<unknown>).metadata.name).toBe('search');
5858
expect((ExamplesService.searchasToolMethod<unknown>).metadata.command).toBe('search');
5959
expect((ExamplesService.searchasToolMethod<unknown>).metadata.description).toBe(
60-
'Search Elements (nve-*) pattern usage examples by name, element type, or keywords. Returns up to 5 matching examples with full template code. Hint: use the list tool to get a list of all available examples and patterns first if unsure of what to search.'
60+
'Search Elements (nve-*) pattern usage examples by name, element type, or keywords. Returns up to 3 matching examples with full template code. Hint: use the list tool to get a list of all available examples and patterns first if unsure of what to search.'
6161
);
6262
expect((ExamplesService.searchasToolMethod<unknown>).metadata.inputSchema?.properties?.query).toBeDefined();
6363
});

‎projects/internals/tools/src/examples/service.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getContextExamples, renderExampleMarkdown, searchContextExamples } from
88
import{markdownDescription}from'../internal/utils.js';
99
import{eslintSchema}from'../internal/schema.js';
1010

11-
constMAX_RESULT_LIMIT=5;
11+
constMAX_RESULT_LIMIT=3;
1212

1313
@service()
1414
exportclassExamplesService{

‎projects/internals/tools/src/packages/service.test.ts‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,26 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import{describe,expect,it,vi}from'vitest';
5-
importtype{ToolMethod}from'../internal/tools.js';
5+
importtype{Schema,ToolMethod}from'../internal/tools.js';
66
import{PackagesService}from'./service.js';
77

8+
functionhasInputSchema(
9+
method: typeofPackagesService.changelogsGet
10+
): method is typeofPackagesService.changelogsGet&{metadata: {inputSchema: Schema}}{
11+
if(!('metadata'inmethod)){
12+
returnfalse;
13+
}
14+
15+
const{ metadata }=method;
16+
return(
17+
typeofmetadata==='object'&&
18+
metadata!==null&&
19+
'inputSchema'inmetadata&&
20+
typeofmetadata.inputSchema==='object'&&
21+
metadata.inputSchema!==null
22+
);
23+
}
24+
825
vi.mock('../api/utils.js',asyncimportOriginal=>{
926
constactual=(awaitimportOriginal())asRecord<string,unknown>;
1027
return{
@@ -95,24 +112,28 @@ describe('PackagesService', () => {
95112
expect((limitedasstring).length).toBeLessThanOrEqual((fullasstring).length);
96113
});
97114

98-
it('should expose the same package set in list and changelogs-unknown-package error',async()=>{
99-
constlist=(awaitPackagesService.list())asstring;
100-
constlistSet=newSet(Array.from(list.matchAll(/^##(\S+)v/gm),m=>m[1]));
115+
it('should expose the same package set in the changelogs schema and unknown-package error',async()=>{
116+
constchangelogsGet=PackagesService.changelogsGet;
117+
if(!hasInputSchema(changelogsGet)){
118+
thrownewTypeError('Expected changelogsGet to define an input schema.');
119+
}
101120

121+
constschema=changelogsGet.metadata?.inputSchema;
122+
constschemaSet=newSet(schema?.properties?.name?.enum??[]);
102123
consterror=awaitPackagesService.changelogsGet({name: 'does-not-exist',format: 'markdown'}).catch(
103124
e=>easError
104125
);
105126
constavailable=error.message.split('Available packages:')[1]??'';
106127
consterrSet=newSet(Array.from(available.matchAll(/"([^"]+)"/g),m=>m[1]));
107128

108-
expect(listSet.size).toBeGreaterThan(0);
109-
expect(errSet).toEqual(listSet);
129+
expect(schemaSet.size).toBeGreaterThan(0);
130+
expect(errSet).toEqual(schemaSet);
110131
});
111132

112133
it('should provide versions method',async()=>{
113134
constresult=awaitPackagesService.versions();
114135
expect(result).toBeDefined();
115136
expect(typeofresult).toBe('object');
116-
expect(result['@nvidia-elements/core']).toBe('1.0.0');
137+
expect(result['@nvidia-elements/core']).toMatch(/^\d+\.\d+\.\d+$/);
117138
});
118139
});

‎projects/lint/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export default [
9999
|`@nvidia-elements/lint/no-unknown-css-variable`| Disallow use of unknown --nve-* CSS theme variables. | CSS |`error`|
100100
|`@nvidia-elements/lint/no-unknown-tags`| Disallow use of unknown nve-* tags. | HTML |`error`|
101101
|`@nvidia-elements/lint/no-unstyled-typography`| Require typography elements to have nve-text styling applied. | HTML |`error`|
102+
|`@nvidia-elements/lint/prefer-aria-label-in-compact-containers`| Prefer aria-label on form controls inside toolbars and page headers. | HTML |`error`|
102103

103104
## Links
104105

‎projects/lint/src/eslint/configs/html.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import noRestrictedPageSizing from '../rules/no-restricted-page-sizing.js';
3333
importnoNestedContainerTypesfrom'../rules/no-nested-container-types.js';
3434
importnoUnstyledTypographyfrom'../rules/no-unstyled-typography.js';
3535
importnoTailwindClassesfrom'../rules/no-tailwind-classes.js';
36+
importpreferAriaLabelInCompactContainersfrom'../rules/prefer-aria-label-in-compact-containers.js';
3637

3738
constsource=['src/**/*.html','src/**/*.js','src/**/*.ts','src/**/*.tsx'];
3839

@@ -90,7 +91,8 @@ export const elementsHtmlConfig: Linter.Config = {
9091
'no-unknown-css-variable': noUnknownCssVariable,
9192
'no-nested-container-types': noNestedContainerTypes,
9293
'no-unstyled-typography': noUnstyledTypography,
93-
'no-tailwind-classes': noTailwindClasses
94+
'no-tailwind-classes': noTailwindClasses,
95+
'prefer-aria-label-in-compact-containers': preferAriaLabelInCompactContainers
9496
}
9597
}
9698
},
@@ -123,6 +125,7 @@ export const elementsHtmlConfig: Linter.Config = {
123125
'@nvidia-elements/lint/no-nested-container-types': ['error'],
124126
'@nvidia-elements/lint/no-unstyled-typography': ['error'],
125127
'@nvidia-elements/lint/no-tailwind-classes': ['error'],
128+
'@nvidia-elements/lint/prefer-aria-label-in-compact-containers': ['error'],
126129
'@nvidia-elements/lint/no-unexpected-style-customization': ['off'],
127130
'@nvidia-elements/lint/no-missing-gap-space': ['off']
128131
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import{beforeEach,describe,expect,it}from'vitest';
5+
import{RuleTester}from'eslint';
6+
importtype{JSRuleDefinition}from'eslint';
7+
importhtmlParserfrom'@html-eslint/parser';
8+
import{elementsHtmlConfig}from'../configs/html.js';
9+
importpreferAriaLabelInCompactContainersfrom'./prefer-aria-label-in-compact-containers.js';
10+
11+
functionisRecord(value: unknown): value is Record<PropertyKey,unknown>{
12+
returntypeofvalue==='object'&&value!==null;
13+
}
14+
15+
functionisJSRuleDefinition(rule: unknown): rule is JSRuleDefinition{
16+
if(!isRecord(rule)||!isRecord(rule.meta)){
17+
returnfalse;
18+
}
19+
20+
const{ meta, create }=rule;
21+
return(
22+
typeofcreate==='function'&&meta.type==='problem'&&'docs'inmeta&&'schema'inmeta&&'messages'inmeta
23+
);
24+
}
25+
26+
if(!isJSRuleDefinition(preferAriaLabelInCompactContainers)){
27+
thrownewTypeError('Expected preferAriaLabelInCompactContainers to be an ESLint rule definition.');
28+
}
29+
30+
construle=preferAriaLabelInCompactContainers;
31+
32+
functioncompactLabelError(control: string,container: string){
33+
return{
34+
messageId: 'prefer-aria-label'asconst,
35+
data: { control, container }
36+
};
37+
}
38+
39+
describe('preferAriaLabelInCompactContainers',()=>{
40+
lettester: RuleTester;
41+
42+
beforeEach(()=>{
43+
tester=newRuleTester({
44+
languageOptions: {
45+
parser: htmlParser,
46+
parserOptions: {
47+
frontmatter: true
48+
}
49+
}
50+
});
51+
});
52+
53+
it('should define rule metadata',()=>{
54+
expect(preferAriaLabelInCompactContainers.meta).toBeDefined();
55+
expect(preferAriaLabelInCompactContainers.meta.type).toBe('problem');
56+
expect(preferAriaLabelInCompactContainers.meta.docs).toBeDefined();
57+
expect(preferAriaLabelInCompactContainers.meta.docs.description).toBe(
58+
'Prefer aria-label on form controls inside toolbars and page headers.'
59+
);
60+
expect(preferAriaLabelInCompactContainers.meta.docs.category).toBe('Best Practice');
61+
expect(preferAriaLabelInCompactContainers.meta.docs.recommended).toBe(true);
62+
expect(preferAriaLabelInCompactContainers.meta.docs.url).toContain('/docs/lint/');
63+
expect(preferAriaLabelInCompactContainers.meta.schema).toEqual([]);
64+
expect(preferAriaLabelInCompactContainers.meta.messages['prefer-aria-label']).toBe(
65+
'Remove <label> from <{{control}}> inside <{{container}}> and use aria-label instead to preserve the compact layout.'
66+
);
67+
});
68+
69+
it('should register the rule as a recommended error',()=>{
70+
constplugin=elementsHtmlConfig.plugins?.['@nvidia-elements/lint'];
71+
72+
expect(plugin?.rules?.['prefer-aria-label-in-compact-containers']).toBe(preferAriaLabelInCompactContainers);
73+
expect(elementsHtmlConfig.rules?.['@nvidia-elements/lint/prefer-aria-label-in-compact-containers']).toEqual([
74+
'error'
75+
]);
76+
});
77+
78+
it('should allow labels that do not affect compact form controls',()=>{
79+
tester.run('allowed labels',rule,{
80+
valid: [
81+
`<nve-input><label>Name</label><input /></nve-input>`,
82+
`<nve-toolbar><label nve-text="body sm">1 of 13</label></nve-toolbar>`,
83+
`<nve-page-header><nve-button><label>Action</label></nve-button></nve-page-header>`,
84+
`<nve-toolbar><div><label>Caption</label></div></nve-toolbar>`
85+
],
86+
invalid: []
87+
});
88+
});
89+
90+
it('should allow compact form controls without visual labels',()=>{
91+
tester.run('aria labels',rule,{
92+
valid: [
93+
`<nve-toolbar><nve-input><input aria-label="Search" /></nve-input></nve-toolbar>`,
94+
`<nve-page-header><nve-select><select aria-label="Page"><option>One</option></select></nve-select></nve-page-header>`,
95+
`<nve-toolbar><nve-input><input /></nve-input></nve-toolbar>`
96+
],
97+
invalid: []
98+
});
99+
});
100+
101+
it('should report labels in compact form controls',()=>{
102+
tester.run('compact control labels',rule,{
103+
valid: [],
104+
invalid: [
105+
{
106+
code: `<nve-toolbar><nve-input><label>Search</label><input /></nve-input></nve-toolbar>`,
107+
errors: [compactLabelError('nve-input','nve-toolbar')]
108+
},
109+
{
110+
code: `<nve-page-header><nve-search slot="suffix"><label>Search</label><input type="search" /></nve-search></nve-page-header>`,
111+
errors: [compactLabelError('nve-search','nve-page-header')]
112+
},
113+
{
114+
code: `<nve-toolbar><div><nve-select><label>Page</label><select></select></nve-select></div></nve-toolbar>`,
115+
errors: [compactLabelError('nve-select','nve-toolbar')]
116+
},
117+
{
118+
code: `<nve-page-header><nve-star-rating><span><label>Rating</label></span><input type="range" /></nve-star-rating></nve-page-header>`,
119+
errors: [compactLabelError('nve-star-rating','nve-page-header')]
120+
},
121+
{
122+
code: `<nve-toolbar><nve-input><label>Name</label><input aria-label="Name" /></nve-input></nve-toolbar>`,
123+
errors: [compactLabelError('nve-input','nve-toolbar')]
124+
}
125+
]
126+
});
127+
});
128+
129+
it('should report each label against its nearest form control',()=>{
130+
tester.run('nested form controls',rule,{
131+
valid: [],
132+
invalid: [
133+
{
134+
code: `<nve-toolbar>
135+
<nve-checkbox-group>
136+
<label>Options</label>
137+
<nve-checkbox><label>First</label><input type="checkbox" /></nve-checkbox>
138+
</nve-checkbox-group>
139+
</nve-toolbar>`,
140+
errors: [
141+
compactLabelError('nve-checkbox-group','nve-toolbar'),
142+
compactLabelError('nve-checkbox','nve-toolbar')
143+
]
144+
}
145+
]
146+
});
147+
});
148+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
importtype{Rule}from'eslint';
5+
import{createVisitors}from'@html-eslint/eslint-plugin/lib/rules/utils/visitors.js';
6+
import{elements}from'../internals/metadata.js';
7+
importtype{HtmlTagNode}from'../rule-types.js';
8+
9+
declareconst__ELEMENTS_PAGES_BASE_URL__: string;
10+
11+
constCOMPACT_CONTAINERS: ReadonlySet<string>=newSet(['nve-page-header','nve-toolbar']);
12+
constFORM_CONTROLS: ReadonlySet<string>=newSet(
13+
elements.filter(element=>element.manifest?.metadata?.behavior==='form').map(element=>element.name.toLowerCase())
14+
);
15+
16+
functionfindAncestor(node: HtmlTagNode|undefined,tags: ReadonlySet<string>): HtmlTagNode|undefined{
17+
letcurrent=node;
18+
while(current){
19+
if(current.type==='Tag'&&tags.has(current.name.toLowerCase())){
20+
returncurrent;
21+
}
22+
current=current.parent;
23+
}
24+
returnundefined;
25+
}
26+
27+
construle: Rule.RuleModule&{meta: {docs: {category: string}}}={
28+
meta: {
29+
type: 'problem'asconst,
30+
docs: {
31+
description: 'Prefer aria-label on form controls inside toolbars and page headers.',
32+
category: 'Best Practice',
33+
recommended: true,
34+
url: `${__ELEMENTS_PAGES_BASE_URL__}/docs/lint/`
35+
},
36+
schema: [],
37+
messages: {
38+
['prefer-aria-label']:
39+
'Remove <label> from <{{control}}> inside <{{container}}> and use aria-label instead to preserve the compact layout.'
40+
}
41+
},
42+
create(context: Rule.RuleContext){
43+
returncreateVisitors(context,{
44+
Tag(node: HtmlTagNode){
45+
if(node.name.toLowerCase()!=='label'){
46+
return;
47+
}
48+
49+
constcontrol=findAncestor(node.parent,FORM_CONTROLS);
50+
if(!control){
51+
return;
52+
}
53+
54+
constcontainer=findAncestor(control.parent,COMPACT_CONTAINERS);
55+
if(!container){
56+
return;
57+
}
58+
59+
context.report({
60+
node,
61+
messageId: 'prefer-aria-label',
62+
data: {
63+
control: control.name.toLowerCase(),
64+
container: container.name.toLowerCase()
65+
}
66+
});
67+
}
68+
});
69+
}
70+
};
71+
72+
exportdefaultrule;

‎projects/site/src/docs/lint/index.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,10 @@ export default [
286286
<nve-grid-cell>HTML</nve-grid-cell>
287287
<nve-grid-cell><code nve-text="code">error</code></nve-grid-cell>
288288
</nve-grid-row>
289+
<nve-grid-row>
290+
<nve-grid-cell><code nve-text="code">@nvidia-elements/lint/prefer-aria-label-in-compact-containers</code></nve-grid-cell>
291+
<nve-grid-cell>Prefer aria-label on form controls inside toolbars and page headers.</nve-grid-cell>
292+
<nve-grid-cell>HTML</nve-grid-cell>
293+
<nve-grid-cell><code nve-text="code">error</code></nve-grid-cell>
294+
</nve-grid-row>
289295
</nve-grid>

0 commit comments

Comments
 (0)