Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions projects/core/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export default [
'@nvidia-elements/lint/no-missing-gap-space': ['off']
}
},
{
files: ['src/button/button.test.visual.ts'],
rules: {
'@nvidia-elements/lint/no-excessive-primary-actions': ['off']
}
},
{
files: [
'src/format-datetime/format-datetime.ts',
Expand Down
1 change: 1 addition & 0 deletions projects/lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default [
| `@nvidia-elements/lint/no-deprecated-popover-attributes` | Disallow use of deprecated popover attributes. | HTML | `error` |
| `@nvidia-elements/lint/no-deprecated-slots` | Disallow use of deprecated slot APIs. | HTML | `error` |
| `@nvidia-elements/lint/no-deprecated-tags` | Disallow use of deprecated elements in HTML. | HTML | `error` |
| `@nvidia-elements/lint/no-excessive-primary-actions` | Limit primary actions to two per page. | HTML | `error` |
| `@nvidia-elements/lint/no-invalid-event-listeners` | Disallow inline event handler attributes in HTML. | HTML | `error` |
| `@nvidia-elements/lint/no-invalid-invoker-triggers` | Disallow use of invoker trigger attributes on non-button nve-* elements. | HTML | `error` |
| `@nvidia-elements/lint/no-missing-control-label` | Require form controls to have an accessible label. | HTML | `error` |
Expand Down
3 changes: 3 additions & 0 deletions projects/lint/src/eslint/configs/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import noDeprecatedGlobalAttributes from '../rules/no-deprecated-global-attribut
import noRestrictedAttributes from '../rules/no-restricted-attributes.js';
import noSlottedPopovers from '../rules/no-slotted-popovers.js';
import noDeprecatedSlots from '../rules/no-deprecated-slots.js';
import noExcessivePrimaryActions from '../rules/no-excessive-primary-actions.js';
import noMissingSlottedElements from '../rules/no-missing-slotted-elements.js';
import noMissingControlLabel from '../rules/no-missing-control-label.js';
import noMissingIconName from '../rules/no-missing-icon-name.js';
Expand Down Expand Up @@ -71,6 +72,7 @@ export const elementsHtmlConfig: Linter.Config = {
'no-deprecated-global-attribute-value': noDeprecatedGlobalAttributeValue,
'no-deprecated-global-attributes': noDeprecatedGlobalAttributes,
'no-deprecated-slots': noDeprecatedSlots,
'no-excessive-primary-actions': noExcessivePrimaryActions,
'no-missing-slotted-elements': noMissingSlottedElements,
'no-missing-control-label': noMissingControlLabel,
'no-missing-icon-name': noMissingIconName,
Expand Down Expand Up @@ -106,6 +108,7 @@ export const elementsHtmlConfig: Linter.Config = {
'@nvidia-elements/lint/no-deprecated-global-attribute-value': ['error'],
'@nvidia-elements/lint/no-deprecated-global-attributes': ['error'],
'@nvidia-elements/lint/no-deprecated-slots': ['error'],
'@nvidia-elements/lint/no-excessive-primary-actions': ['error'],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

went with slightly more generic name so the rule has some flexibility to what it checks

'@nvidia-elements/lint/no-missing-slotted-elements': ['error'],
'@nvidia-elements/lint/no-missing-control-label': ['error'],
'@nvidia-elements/lint/no-missing-icon-name': ['error'],
Expand Down
11 changes: 11 additions & 0 deletions projects/lint/src/eslint/internals/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ describe('lintPlaygroundTemplate', () => {
expect(result).toEqual([]);
});

it('should limit emphasis buttons per template', async () => {
const code = `
<nve-button interaction="emphasis">One</nve-button>
<nve-button interaction="emphasis">Two</nve-button>
<nve-button interaction="emphasis">Three</nve-button>
`;
const result = await lintTemplate(code, { strict: true });

expect(result.filter(message => message.id === 'excessive-primary-action')).toHaveLength(1);
});

it('should detect restricted attributes on custom elements', async () => {
const codeWithRestrictedAttribute = '<nve-button nve-layout="pad:md">Button</nve-button>';
const result = await lintTemplate(codeWithRestrictedAttribute, { strict: true });
Expand Down
147 changes: 147 additions & 0 deletions projects/lint/src/eslint/rules/no-excessive-primary-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// 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 noExcessivePrimaryActions from './no-excessive-primary-actions.js';

const rule = noExcessivePrimaryActions as unknown as JSRuleDefinition;
const error = {
messageId: 'excessive-primary-action' as const,
data: { max: '2' }
};

describe('noExcessivePrimaryActions', () => {
let tester: RuleTester;

beforeEach(() => {
tester = new RuleTester({
languageOptions: {
parser: htmlParser,
parserOptions: {
frontmatter: true
}
}
});
});

it('should define rule metadata', () => {
expect(noExcessivePrimaryActions.meta).toBeDefined();
expect(noExcessivePrimaryActions.meta.type).toBe('problem');
expect(noExcessivePrimaryActions.meta.docs).toBeDefined();
expect(noExcessivePrimaryActions.meta.docs.description).toBe('Limit primary actions to two per page.');
expect(noExcessivePrimaryActions.meta.docs.category).toBe('Best Practice');
expect(noExcessivePrimaryActions.meta.docs.recommended).toBe(true);
expect(noExcessivePrimaryActions.meta.docs.url).toContain('/docs/lint/');
expect(noExcessivePrimaryActions.meta.schema).toEqual([]);
expect(noExcessivePrimaryActions.meta.messages['excessive-primary-action']).toBe(
'Limit primary actions to {{max}} per page. Reserve interaction="emphasis" for primary calls to action.'
);
});

it('should allow no more than two emphasis buttons', () => {
tester.run('valid emphasis button count', rule, {
valid: [
'<nve-button>Default</nve-button>',
'<nve-button interaction="emphasis">Primary action</nve-button>',
`<nve-button interaction="emphasis">Primary action</nve-button>
<nve-button interaction="emphasis">Secondary action</nve-button>`,
`<nve-button interaction="emphasis">Primary action</nve-button>
<nve-button interaction="destructive">Delete</nve-button>
<nve-button interaction="neutral">Cancel</nve-button>`
],
invalid: []
});
});

it('should ignore emphasis on elements other than nve-button', () => {
tester.run('non-button elements', rule, {
valid: [
`<nve-icon-button interaction="emphasis" icon-name="menu"></nve-icon-button>
<custom-button interaction="emphasis">Custom action</custom-button>
<button interaction="emphasis">Native action</button>`
],
invalid: []
});
});

it('should ignore dynamic interaction values', () => {
tester.run('dynamic interaction values', rule, {
valid: [
`<nve-button interaction="\${interaction}">Lit attribute</nve-button>
<nve-button .interaction="\${interaction}">Lit property</nve-button>
<nve-button [interaction]="interaction">Angular property</nve-button>
<nve-button interaction="{{ interaction }}">Template binding</nve-button>
<nve-button interaction="{interaction}">JSX expression</nve-button>`
],
invalid: []
});
});

it('should count separate tagged templates independently', () => {
const javascriptTester = new RuleTester({
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
});

javascriptTester.run('separate tagged templates', rule, {
valid: [
`const first = html\`
<nve-button interaction="emphasis">One</nve-button>
<nve-button interaction="emphasis">Two</nve-button>
\`;
const second = html\`
<nve-button interaction="emphasis">Three</nve-button>
<nve-button interaction="emphasis">Four</nve-button>
\`;`
],
invalid: [
{
code: `const template = html\`
<nve-button interaction="emphasis">One</nve-button>
<nve-button interaction="emphasis">Two</nve-button>
<nve-button interaction="emphasis">Three</nve-button>
\`;`,
errors: [error]
}
]
});
});
Comment on lines +83 to +113

it('should report the third emphasis button', () => {
tester.run('third emphasis button', rule, {
valid: [],
invalid: [
{
code: `<nve-button interaction="emphasis">One</nve-button>
<nve-button interaction="emphasis">Two</nve-button>
<nve-button interaction="emphasis">Three</nve-button>`,
errors: [error]
}
]
});
});

it('should report every emphasis button after the second', () => {
tester.run('multiple excessive emphasis buttons', rule, {
valid: [],
invalid: [
{
code: `<main>
<nve-button interaction="emphasis">One</nve-button>
<section>
<nve-button interaction="emphasis">Two</nve-button>
<nve-button interaction="emphasis">Three</nve-button>
</section>
<nve-button interaction="emphasis">Four</nve-button>
</main>`,
errors: [error, error]
}
]
});
});
});
59 changes: 59 additions & 0 deletions projects/lint/src/eslint/rules/no-excessive-primary-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { Rule } from 'eslint';
import { createVisitors } from '@html-eslint/eslint-plugin/lib/rules/utils/visitors.js';
import { findAttr } from '@html-eslint/eslint-plugin/lib/rules/utils/node.js';
import type { HtmlTagNode } from '../rule-types.js';

declare const __ELEMENTS_PAGES_BASE_URL__: string;
const MAX_EMPHASIS_BUTTONS = 2;

const rule = {
meta: {
type: 'problem' as const,
docs: {
description: 'Limit primary actions to two per page.',
category: 'Best Practice',
recommended: true,
url: `${__ELEMENTS_PAGES_BASE_URL__}/docs/lint/`
},
schema: [],
messages: {
['excessive-primary-action']:
'Limit primary actions to {{max}} per page. Reserve interaction="emphasis" for primary calls to action.'
}
},
create(context: Rule.RuleContext) {
let emphasisButtonCount = 0;

return createVisitors(context, {
Document() {
emphasisButtonCount = 0;
},
Tag(node: HtmlTagNode) {
if (node.name.toLowerCase() !== 'nve-button') {
return;
}
Comment on lines +34 to +37

const interaction = findAttr(node, 'interaction');
if (interaction?.value?.value !== 'emphasis') {
return;
}

emphasisButtonCount += 1;
if (emphasisButtonCount <= MAX_EMPHASIS_BUTTONS) {
return;
}

context.report({
node: interaction,
messageId: 'excessive-primary-action',
data: { max: String(MAX_EMPHASIS_BUTTONS) }
});
}
});
}
} as const;

export default rule;
6 changes: 6 additions & 0 deletions projects/site/src/docs/lint/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ export default [
<nve-grid-cell>HTML</nve-grid-cell>
<nve-grid-cell><code nve-text="code">error</code></nve-grid-cell>
</nve-grid-row>
<nve-grid-row>
<nve-grid-cell><code nve-text="code">@nvidia-elements/lint/no-excessive-primary-actions</code></nve-grid-cell>
<nve-grid-cell>Limit primary actions to two per page.</nve-grid-cell>
<nve-grid-cell>HTML</nve-grid-cell>
<nve-grid-cell><code nve-text="code">error</code></nve-grid-cell>
</nve-grid-row>
<nve-grid-row>
<nve-grid-cell><code nve-text="code">@nvidia-elements/lint/no-invalid-event-listeners</code></nve-grid-cell>
<nve-grid-cell>Disallow inline event handler attributes in HTML.</nve-grid-cell>
Expand Down