diff --git a/projects/cli/src/index.test.ts b/projects/cli/src/index.test.ts index 5a0642ea55..9e3f22354b 100644 --- a/projects/cli/src/index.test.ts +++ b/projects/cli/src/index.test.ts @@ -25,6 +25,11 @@ describe('index', () => { expect(output).toContain('nve [args]'); }); + it('should hide the banner when output is not interactive', () => { + expect(output).not.toContain('░██████████'); + expect(output).toContain('@nvidia-elements/cli'); + }); + it('should provide api.list', () => { expect(output).toContain('nve api.list [format]'); }); @@ -163,7 +168,7 @@ describe('index', () => { it('should reject array arguments that exceed the schema limit', () => { const result = spawnSync( process.execPath, - ['dist/index.js', 'api.get', 'nve-card', 'nve-input', 'nve-button', 'nve-badge', 'nve-alert', 'nve-link'], + ['dist/index.js', 'api.get', 'nve-card', 'nve-input', 'nve-button', 'nve-badge'], { timeout: 10000, encoding: 'utf-8', @@ -173,7 +178,7 @@ describe('index', () => { ); expect(result.status).toBe(1); - expect(result.stderr).toContain('api.get accepts at most 5 names.'); + expect(result.stderr).toContain('api.get accepts at most 3 names.'); }); }); diff --git a/projects/cli/src/index.ts b/projects/cli/src/index.ts index f7ae94ba1c..dcdb76f30c 100755 --- a/projects/cli/src/index.ts +++ b/projects/cli/src/index.ts @@ -11,7 +11,15 @@ import { hideBin } from 'yargs/helpers'; import { performance } from 'perf_hooks'; import { type ManagedToolMethod, tools, ToolSupport, type Schema } from '@internals/tools'; import { installNve } from './install.js'; -import { banner, colors, exitWithCompleteToolResult, exitWithToolError, getArgValue, runAsyncTool } from './utils.js'; +import { + banner, + colors, + exitWithCompleteToolResult, + exitWithToolError, + getArgValue, + isInteractiveTerminal, + runAsyncTool +} from './utils.js'; import { notifyIfUpdateAvailable } from './update.js'; export const VERSION = '0.0.0'; @@ -76,7 +84,9 @@ yargsInstance.command( await exitWithToolError(result, message); } } else { - const greeting = colors.complete(`\x1b[?7l\n${JSON.parse(banner)}\n\n`); + const greeting = isInteractiveTerminal(process.stdout) + ? colors.complete(`\x1b[?7l\n${JSON.parse(banner)}\n\n`) + : ''; console.log( `${greeting}${colors.complete(`@nvidia-elements/cli (${BUILD_SHA})`)}\n\n${await yargsInstance.getHelp()}` ); diff --git a/projects/cli/src/utils.test.ts b/projects/cli/src/utils.test.ts index dfc02f7e47..6fb684d3a8 100644 --- a/projects/cli/src/utils.test.ts +++ b/projects/cli/src/utils.test.ts @@ -38,16 +38,24 @@ vi.mock('@inquirer/prompts', () => ({ })); describe('utils', () => { + const stderrIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); + beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }); }); afterEach(() => { vi.restoreAllMocks(); + if (stderrIsTTYDescriptor) { + Object.defineProperty(process.stderr, 'isTTY', stderrIsTTYDescriptor); + } else { + delete process.stderr.isTTY; + } }); describe('constants', () => { @@ -93,7 +101,7 @@ describe('utils', () => { it('should return different messages on multiple calls', () => { const messages = new Set(); - // Call multiple times to increase chance of getting different messages + // Call repeatedly to increase the chance of getting different messages for (let i = 0; i < 10; i++) { messages.add(getSpinnerProgressMessage()); } @@ -126,6 +134,16 @@ describe('utils', () => { delete process.env.CI; }); + it('should run function without spinner when stderr is not a TTY', async () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + const args = {}; + const result = await runAsyncTool(args, mockFn); + + expect(result).toBe('test result'); + expect(mockFn).toHaveBeenCalledWith(args); + expect(ora).not.toHaveBeenCalled(); + }); + it('should run function without spinner when start flag is true', async () => { const args = { start: true }; const result = await runAsyncTool(args, mockFn); diff --git a/projects/cli/src/utils.ts b/projects/cli/src/utils.ts index 60656cee66..624793f597 100644 --- a/projects/cli/src/utils.ts +++ b/projects/cli/src/utils.ts @@ -44,12 +44,20 @@ export function getSpinnerProgressMessage() { return messages[Math.floor(Math.random() * messages.length)]; } +export function isInteractiveTerminal(stream: NodeJS.WriteStream) { + return Boolean(stream.isTTY) && !process.env.CI; +} + +function shouldShowInteractiveProgress(args: Record, options: RunAsyncToolOptions) { + return (options.interactiveProgress ?? true) && isInteractiveTerminal(process.stderr) && !args.start && !args.log; +} + export async function runAsyncTool( args: Record, fn: ManagedToolMethod, options: RunAsyncToolOptions = {} ) { - const isInteractive = (options.interactiveProgress ?? true) && !args.start && !args.log && !process.env.CI; + const isInteractive = shouldShowInteractiveProgress(args, options); let spinner: Ora | undefined; const startTime = Date.now(); diff --git a/projects/core/src/page/page-panel/page-panel.ts b/projects/core/src/page/page-panel/page-panel.ts index 2615c82dc0..a818cb96e5 100644 --- a/projects/core/src/page/page-panel/page-panel.ts +++ b/projects/core/src/page/page-panel/page-panel.ts @@ -20,13 +20,13 @@ import globalStyles from './page-panel.global.css?inline'; * @description Child panel for embedded panels within the page component. Typically used for left/right/bottom page slot positions. * @entrypoint \@nvidia-elements/core/page * @since 1.15.0 - * @event open - * @event close + * @event open - Dispatched after an invoker command removes `hidden` and opens the panel. + * @event close - Dispatched after an invoker command sets `hidden` and closes the panel. * @slot - default content slot * @slot actions - slot for action / dismiss buttons - * @command --open - use to open the panel - * @command --close - use to close the panel - * @command --toggle - use to toggle the panel + * @command --open - Removes `hidden` and dispatches `open`. + * @command --close - Sets `hidden` and dispatches `close`. + * @command --toggle - Toggles `hidden` and dispatches `open` or `close`. * @cssprop --background * @cssprop --border * @cssprop --color diff --git a/projects/core/src/sort-button/sort-button.ts b/projects/core/src/sort-button/sort-button.ts index 18cb843d4c..e3ba5704bf 100644 --- a/projects/core/src/sort-button/sort-button.ts +++ b/projects/core/src/sort-button/sort-button.ts @@ -19,7 +19,7 @@ const nextSort = { * @description A sort button is a control that enables users to sort a list of items in ascending or descending order. * @since 0.11.0 * @entrypoint \@nvidia-elements/core/sort-button - * @event sort - Dispatched on sort button click, returns the current sort value and the next sort value. + * @event sort - Dispatched on activation with `detail: { value, next }`, where `value` is the current sort and `next` follows the cycle `none` → `ascending` → `descending` → `none`. * @cssprop --width * @cssprop --height * @cssprop --border-radius 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