From c0da0d331d162184443c2e5ff6c545fad5d384ca Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Thu, 2 Jul 2026 14:46:07 -0500 Subject: [PATCH 1/4] 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 --- .../internals/tools/src/api/service.test.ts | 2 +- projects/internals/tools/src/api/service.ts | 2 +- .../tools/src/examples/service.test.ts | 4 +- .../internals/tools/src/examples/service.ts | 2 +- .../tools/src/packages/service.test.ts | 13 +- projects/lint/README.md | 1 + projects/lint/src/eslint/configs/html.ts | 5 +- ...r-aria-label-in-compact-containers.test.ts | 129 ++++++++++++++++++ ...prefer-aria-label-in-compact-containers.ts | 72 ++++++++++ projects/site/src/docs/lint/index.md | 6 + 10 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 projects/lint/src/eslint/rules/prefer-aria-label-in-compact-containers.test.ts create mode 100644 projects/lint/src/eslint/rules/prefer-aria-label-in-compact-containers.ts diff --git a/projects/internals/tools/src/api/service.test.ts b/projects/internals/tools/src/api/service.test.ts index ca2f6c7ca6..1daf8eb231 100644 --- a/projects/internals/tools/src/api/service.test.ts +++ b/projects/internals/tools/src/api/service.test.ts @@ -57,7 +57,7 @@ describe('ApiService', () => { expect((ApiService.get as ToolMethod).metadata.name).toBe('get'); expect((ApiService.get as ToolMethod).metadata.command).toBe('get'); expect((ApiService.get as ToolMethod).metadata.description).toContain( - 'Get documentation known components or attributes by name (nve-*). Limit: 5' + 'Get documentation known components or attributes by name (nve-*). Limit: 3' ); expect((ApiService.get as ToolMethod).metadata.inputSchema?.properties?.names).toBeDefined(); expect((ApiService.get as ToolMethod).metadata.inputSchema?.required).toContain('names'); diff --git a/projects/internals/tools/src/api/service.ts b/projects/internals/tools/src/api/service.ts index 4ae7699b7a..820fa8cb2e 100644 --- a/projects/internals/tools/src/api/service.ts +++ b/projects/internals/tools/src/api/service.ts @@ -8,7 +8,7 @@ import { service, tool } from '../internal/tools.js'; import { getElementImports, markdownDescription } from '../internal/utils.js'; import { eslintSchema } from '../internal/schema.js'; -const MAX_RESULT_LIMIT = 5; +const MAX_RESULT_LIMIT = 3; const listToolHelpfulTip = 'Tip: Use the list tool to get a summary list of all available components and attribute APIs.'; diff --git a/projects/internals/tools/src/examples/service.test.ts b/projects/internals/tools/src/examples/service.test.ts index 2b57f4bd7a..c9b434fc53 100644 --- a/projects/internals/tools/src/examples/service.test.ts +++ b/projects/internals/tools/src/examples/service.test.ts @@ -29,7 +29,7 @@ describe('ExampleService', () => { expect((ExamplesService.search as ToolMethod).metadata.name).toBe('search'); expect((ExamplesService.search as ToolMethod).metadata.command).toBe('search'); expect((ExamplesService.search as ToolMethod).metadata.description).toBe( - '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.' + '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.' ); expect((ExamplesService.search as ToolMethod).metadata.inputSchema?.properties?.query).toBeDefined(); }); @@ -57,7 +57,7 @@ describe('ExampleService', () => { expect((ExamplesService.search as ToolMethod).metadata.name).toBe('search'); expect((ExamplesService.search as ToolMethod).metadata.command).toBe('search'); expect((ExamplesService.search as ToolMethod).metadata.description).toBe( - '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.' + '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.' ); expect((ExamplesService.search as ToolMethod).metadata.inputSchema?.properties?.query).toBeDefined(); }); diff --git a/projects/internals/tools/src/examples/service.ts b/projects/internals/tools/src/examples/service.ts index c5cc4b5e36..24a2046cbf 100644 --- a/projects/internals/tools/src/examples/service.ts +++ b/projects/internals/tools/src/examples/service.ts @@ -8,7 +8,7 @@ import { getContextExamples, renderExampleMarkdown, searchContextExamples } from import { markdownDescription } from '../internal/utils.js'; import { eslintSchema } from '../internal/schema.js'; -const MAX_RESULT_LIMIT = 5; +const MAX_RESULT_LIMIT = 3; @service() export class ExamplesService { diff --git a/projects/internals/tools/src/packages/service.test.ts b/projects/internals/tools/src/packages/service.test.ts index 63f621f595..ce4636ea80 100644 --- a/projects/internals/tools/src/packages/service.test.ts +++ b/projects/internals/tools/src/packages/service.test.ts @@ -95,24 +95,23 @@ describe('PackagesService', () => { expect((limited as string).length).toBeLessThanOrEqual((full as string).length); }); - it('should expose the same package set in list and changelogs-unknown-package error', async () => { - const list = (await PackagesService.list()) as string; - const listSet = new Set(Array.from(list.matchAll(/^## (\S+) v/gm), m => m[1])); - + it('should expose the same package set in the changelogs schema and unknown-package error', async () => { + const schema = (PackagesService.changelogsGet as ToolMethod).metadata.inputSchema; + const schemaSet = new Set(schema?.properties?.name.enum ?? []); const error = await PackagesService.changelogsGet({ name: 'does-not-exist', format: 'markdown' }).catch( e => e as Error ); const available = error.message.split('Available packages:')[1] ?? ''; const errSet = new Set(Array.from(available.matchAll(/"([^"]+)"/g), m => m[1])); - expect(listSet.size).toBeGreaterThan(0); - expect(errSet).toEqual(listSet); + expect(schemaSet.size).toBeGreaterThan(0); + expect(errSet).toEqual(schemaSet); }); it('should provide versions method', async () => { const result = await PackagesService.versions(); expect(result).toBeDefined(); expect(typeof result).toBe('object'); - expect(result['@nvidia-elements/core']).toBe('1.0.0'); + expect(result['@nvidia-elements/core']).toMatch(/^\d+\.\d+\.\d+$/); }); }); diff --git a/projects/lint/README.md b/projects/lint/README.md index 3b58760861..2a8b98a963 100644 --- a/projects/lint/README.md +++ b/projects/lint/README.md @@ -99,6 +99,7 @@ export default [ | `@nvidia-elements/lint/no-unknown-css-variable` | Disallow use of unknown --nve-* CSS theme variables. | CSS | `error` | | `@nvidia-elements/lint/no-unknown-tags` | Disallow use of unknown nve-* tags. | HTML | `error` | | `@nvidia-elements/lint/no-unstyled-typography` | Require typography elements to have nve-text styling applied. | HTML | `error` | +| `@nvidia-elements/lint/prefer-aria-label-in-compact-containers` | Prefer aria-label on form controls inside toolbars and page headers. | HTML | `error` | ## Links diff --git a/projects/lint/src/eslint/configs/html.ts b/projects/lint/src/eslint/configs/html.ts index 41d1ab1b05..9be528eb20 100644 --- a/projects/lint/src/eslint/configs/html.ts +++ b/projects/lint/src/eslint/configs/html.ts @@ -33,6 +33,7 @@ import noRestrictedPageSizing from '../rules/no-restricted-page-sizing.js'; import noNestedContainerTypes from '../rules/no-nested-container-types.js'; import noUnstyledTypography from '../rules/no-unstyled-typography.js'; import noTailwindClasses from '../rules/no-tailwind-classes.js'; +import preferAriaLabelInCompactContainers from '../rules/prefer-aria-label-in-compact-containers.js'; const source = ['src/**/*.html', 'src/**/*.js', 'src/**/*.ts', 'src/**/*.tsx']; @@ -90,7 +91,8 @@ export const elementsHtmlConfig: Linter.Config = { 'no-unknown-css-variable': noUnknownCssVariable, 'no-nested-container-types': noNestedContainerTypes, 'no-unstyled-typography': noUnstyledTypography, - 'no-tailwind-classes': noTailwindClasses + 'no-tailwind-classes': noTailwindClasses, + 'prefer-aria-label-in-compact-containers': preferAriaLabelInCompactContainers } } }, @@ -123,6 +125,7 @@ export const elementsHtmlConfig: Linter.Config = { '@nvidia-elements/lint/no-nested-container-types': ['error'], '@nvidia-elements/lint/no-unstyled-typography': ['error'], '@nvidia-elements/lint/no-tailwind-classes': ['error'], + '@nvidia-elements/lint/prefer-aria-label-in-compact-containers': ['error'], '@nvidia-elements/lint/no-unexpected-style-customization': ['off'], '@nvidia-elements/lint/no-missing-gap-space': ['off'] } diff --git a/projects/lint/src/eslint/rules/prefer-aria-label-in-compact-containers.test.ts b/projects/lint/src/eslint/rules/prefer-aria-label-in-compact-containers.test.ts new file mode 100644 index 0000000000..bb8478ebf6 --- /dev/null +++ b/projects/lint/src/eslint/rules/prefer-aria-label-in-compact-containers.test.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it } from 'vitest'; +import { RuleTester } from 'eslint'; +import type { JSRuleDefinition } from 'eslint'; +import htmlParser from '@html-eslint/parser'; +import { elementsHtmlConfig } from '../configs/html.js'; +import preferAriaLabelInCompactContainers from './prefer-aria-label-in-compact-containers.js'; + +const rule = preferAriaLabelInCompactContainers as unknown as JSRuleDefinition; + +function compactLabelError(control: string, container: string) { + return { + messageId: 'prefer-aria-label' as const, + data: { control, container } + }; +} + +describe('preferAriaLabelInCompactContainers', () => { + let tester: RuleTester; + + beforeEach(() => { + tester = new RuleTester({ + languageOptions: { + parser: htmlParser, + parserOptions: { + frontmatter: true + } + } + }); + }); + + it('should define rule metadata', () => { + expect(preferAriaLabelInCompactContainers.meta).toBeDefined(); + expect(preferAriaLabelInCompactContainers.meta.type).toBe('problem'); + expect(preferAriaLabelInCompactContainers.meta.docs).toBeDefined(); + expect(preferAriaLabelInCompactContainers.meta.docs.description).toBe( + 'Prefer aria-label on form controls inside toolbars and page headers.' + ); + expect(preferAriaLabelInCompactContainers.meta.docs.category).toBe('Best Practice'); + expect(preferAriaLabelInCompactContainers.meta.docs.recommended).toBe(true); + expect(preferAriaLabelInCompactContainers.meta.docs.url).toContain('/docs/lint/'); + expect(preferAriaLabelInCompactContainers.meta.schema).toEqual([]); + expect(preferAriaLabelInCompactContainers.meta.messages['prefer-aria-label']).toBe( + 'Remove