diff --git a/NOTICE.md b/NOTICE.md index ca9b7a6b72..4e00a03761 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -25,10 +25,10 @@ This product includes the following bundled third-party software: - @inquirer/prompts v8.5.0 [MIT] (used by: @nvidia-elements/cli) Copyright: Simon Boudrias -- @modelcontextprotocol/ext-apps v1.7.3 [MIT] (used by: @nvidia-elements/cli) +- @modelcontextprotocol/ext-apps v1.7.5 [MIT] (used by: @nvidia-elements/cli) Copyright: Olivier Chafik -- @modelcontextprotocol/sdk v1.29.0 [MIT] (used by: @nvidia-elements/cli) +- @modelcontextprotocol/sdk v1.30.0 [MIT] (used by: @nvidia-elements/cli) Copyright: Anthropic, PBC (https://anthropic.com) - adm-zip v0.5.17 [MIT] (used by: @nvidia-elements/cli) @@ -307,8 +307,8 @@ The following bundled components are provided under the MIT license: @html-eslint/eslint-plugin v0.61.0 - Copyright yeonjuan (https://github.com/yeonjuan) @html-eslint/parser v0.61.0 - Copyright yeonjuan (https://github.com/yeonjuan) @inquirer/prompts v8.5.0 - Copyright Simon Boudrias -@modelcontextprotocol/ext-apps v1.7.3 - Copyright Olivier Chafik -@modelcontextprotocol/sdk v1.29.0 - Copyright Anthropic, PBC (https://anthropic.com) +@modelcontextprotocol/ext-apps v1.7.5 - Copyright Olivier Chafik +@modelcontextprotocol/sdk v1.30.0 - Copyright Anthropic, PBC (https://anthropic.com) adm-zip v0.5.17 - Copyright Nasca Iacob (https://github.com/cthackers) archiver v8.0.0 - Copyright Chris Talkington (http://christalkington.com/) markdown-it v14.3.0 - Copyright Unknown diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d36efb0c1f..e677a8944f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,11 +251,11 @@ catalogs: specifier: 2.1.2 version: 2.1.2 '@modelcontextprotocol/ext-apps': - specifier: 1.7.3 - version: 1.7.3 + specifier: 1.7.5 + version: 1.7.5 '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0 + specifier: 1.30.0 + version: 1.30.0 '@types/node': specifier: 25.6.2 version: 25.6.2 @@ -476,10 +476,10 @@ importers: version: 8.5.0(@types/node@25.6.2) '@modelcontextprotocol/ext-apps': specifier: 'catalog:' - version: 1.7.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) + version: 1.7.5(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(zod@4.4.3) + version: 1.30.0(zod@4.4.3) '@nvidia-elements/code': specifier: workspace:* version: link:../code @@ -1760,10 +1760,10 @@ importers: dependencies: '@modelcontextprotocol/ext-apps': specifier: 'catalog:' - version: 1.7.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) + version: 1.7.5(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: 'catalog:' - version: 1.29.0(zod@4.4.3) + version: 1.30.0(zod@4.4.3) '@nvidia-elements/core': specifier: workspace:* version: link:../../core @@ -3904,8 +3904,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@modelcontextprotocol/ext-apps@1.7.3': - resolution: {integrity: sha512-IRBaqDtBZyiwv046FNbyFCIERPT5npu2lDSTCsiRL4se9+s7zcLjRxXlQ7dyD0IZnyS2VjturHftY8GAiDvldA==} + '@modelcontextprotocol/ext-apps@1.7.5': + resolution: {integrity: sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==} engines: {node: '>=20'} peerDependencies: '@modelcontextprotocol/sdk': ^1.29.0 @@ -3928,6 +3928,16 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -15804,9 +15814,9 @@ snapshots: - encoding - supports-color - '@modelcontextprotocol/ext-apps@1.7.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3)': + '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) '@standard-schema/spec': 1.1.0 zod: 4.4.3 optionalDependencies: @@ -15835,7 +15845,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.20.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7b12c6d62..d940279c14 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -83,8 +83,8 @@ catalog: '@lit-labs/ssr-react': 0.3.4 '@lit-labs/virtualizer': 2.1.1 '@lit/reactive-element': 2.1.2 - '@modelcontextprotocol/ext-apps': 1.7.3 - '@modelcontextprotocol/sdk': 1.29.0 + '@modelcontextprotocol/ext-apps': 1.7.5 + '@modelcontextprotocol/sdk': 1.30.0 '@types/node': 25.6.2 '@types/react': 19.2.14 '@types/react-dom': 19.2.3 diff --git a/projects/cli/NOTICE.md b/projects/cli/NOTICE.md index 6c5aa42623..7cbe48f427 100644 --- a/projects/cli/NOTICE.md +++ b/projects/cli/NOTICE.md @@ -8,10 +8,10 @@ This project includes the following bundled third-party software: - @inquirer/prompts v8.5.0 [MIT] Copyright: Simon Boudrias -- @modelcontextprotocol/ext-apps v1.7.3 [MIT] +- @modelcontextprotocol/ext-apps v1.7.5 [MIT] Copyright: Olivier Chafik -- @modelcontextprotocol/sdk v1.29.0 [MIT] +- @modelcontextprotocol/sdk v1.30.0 [MIT] Copyright: Anthropic, PBC (https://anthropic.com) - adm-zip v0.5.17 [MIT] @@ -48,8 +48,8 @@ MIT The following bundled components are provided under the MIT license: @inquirer/prompts v8.5.0 - Copyright Simon Boudrias -@modelcontextprotocol/ext-apps v1.7.3 - Copyright Olivier Chafik -@modelcontextprotocol/sdk v1.29.0 - Copyright Anthropic, PBC (https://anthropic.com) +@modelcontextprotocol/ext-apps v1.7.5 - Copyright Olivier Chafik +@modelcontextprotocol/sdk v1.30.0 - Copyright Anthropic, PBC (https://anthropic.com) adm-zip v0.5.17 - Copyright Nasca Iacob (https://github.com/cthackers) archiver v8.0.0 - Copyright Chris Talkington (http://christalkington.com/) marked v18.0.3 - Copyright Christopher Jeffrey diff --git a/projects/core/src/accordion/accordion.ts b/projects/core/src/accordion/accordion.ts index 85069c6abc..6810c03136 100644 --- a/projects/core/src/accordion/accordion.ts +++ b/projects/core/src/accordion/accordion.ts @@ -30,7 +30,7 @@ import accordionGroupStyleSheet from './accordion-group.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/accordion/ * @since 0.12.0 * @entrypoint \@nvidia-elements/core/accordion - * @slot - default content slot + * @slot - Heading text and supporting content that labels the accordion section. * @slot prefix - slot for prefix content * @slot suffix - slot for suffix content * @cssprop --cursor @@ -57,6 +57,7 @@ export class AccordionHeader extends LitElement { `; } + /** @private */ @hostAttr() slot = 'header'; connectedCallback() { @@ -101,14 +102,13 @@ export class AccordionContent extends LitElement { * @command --open - use to open the accordion * @command --close - use to close the accordion * @command --toggle - use to toggle the accordion - * @slot - This is a default/unnamed slot for accordion content + * @slot - Content displayed in the collapsible region, typically an `nve-accordion-content` element. * @slot icon-button - icon elements to display for expand/collapse * @slot header - header element (Use `accordion-header` or custom content) - * @slot content - content element (Use `accordion-content` or custom content) * @cssprop --background * @cssprop --color * @cssprop --border-radius - * @cssprop --header-padding + * @cssprop --header-padding - Padding around the header content. * @cssprop --cursor * @cssprop --transition * @csspart icon-button - The toggle icon button element @@ -244,7 +244,9 @@ export class AccordionGroup extends LitElement { */ @property({ type: Boolean, attribute: 'behavior-expand-single' }) behaviorExpandSingle = false; - /** flat (Borderless, container-less accordions), full (default), or inset (Rounded corner, contained accordion) */ + /** + * Controls the container style applied to child accordions. `flat` removes the visual container, `inset` adds rounded containment, and omission uses the default divided presentation. + */ @property({ type: String, reflect: true }) container?: Extract; static readonly metadata = { diff --git a/projects/core/src/avatar/avatar-group.ts b/projects/core/src/avatar/avatar-group.ts index fb4e51d321..53a3cf3778 100644 --- a/projects/core/src/avatar/avatar-group.ts +++ b/projects/core/src/avatar/avatar-group.ts @@ -10,7 +10,7 @@ import styles from './avatar-group.css?inline'; * @description An avatar group displays a collection of user avatars in a compact and organized layout, showcasing many participants or contributors in a space-efficient way. * @since 1.20.0 * @entrypoint \@nvidia-elements/core/avatar - * @slot - default slot for content + * @slot - `nve-avatar` elements that represent the group members. * @aria https://www.w3.org/WAI/ARIA/apg/patterns/alert/ * */ diff --git a/projects/core/src/avatar/avatar.ts b/projects/core/src/avatar/avatar.ts index 8e2536d821..aebffeb304 100644 --- a/projects/core/src/avatar/avatar.ts +++ b/projects/core/src/avatar/avatar.ts @@ -14,7 +14,7 @@ import styles from './avatar.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/avatar/ * @since 1.20.0 * @entrypoint \@nvidia-elements/core/avatar - * @slot - default slot for content + * @slot - Initials, an image, or other content that identifies the represented user or bot. * @cssprop --background * @cssprop --color * @cssprop --border-radius diff --git a/projects/core/src/badge/badge.ts b/projects/core/src/badge/badge.ts index d2d2e57383..c5dd77b480 100644 --- a/projects/core/src/badge/badge.ts +++ b/projects/core/src/badge/badge.ts @@ -25,7 +25,7 @@ import styles from './badge.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/badge/ * @since 0.11.0 * @entrypoint \@nvidia-elements/core/badge - * @slot - default slot for content + * @slot - Short text that communicates the badge status. * @slot prefix-icon - slot for prefix icon * @slot suffix-icon - slot for suffix icon * @cssprop --background diff --git a/projects/core/src/card/card.ts b/projects/core/src/card/card.ts index 43f1a0ab49..39310ba3a6 100644 --- a/projects/core/src/card/card.ts +++ b/projects/core/src/card/card.ts @@ -55,7 +55,7 @@ export class Card extends LitElement implements ContainerElement { * @documentation https://nvidia.github.io/elements/docs/elements/card/ * @since 0.1.3 * @entrypoint \@nvidia-elements/core/card - * @slot - default slot + * @slot - Card title, supporting text, and optional action controls. * @cssprop --padding * @cssprop --border-bottom * @cssprop --line-height @@ -72,6 +72,7 @@ export class CardHeader extends LitElement { parents: ['nve-card'] }; + /** @private */ @hostAttr() slot = 'header'; render() { @@ -133,6 +134,7 @@ export class CardFooter extends LitElement { parents: ['nve-card'] }; + /** @private */ @hostAttr() slot = 'footer'; render() { diff --git a/projects/core/src/chat-message/chat-message.ts b/projects/core/src/chat-message/chat-message.ts index 911900d3f9..7ca8417e23 100644 --- a/projects/core/src/chat-message/chat-message.ts +++ b/projects/core/src/chat-message/chat-message.ts @@ -15,7 +15,7 @@ import globalStyles from './chat-message.global.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/chat-message/ * @since 1.25.0 * @entrypoint \@nvidia-elements/core/chat-message - * @slot - default slot for content + * @slot - The message body displayed between the prefix and suffix content. * @slot prefix - for avatar/img content * @slot suffix - for avatar/img content * @cssprop --background @@ -37,10 +37,12 @@ export class ChatMessage extends LitElement { version: '0.0.0' }; + /** Applies a transparent background and reduced horizontal padding for embedding the message in another container. */ @property({ type: String, reflect: true }) container: 'flat'; @property({ type: String, reflect: true }) color: Color; + /** Removes the border radius from the selected message corner to indicate the speaker direction. */ @property({ type: String, reflect: true, attribute: 'arrow-position' }) arrowPosition: | 'top-start' | 'top-end' diff --git a/projects/core/src/copy-button/copy-button.ts b/projects/core/src/copy-button/copy-button.ts index a72cce4667..1b6835e19b 100644 --- a/projects/core/src/copy-button/copy-button.ts +++ b/projects/core/src/copy-button/copy-button.ts @@ -17,7 +17,7 @@ import styles from './copy-button.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/copy-button/ * @since 1.1.4 * @entrypoint \@nvidia-elements/core/copy-button - * @slot - default + * @slot - Text label displayed before the copy icon. * @slot icon - slot for custom icon * @cssprop --color * @cssprop --background diff --git a/projects/core/src/dialog/dialog.ts b/projects/core/src/dialog/dialog.ts index e068dfe643..0492ceb136 100644 --- a/projects/core/src/dialog/dialog.ts +++ b/projects/core/src/dialog/dialog.ts @@ -29,7 +29,7 @@ import styles from './dialog.css?inline'; * @event toggle - Dispatched on a popover element just after showing or hiding. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event) * @event open - Dispatched when the dialog opens. * @event close - Dispatched when the dialog closes. - * @slot - default content slot + * @slot - Dialog body content displayed between the header and footer. * @cssprop --border * @cssprop --border-radius * @cssprop --background diff --git a/projects/core/src/drawer/drawer.ts b/projects/core/src/drawer/drawer.ts index aae5d2ed6c..da9a02c59d 100644 --- a/projects/core/src/drawer/drawer.ts +++ b/projects/core/src/drawer/drawer.ts @@ -26,7 +26,7 @@ import styles from './drawer.css?inline'; * @event toggle - Dispatched on a popover element just after showing or hiding. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event) * @event open - Dispatched when the drawer opens. * @event close - Dispatched when the drawer closes. - * @slot - default content slot + * @slot - Drawer body content displayed between the header and footer. * @cssprop --border * @cssprop --background * @cssprop --color diff --git a/projects/core/src/dropdown-group/dropdown-group.ts b/projects/core/src/dropdown-group/dropdown-group.ts index 57e834f1b5..f1a55c61bb 100644 --- a/projects/core/src/dropdown-group/dropdown-group.ts +++ b/projects/core/src/dropdown-group/dropdown-group.ts @@ -23,8 +23,6 @@ import globalStyles from './dropdown-group.global.css?inline'; * @slot - default slot for dropdown content * @event open - Dispatched when a dropdown in the group opens * @event close - Dispatched when a dropdown in the group closes - * @cssprop --nve-dropdown-group-spacing - * @cssprop --nve-dropdown-group-transition * @cssprop --arrow-transform - Transform applied to the popover arrow * @aria https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ */ @@ -114,6 +112,7 @@ export class DropdownGroup extends LitElement { } } + /** Closes every descendant dropdown in the group. */ close() { this.querySelectorAll('nve-dropdown').forEach(d => d.hidePopover()); } diff --git a/projects/core/src/dropdown/dropdown.ts b/projects/core/src/dropdown/dropdown.ts index 2077f825eb..1a4f67cc1b 100644 --- a/projects/core/src/dropdown/dropdown.ts +++ b/projects/core/src/dropdown/dropdown.ts @@ -103,6 +103,7 @@ export class Dropdown extends LitElement { /** @private */ @property({ type: String, attribute: 'popover-type' }) popoverType: PopoverType = 'auto'; + /** @private */ @query('.arrow') popoverArrow: HTMLElement; #i18nController: I18nController = new I18nController(this); diff --git a/projects/core/src/dropzone/dropzone.ts b/projects/core/src/dropzone/dropzone.ts index 8f5879426f..b004dbed57 100644 --- a/projects/core/src/dropzone/dropzone.ts +++ b/projects/core/src/dropzone/dropzone.ts @@ -16,30 +16,35 @@ import styles from './dropzone.css?inline'; import { FormControlMixin } from '@nvidia-elements/forms/mixins'; import { fileTypeValidator, fileSizeValidator, getFileTypeSpecifiers } from './dropzone.util'; +/* eslint-disable jsdoc/no-types */ +// Explicit JSDoc type because the CEM analyzer loses the inherited generic specialization. + /** * @element nve-dropzone * @description A dropzone form control that enables users to drag and drop files onto it. * @documentation https://nvidia.github.io/elements/docs/elements/dropzone/ * @since 1.29.0 * @entrypoint \@nvidia-elements/core/dropzone + * @property {File[]} value - Files selected or dropped by the user. Assign files through the JavaScript property; the HTML `value` attribute is not supported. * @event change - Dispatched when the value has changed (files located in event.target) - * @slot - use only when custom messaging requires it + * @slot - Custom messaging that replaces the default upload instructions. * @cssprop --background * @cssprop --border-color * @cssprop --border-radius * @cssprop --padding * @cssprop --min-height * @cssprop --color - * @slot icon - default slot for icon - * @slot content - default slot for content + * @slot icon - Custom icon that replaces the default upload icon. * @csspart icon - The upload icon element * @aria https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file */ @scopedRegistry() export class Dropzone extends FormControlMixin(LitElement) { + /** Comma-separated file extensions or MIME types accepted by the dropzone. */ @property() accept: string = `image/gif, image/jpeg, image/png, image/svg+xml`; + /** File size limit in bytes. */ @property({ attribute: 'max-file-size', type: Number }) maxFileSize: number = 2 * 1024 ** 2; @@ -76,6 +81,7 @@ export class Dropzone extends FormControlMixin(LitEle [Icon.metadata.tag]: Icon }; + /** @protected */ formResetCallback() { this.value = []; this.requestUpdate(); diff --git a/projects/core/src/format-number/format-number.ts b/projects/core/src/format-number/format-number.ts index 8a44361339..c28efa7a8d 100644 --- a/projects/core/src/format-number/format-number.ts +++ b/projects/core/src/format-number/format-number.ts @@ -110,7 +110,8 @@ export class FormatNumber extends LitElement { /** * Grouping separators: 'auto' | 'always' | 'min2' | 'true' | 'false'. */ - @property({ type: String, attribute: 'use-grouping' }) useGrouping?: string; + @property({ type: String, attribute: 'use-grouping' }) + useGrouping?: 'auto' | 'always' | 'min2' | 'true' | 'false' | boolean; /** * Pad fraction output to at least this many digits (0-20). diff --git a/projects/core/src/forms/control-message/control-message.ts b/projects/core/src/forms/control-message/control-message.ts index 5661028d43..353ec8dafe 100644 --- a/projects/core/src/forms/control-message/control-message.ts +++ b/projects/core/src/forms/control-message/control-message.ts @@ -22,7 +22,7 @@ const statusIcons = { * @documentation https://nvidia.github.io/elements/docs/elements/control/ * @since 0.3.0 * @entrypoint \@nvidia-elements/core/forms - * @slot - default slot for content + * @slot - Validation or supporting message text for the associated control. * @cssprop --color * @cssprop --font-weight * @cssprop --font-size @@ -54,6 +54,7 @@ export class ControlMessage extends LitElement { [Icon.metadata.tag]: Icon }; + /** @private */ @hostAttr() slot = 'messages'; render() { diff --git a/projects/core/src/grid/cell/cell.ts b/projects/core/src/grid/cell/cell.ts index 14560ca0ff..e9ccb05c92 100644 --- a/projects/core/src/grid/cell/cell.ts +++ b/projects/core/src/grid/cell/cell.ts @@ -11,7 +11,7 @@ import styles from './cell.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/data-grid/ * @since 0.11.0 * @entrypoint \@nvidia-elements/core/grid - * @slot - default slot for content + * @slot - Data or interactive controls displayed within the cell. * @cssprop --background * @cssprop --color * @cssprop --padding diff --git a/projects/core/src/grid/column/column.ts b/projects/core/src/grid/column/column.ts index bf293c123c..600d63758e 100644 --- a/projects/core/src/grid/column/column.ts +++ b/projects/core/src/grid/column/column.ts @@ -14,7 +14,7 @@ import styles from './column.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/data-grid/ * @since 0.11.0 * @entrypoint \@nvidia-elements/core/grid - * @slot - default slot for content + * @slot - Column heading text or controls. * @slot actions - slot for column actions * @cssprop --color * @cssprop --padding diff --git a/projects/core/src/grid/footer/footer.ts b/projects/core/src/grid/footer/footer.ts index 2bedad013b..3cba349304 100644 --- a/projects/core/src/grid/footer/footer.ts +++ b/projects/core/src/grid/footer/footer.ts @@ -10,7 +10,7 @@ import styles from './footer.css?inline'; * @since 0.11.0 * @entrypoint \@nvidia-elements/core/grid * @description Grid footer displays contextual information or user actions such as pagination. - * @slot - default slot for content + * @slot - Grid summary, pagination, or other footer controls. * @cssprop --background * @cssprop --color * @cssprop --padding @@ -30,6 +30,7 @@ export class GridFooter extends LitElement { /** @private */ _internals: ElementInternals; + /** @private */ @hostAttr() slot = 'footer'; render() { diff --git a/projects/core/src/grid/grid.ts b/projects/core/src/grid/grid.ts index ddeb7e3500..efecda06bb 100644 --- a/projects/core/src/grid/grid.ts +++ b/projects/core/src/grid/grid.ts @@ -29,7 +29,7 @@ import globalStyles from './grid.global.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/data-grid/ * @since 0.11.0 * @entrypoint \@nvidia-elements/core/grid - * @slot - default slot for content + * @slot - `nve-grid-header`, `nve-grid-row`, and `nve-grid-placeholder` elements. * @slot footer - slot for grid-footer or toolbar * @cssprop --background * @cssprop --color diff --git a/projects/core/src/grid/placeholder/placeholder.ts b/projects/core/src/grid/placeholder/placeholder.ts index 7266442008..f8bdcf88af 100644 --- a/projects/core/src/grid/placeholder/placeholder.ts +++ b/projects/core/src/grid/placeholder/placeholder.ts @@ -10,7 +10,7 @@ import styles from './placeholder.css?inline'; * @since 0.11.0 * @entrypoint \@nvidia-elements/core/grid * @description Placeholder displays a message while data loads for the grid or shows empty states for datasets. - * @slot - default slot for content + * @slot - Loading or empty-state message displayed in place of grid rows. * @cssprop --color * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/grid/ diff --git a/projects/core/src/internal/types/index.ts b/projects/core/src/internal/types/index.ts index e1c8ee104f..b8a5aee5b7 100644 --- a/projects/core/src/internal/types/index.ts +++ b/projects/core/src/internal/types/index.ts @@ -333,10 +333,10 @@ export interface NveElement { /** Determines the alignment of the popover relative to the provided anchor element. */ alignment?: 'start' | 'end' | 'center'; - /** Provides the element that the popover should position relative to. Anchor can accept a idref string within the same render root or a HTMLElement DOM reference. */ + /** Provides the element that the popover should position relative to. Set the property to an element or its ID within the same render root. The HTML attribute accepts only the element ID. */ anchor?: string | HTMLElement; - /** Defines what element triggers an `open` interaction event. A trigger can accept a idref string within the same render root or a HTMLElement DOM reference. */ + /** Defines the element that triggers an `open` interaction event. Set the property to an element or its ID within the same render root. The HTML attribute accepts only the element ID. */ trigger?: string | HTMLElement; /** Defines named content areas where users can insert custom markup into the element. @@ -360,7 +360,7 @@ export interface NveElement { closeTimeout?: number; /** - * Sets the delay in milliseconds before the element emits a `open` event. + * Sets the delay in milliseconds before the element emits an `open` event. * - `0` - Keyboard focus interactions (always immediate for accessibility). * - `500` - Dense interfaces with many tooltips to reduce visual noise and prevent accidental triggers. */ diff --git a/projects/core/src/logo/logo.ts b/projects/core/src/logo/logo.ts index 71d3a27326..f12d065e37 100644 --- a/projects/core/src/logo/logo.ts +++ b/projects/core/src/logo/logo.ts @@ -13,7 +13,7 @@ import styles from './logo.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/logo/ * @since 0.10.0 * @entrypoint \@nvidia-elements/core/logo - * @slot - default slot for content + * @slot - Text, an image, or other visual content representing the brand or application. * @cssprop --background * @cssprop --gap * @cssprop --color diff --git a/projects/core/src/menu/menu-item.ts b/projects/core/src/menu/menu-item.ts index c8555a9746..0d1c84f9cb 100644 --- a/projects/core/src/menu/menu-item.ts +++ b/projects/core/src/menu/menu-item.ts @@ -13,7 +13,7 @@ import styles from './menu-item.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/menu/ * @since 0.11.0 * @entrypoint \@nvidia-elements/core/menu - * @slot - default slot for content + * @slot - Label and supporting content for the menu option. * @slot suffix - slot for suffix icon * @cssprop --background * @cssprop --border-radius @@ -36,6 +36,7 @@ import styles from './menu-item.css?inline'; export class MenuItem extends ButtonFormControlMixin(LitElement) { static styles = useStyles([styles]); + /** Applies danger styling to destructive or high-risk menu actions. */ @property({ type: String, reflect: true }) status: 'danger'; static readonly metadata = { diff --git a/projects/core/src/notification/notification.ts b/projects/core/src/notification/notification.ts index 1a71a5662c..9cb402e4b6 100644 --- a/projects/core/src/notification/notification.ts +++ b/projects/core/src/notification/notification.ts @@ -28,7 +28,7 @@ import styles from './notification.css?inline'; * @event toggle - Dispatched on a popover element just after showing or hiding. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event) * @event open - Dispatched when the notification opens. * @event close - Dispatched when the notification closes. - * @slot - default content slot + * @slot - The message communicated by the notification. * @slot icon - content slot for the status icon * @cssprop --border-radius * @cssprop --background diff --git a/projects/core/src/page-loader/page-loader.ts b/projects/core/src/page-loader/page-loader.ts index 0db1e495d3..9e486f04b5 100644 --- a/projects/core/src/page-loader/page-loader.ts +++ b/projects/core/src/page-loader/page-loader.ts @@ -14,7 +14,7 @@ import styles from './page-loader.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/page-loader/ * @since 0.19.0 * @entrypoint \@nvidia-elements/core/page-loader - * @slot - default slot for content + * @slot - Loading status text displayed with the progress indicator. * @cssprop --gap * @cssprop --animation-duration - Duration of page loader open/close animations * @csspart progress-ring - The progress ring element diff --git a/projects/core/src/page/page-panel/page-panel-footer.ts b/projects/core/src/page/page-panel/page-panel-footer.ts index d86f43ba18..fde2ce3644 100644 --- a/projects/core/src/page/page-panel/page-panel-footer.ts +++ b/projects/core/src/page/page-panel/page-panel-footer.ts @@ -27,6 +27,7 @@ export class PagePanelFooter extends LitElement { version: '0.0.0' }; + /** @private */ @hostAttr() slot = 'footer'; render() { diff --git a/projects/core/src/page/page-panel/page-panel-header.ts b/projects/core/src/page/page-panel/page-panel-header.ts index 63ae2ab4ea..a0b0ed4e16 100644 --- a/projects/core/src/page/page-panel/page-panel-header.ts +++ b/projects/core/src/page/page-panel/page-panel-header.ts @@ -26,6 +26,7 @@ export class PagePanelHeader extends LitElement { version: '0.0.0' }; + /** @private */ @hostAttr() slot = 'header'; render() { diff --git a/projects/core/src/page/page-panel/page-panel.ts b/projects/core/src/page/page-panel/page-panel.ts index c33376c39a..d9ad4f1e4e 100644 --- a/projects/core/src/page/page-panel/page-panel.ts +++ b/projects/core/src/page/page-panel/page-panel.ts @@ -23,7 +23,7 @@ import globalStyles from './page-panel.global.css?inline'; * @since 1.15.0 * @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 - `nve-page-panel-content` elements that provide the panel body. * @slot actions - slot for action / dismiss buttons * @command --open - Removes `hidden` and dispatches `open`. * @command --close - Sets `hidden` and dispatches `close`. diff --git a/projects/core/src/pagination/pagination.ts b/projects/core/src/pagination/pagination.ts index 5fb6dc0af3..b1888b11aa 100644 --- a/projects/core/src/pagination/pagination.ts +++ b/projects/core/src/pagination/pagination.ts @@ -31,8 +31,8 @@ import styles from './pagination.css?inline'; * @event change - Dispatched when the value (page) has changed * @event first-page - Dispatched when the first page is active * @event last-page - Dispatched when the last page is active - * @slot - default slot for content - * @slot suffix-label - slot for overriding the "n of total" label when total is an approximation + * @event step-change - Dispatched after the page size changes with the current page size in `detail`. The event bubbles and crosses shadow boundaries. + * @slot suffix-label - Overrides the "of total" label when the total is an approximation. * @cssprop --background * @cssprop --font-size * @cssprop --width @@ -44,6 +44,7 @@ import styles from './pagination.css?inline'; * @csspart select - The page size select element * @aria https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/ * @property {number} value - value the current page number + * @attribute {string} step-sizes - A JSON-serialized array of page-size options available in the selector. */ @typeSSR() @keyNavigationList() @@ -54,9 +55,9 @@ export class Pagination extends FormControlMixin(LitE */ @property({ type: Number }) step = 10; /** - * The array of custom step-size. + * Page-size options available in the selector. */ - @property({ type: Array }) stepSizes: number[] = [10, 20, 50, 100]; + @property({ type: Array, attribute: 'step-sizes' }) stepSizes: number[] = [10, 20, 50, 100]; /** * The total number of items. diff --git a/projects/core/src/preferences-input/preferences-input.ts b/projects/core/src/preferences-input/preferences-input.ts index 548d885684..c7189654cf 100644 --- a/projects/core/src/preferences-input/preferences-input.ts +++ b/projects/core/src/preferences-input/preferences-input.ts @@ -13,6 +13,9 @@ import { Menu, MenuItem } from '@nvidia-elements/core/menu'; import { Switch } from '@nvidia-elements/core/switch'; import styles from './preferences-input.css?inline'; +/* eslint-disable jsdoc/no-types */ +// Explicit JSDoc types because the CEM analyzer loses the inherited generic specialization. + export type ColorScheme = 'auto' | 'light' | 'dark' | 'high-contrast'; export type Scale = 'default' | 'compact' | 'relaxed'; export type Variant = 'reduced-motion'; @@ -50,6 +53,8 @@ export interface PreferencesInputValue { * @documentation https://nvidia.github.io/elements/docs/elements/preferences-input/ * @since 1.23.7 * @entrypoint \@nvidia-elements/core/preferences-input + * @property {PreferencesInputValue} value - Contains the current color scheme, scale, and reduced motion preferences. Assign an object through JavaScript or provide its JSON serialization in the HTML `value` attribute. + * @attribute {string} value - JSON object parsed as `PreferencesInputValue`. * @event input - Dispatched when the value has changed * @event change - Dispatched when the value has changed * @cssprop --color diff --git a/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts b/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts index 72be400507..9c56242ce6 100644 --- a/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts +++ b/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts @@ -27,7 +27,7 @@ import styles from './progressive-filter-chip.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/progressive-filter-chip/ * @since 0.16.0 * @entrypoint \@nvidia-elements/core/progressive-filter-chip - * @slot - default slot for content + * @slot - `input`, `select`, `nve-button`, or other `[nve-control]` elements used to build the filter. * @event close - Dispatched when the filter chip closes. * @cssprop --gap * @cssprop --border-radius diff --git a/projects/core/src/skeleton/skeleton.ts b/projects/core/src/skeleton/skeleton.ts index 4493be0695..e0bd4a37c8 100644 --- a/projects/core/src/skeleton/skeleton.ts +++ b/projects/core/src/skeleton/skeleton.ts @@ -12,7 +12,7 @@ import styles from './skeleton.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/skeleton/ * @since 1.44.0 * @entrypoint \@nvidia-elements/core/skeleton - * @slot - default content slot + * @slot - Loaded content that replaces the placeholder when provided. * @cssprop --background * @cssprop --border-radius * @cssprop --min-height @@ -32,7 +32,7 @@ export class Skeleton extends LitElement { /** Geometry of the placeholder — rounded corners or a full pill outline. */ @property({ type: String, reflect: true }) shape: 'round' | 'pill'; - /** Whether the skeleton hides its content */ + /** Hides the skeleton host when `true` and sets `aria-busy` to the inverse visibility state. */ @property({ type: Boolean, reflect: true }) hidden = false; /** @private */ diff --git a/projects/core/src/steps/steps.ts b/projects/core/src/steps/steps.ts index 90e618396b..1f4a1ddf75 100644 --- a/projects/core/src/steps/steps.ts +++ b/projects/core/src/steps/steps.ts @@ -51,7 +51,7 @@ export class StepsItem extends ButtonFormControlMixin(LitElement) { @property({ type: Boolean, reflect: true }) selected = false; /** - * Four visual treatments represent the `status` of tasks. When `status` has a value of `warning`, `success`, or `danger`, the component embeds appropriate icons. + * Controls the step status. `accent` has no default status icon, `danger` displays an alert icon, `success` displays a check icon, and `pending` displays a progress indicator. Omit the property to display the step number. */ @property({ type: String, reflect: true }) status?: 'accent' | 'danger' | 'success' | 'pending'; diff --git a/projects/core/src/tabs/tabs.ts b/projects/core/src/tabs/tabs.ts index 9a9b6f7f66..2eb1d0dad8 100644 --- a/projects/core/src/tabs/tabs.ts +++ b/projects/core/src/tabs/tabs.ts @@ -27,7 +27,7 @@ import tabsStyleSheet from './tabs.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/tabs/ * @since 0.10.0 * @entrypoint \@nvidia-elements/core/tabs - * @slot - default slot for content + * @slot - The label displayed for the tab. * @cssprop --font-size * @cssprop --width * @cssprop --padding diff --git a/projects/core/src/tag/tag.ts b/projects/core/src/tag/tag.ts index d5b3b4e293..5c2ecbee91 100644 --- a/projects/core/src/tag/tag.ts +++ b/projects/core/src/tag/tag.ts @@ -22,7 +22,7 @@ import styles from './tag.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/tag/ * @since 0.10.0 * @entrypoint \@nvidia-elements/core/tag - * @slot - default slot for content + * @slot - Text or other content that identifies the category or group. * @cssprop --background * @cssprop --color * @cssprop --gap diff --git a/projects/core/src/toast/toast.ts b/projects/core/src/toast/toast.ts index a8b789716e..87f3ce44ed 100644 --- a/projects/core/src/toast/toast.ts +++ b/projects/core/src/toast/toast.ts @@ -29,7 +29,7 @@ import styles from './toast.css?inline'; * @event toggle - Dispatched on a popover element just after showing or hiding. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event) * @event open - Dispatched when the toast opens. * @event close - Dispatched when the toast closes. - * @slot - default content slot + * @slot - The message communicated by the toast. * @slot prefix - custom status icon slot * @cssprop --padding * @cssprop --justify-content diff --git a/projects/core/src/toggletip/toggletip.ts b/projects/core/src/toggletip/toggletip.ts index 8449543178..c844c2fc08 100644 --- a/projects/core/src/toggletip/toggletip.ts +++ b/projects/core/src/toggletip/toggletip.ts @@ -103,6 +103,7 @@ export class Toggletip extends LitElement { */ @property({ type: Boolean }) arrow = true; + /** @private */ @query('.arrow') popoverArrow: HTMLElement; /** @private */ diff --git a/projects/core/src/toolbar/toolbar.ts b/projects/core/src/toolbar/toolbar.ts index c9c5c4cd65..b9dd3cdefb 100644 --- a/projects/core/src/toolbar/toolbar.ts +++ b/projects/core/src/toolbar/toolbar.ts @@ -22,7 +22,7 @@ import styles from './toolbar.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/toolbar/ * @since 0.19.0 * @entrypoint \@nvidia-elements/core/toolbar - * @slot - default slot for content + * @slot - Primary buttons, inputs, and other toolbar controls. * @slot prefix - slot for prefix content * @slot suffix - slot for suffix content * @cssprop --background diff --git a/projects/core/src/tooltip/tooltip.ts b/projects/core/src/tooltip/tooltip.ts index 39ab86d822..107a38d588 100644 --- a/projects/core/src/tooltip/tooltip.ts +++ b/projects/core/src/tooltip/tooltip.ts @@ -25,7 +25,7 @@ import styles from './tooltip.css?inline'; * @event toggle - Dispatched on a popover element just after showing or hiding. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event) * @event open - Dispatched when the tooltip opens. * @event close - Dispatched when the tooltip closes. - * @slot - default content slot + * @slot - Noninteractive text or other descriptive content shown in the tooltip. * @cssprop --border-radius * @cssprop --background * @cssprop --color @@ -89,6 +89,7 @@ export class Tooltip extends LitElement { */ @property({ type: Number, attribute: 'open-delay' }) openDelay: number; + /** @private */ @query('.arrow') popoverArrow: HTMLElement; /** @private */ diff --git a/projects/core/src/tree/tree-node.ts b/projects/core/src/tree/tree-node.ts index 9982968620..f382627344 100644 --- a/projects/core/src/tree/tree-node.ts +++ b/projects/core/src/tree/tree-node.ts @@ -112,13 +112,13 @@ export class TreeNode extends LitElement { */ @queryAssignedElements({ slot: 'nodes' }) readonly nodes!: TreeNode[]; - /* @private */ + /** @private */ @state() indeterminate = false; - /* @private */ + /** @private */ @state() behaviorExpand = false; - /* @private */ + /** @private */ @state() behaviorSelect = false; #typeExpandableController = new TypeExpandableController(this); diff --git a/projects/forms/src/mixins/button.types.ts b/projects/forms/src/mixins/button.types.ts index ed1a19425b..5ab642ebee 100644 --- a/projects/forms/src/mixins/button.types.ts +++ b/projects/forms/src/mixins/button.types.ts @@ -23,7 +23,7 @@ export interface ButtonFormControlMixinInstance { expanded: boolean; /** - * Like input readonly, sets a button semantically as visual treatment only. + * Indicates whether the element is a noninteractive visual treatment instead of a button. * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly * @attr readonly * @reflect @@ -31,8 +31,8 @@ export interface ButtonFormControlMixinInstance { readOnly: boolean; /** - * Like input form, sets a button to submit a form outside its parent form. - * Returns a reference to the form element if available. + * Associates the button with a form. Set the property to a form element or its ID. The HTML attribute accepts only + * the form ID. Reading the property returns the associated form element or `null`. * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/form * @attr form */ diff --git a/projects/internals/vite/src/plugins/cem.config.mjs b/projects/internals/vite/src/plugins/cem.config.mjs index f07fa831b6..474a866e5c 100644 --- a/projects/internals/vite/src/plugins/cem.config.mjs +++ b/projects/internals/vite/src/plugins/cem.config.mjs @@ -11,6 +11,100 @@ const pkg = JSON.parse(fs.readFileSync(resolve('./package.json'), 'utf-8')); const runtimeEnvironment = {}; const baseInterface = getBaseInterface(); +function isOmittedTypeBranch(type) { + const value = type.trim(); + return value === '' || value === 'undefined'; +} + +export function getDocumentedTypeValues(type) { + if (typeof type !== 'string') { + return []; + } + + return type + .split('|') + .map(value => value.trim()) + .filter(value => !isOmittedTypeBranch(value) && value !== "''" && value !== '""') + .map(value => value.replace(/^(['"])(.*)\1$/, '$2')); +} + +function normalizeTypeValues(type) { + if (typeof type !== 'string') { + return new Set(); + } + + return new Set( + type + .split('|') + .map(value => value.trim()) + .filter(value => !isOmittedTypeBranch(value)) + .map(value => value.replace(/^(['"])(.*)\1$/, '$2')) + ); +} + +export function rewriteTypesText(entry, stringLiteralsByTypeAlias = new Map()) { + let text = entry.type?.text; + if (!text) { + return; + } + + if (text.startsWith('Extract')) { + text = text + .replace('Extract<', '') + .replace(/>(?=\s*(?:\||$))/, '') + .split(',')[1] + .trim(); + entry.type.text = text; + } + + const types = text.split('|').map(value => value.trim()); + const rewrittenTypes = new Set(); + let performRewrite = false; + const sourceAliases = []; + + for (const type of types) { + const stringLiterals = stringLiteralsByTypeAlias.get(type); + if (stringLiterals !== undefined) { + performRewrite = true; + sourceAliases.push(type); + for (const stringLiteral of stringLiterals) { + // NOTE: This has() check is necessary to retain TypeScript's first-declaration-wins ordering. + if (!rewrittenTypes.has(stringLiteral)) { + rewrittenTypes.add(stringLiteral); + } + } + } else { + rewrittenTypes.add(type); + } + } + + if (performRewrite) { + entry.type.text = Array.from(rewrittenTypes).join(' | '); + } + + entry.type._sourceAliases = sourceAliases; +} + +export function typesMatch(entry, baseProperty) { + const entryTypes = normalizeTypeValues(entry.type?.text); + const baseTypes = normalizeTypeValues(baseProperty.type); + + return ( + entryTypes.size > 0 && entryTypes.size === baseTypes.size && [...entryTypes].every(type => baseTypes.has(type)) + ); +} + +/** APIs whose local or inherited descriptions intentionally specialize the broad NveElement contract. */ +const standardDescriptionExclusions = { + value: declaration => declaration.tagName === 'nve-copy-button' || declaration.tagName === 'nve-preferences-input', + readOnly: (_declaration, entry) => entry.inheritedFrom?.name === 'ButtonFormControlMixin' +}; + +export function shouldUseStandardDescription(declaration, entry) { + const propertyName = entry.fieldName ?? entry.name; + return !standardDescriptionExclusions[propertyName]?.(declaration, entry); +} + /** todo: this should be more generalized and not coupled specifically to the elements core package */ function getBaseInterface() { const baseInterfacePath = resolve('src/internal/types/index.ts'); @@ -350,32 +444,44 @@ function getTypeText(type, location) { return type.getText(location); } -function isElementReferencePropertyType(type, seen = new Set()) { - const nonNullableTypes = type.getUnionTypes().filter(unionType => !unionType.isNull() && !unionType.isUndefined()); +function isElementReferenceTypeBranch(type) { + return /^(?:[A-Za-z_$][\w$]*\.)?[A-Za-z_$][\w$]*Element$/.test(type); +} - if (nonNullableTypes.length > 1) { - return nonNullableTypes.every(unionType => isElementReferencePropertyType(unionType, new Set(seen))); - } +// CEM attribute types normally follow the property's converted type. Properties containing an element reference accept +// an element or ID string in JavaScript, while their HTML attributes accept only the ID string. +export function getAttributeFacingTypeText(propertyTypeText, attributeTypeText = propertyTypeText) { + const branches = propertyTypeText + .split('|') + .map(type => type.trim()) + .filter(type => type !== 'null' && type !== 'undefined'); + const hasElementReference = branches.some(isElementReferenceTypeBranch); + + return hasElementReference && branches.every(type => type === 'string' || isElementReferenceTypeBranch(type)) + ? 'string' + : attributeTypeText; +} - const nonNullableType = nonNullableTypes[0] ?? type; - const symbolName = nonNullableType.getSymbol()?.getName() ?? nonNullableType.getAliasSymbol()?.getName(); - if (symbolName === 'Element') { - return true; +function getExplicitAttributeDoc(tag, sourceFile) { + if (!['attr', 'attribute'].includes(tag.tagName?.getText(sourceFile))) { + return undefined; } - const typeKey = nonNullableType.getText(); - if (seen.has(typeKey)) { - return false; + const match = tag + .getText(sourceFile) + .trim() + .match(/^@(?:attr|attribute)\s+(?:\{([^}]+)\}\s+)?(\S+)(?:\s+([\s\S]*))?$/); + if (!match) { + return undefined; } - seen.add(typeKey); - return nonNullableType.getBaseTypes().some(baseType => isElementReferencePropertyType(baseType, new Set(seen))); -} - -// CEM attribute types normally follow the property's converted type. Element-reference attributes instead accept an -// element id, so their attribute-facing type is string while the JavaScript property remains Element | null. -function getCemAttributeTypeText(propertyType, propertyTypeText) { - return isElementReferencePropertyType(propertyType) ? 'string' : propertyTypeText; + const [, type, name, descriptionText] = match; + const description = descriptionText?.replace(/^-\s*/, '').trim(); + return { + name, + ...(type && { type: { text: type } }), + ...(description && { description }) + }; } function escapeRegExp(value) { @@ -441,7 +547,7 @@ function createMixinApiEntry(property, location, mixinName) { ? { name: attribute, fieldName: member.name, - type: { text: getCemAttributeTypeText(propertyType, typeText) }, + type: { text: getAttributeFacingTypeText(typeText) }, description, inheritedFrom: member.inheritedFrom } @@ -537,36 +643,28 @@ function getDeclarationMixins(declaration, declarationRegistry, seen = new Set() ]; } -function addUniqueMember(declaration, member) { +export function addUniqueMember(declaration, member) { declaration.members ??= []; - if (!declaration.members.some(item => item.name === member.name)) { - declaration.members.push(structuredClone(member)); - } -} - -function isTypescriptElementReferencePropertyType(type, ts, seen = new Set()) { - const nonNullableTypes = type.isUnion() - ? type.types.filter(item => !(item.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))) - : []; - - if (nonNullableTypes.length > 1) { - return nonNullableTypes.every(item => isTypescriptElementReferencePropertyType(item, ts, new Set(seen))); - } - - const nonNullableType = nonNullableTypes[0] ?? type; - const symbolName = nonNullableType.getSymbol?.()?.getName() ?? nonNullableType.aliasSymbol?.getName(); - if (symbolName === 'Element') { - return true; - } - - if (seen.has(nonNullableType)) { - return false; + const existingMember = declaration.members.find(item => item.name === member.name); + + if (existingMember) { + const inheritedTypeValues = member.type?.text + ?.split('|') + .map(type => type.trim()) + .filter(type => type !== 'undefined'); + const hasFiniteStringUnion = + inheritedTypeValues?.length > 1 && inheritedTypeValues.every(type => /^(['"]).*\1$/.test(type)); + + if (existingMember.type?.text === 'string' && hasFiniteStringUnion) { + existingMember.type = structuredClone(member.type); + } + if (!existingMember.description && member.description) { + existingMember.description = member.description; + } + return; } - seen.add(nonNullableType); - return (nonNullableType.getBaseTypes?.() ?? []).some(baseType => - isTypescriptElementReferencePropertyType(baseType, ts, new Set(seen)) - ); + declaration.members.push(structuredClone(member)); } // Mixin APIs are analyzed generically. Resolve type parameters against each concrete component before projection, then @@ -588,11 +686,19 @@ function resolveConcreteMixinAttributeType(modulePath, declarationName, memberNa const type = typeChecker.getTypeOfSymbolAtLocation(property, classDeclaration); const typeText = typeChecker.typeToString(type, classDeclaration, ts.TypeFormatFlags.NoTruncation); - return isTypescriptElementReferencePropertyType(type, ts) ? 'string' : typeText; + return getAttributeFacingTypeText(typeText); +} + +const projectedMixinAttributeExclusions = { + value: declaration => declaration.tagName === 'nve-dropzone' +}; + +export function shouldProjectMixinAttribute(declaration, attribute) { + return !projectedMixinAttributeExclusions[attribute.fieldName]?.(declaration); } function addProjectedMixinAttribute(declaration, attribute, { modulePath, requiresMixinTypeSpecialization }) { - if (!attribute) { + if (!attribute || !shouldProjectMixinAttribute(declaration, attribute)) { return; } @@ -642,6 +748,47 @@ function mixinApiProjectionPlugin() { }; } +/** Keeps JavaScript element-reference properties distinct from their ID-reference HTML attributes. */ +export function attributeTypesPlugin() { + return { + name: 'attribute-types', + analyzePhase({ ts, node, moduleDoc }) { + if (node.kind !== ts.SyntaxKind.ClassDeclaration) { + return; + } + + const sourceFile = node.getSourceFile(); + const declaration = moduleDoc.declarations?.find(item => item.name === node.name?.getText(sourceFile)); + node.jsDoc?.forEach(jsDoc => { + jsDoc.tags?.forEach(tag => { + const explicitAttribute = getExplicitAttributeDoc(tag, sourceFile); + const attribute = declaration?.attributes?.find(item => item.name === explicitAttribute?.name); + if (attribute) { + Object.assign(attribute, explicitAttribute); + } + }); + }); + }, + packageLinkPhase({ customElementsManifest }) { + customElementsManifest.modules + .flatMap(module => module.declarations ?? []) + .filter(declaration => declaration.tagName) + .forEach(declaration => { + declaration.attributes?.forEach(attribute => { + const member = declaration.members?.find( + item => item.name === attribute.fieldName || item.name === attribute.name + ); + if (!member?.type?.text || !attribute.type?.text) { + return; + } + + attribute.type.text = getAttributeFacingTypeText(member.type.text, attribute.type.text); + }); + }); + } + }; +} + function basePathPlugin() { return { name: 'base', @@ -790,18 +937,38 @@ function expandStandaloneTypeText(type, standaloneInterfaceTypes, resolvingInter return standaloneType; } -// JSX and Vue generators normally replace an attributed property with its attribute alias. Preserve both bindings when -// the alias has a different name and type because the attribute cannot represent the JavaScript property value. -function exposeDistinctFrameworkPropertyBindings(declaration) { +// Framework generators normally replace an attributed property with its attribute alias. Preserve differently named +// bindings independently, and prefer the property for a shared name because frameworks assign it through the element. +export function projectFrameworkPropertyBindings(declaration) { + const replacedAttributes = new Set(); + declaration.members?.forEach(member => { const attribute = declaration.attributes?.find( - item => item.name === member.attribute && item.fieldName === member.name + item => item.name === member.attribute && (item.fieldName === undefined || item.fieldName === member.name) ); - if (attribute && attribute.name !== member.name && attribute.type?.text !== member.type?.text) { - delete member.attribute; + if (!attribute) { + return; + } + + delete member.attribute; + + if (attribute.name === member.name) { + replacedAttributes.add(attribute); } }); + + declaration.attributes = declaration.attributes?.filter(attribute => !replacedAttributes.has(attribute)); +} + +// Vue normalizes Boolean attribute aliases to their linked properties. Keep the camel-cased property binding without +// duplicating it as a kebab-cased Boolean prop. Preserve attribute-only Boolean APIs. +export function omitVueBooleanAttributeAliases(declaration) { + const memberNames = new Set(declaration.members?.map(member => member.name)); + + declaration.attributes = declaration.attributes?.filter( + attribute => attribute.type?.text !== 'boolean' || !attribute.fieldName || !memberNames.has(attribute.fieldName) + ); } // Preserve the shared CEM types and select standalone types only for framework declaration generators. @@ -813,7 +980,7 @@ function createStandaloneTypesManifest(customElementsManifest) { .flatMap(module => module.declarations ?? []) .filter(declaration => declaration.tagName) .forEach(declaration => { - exposeDistinctFrameworkPropertyBindings(declaration); + projectFrameworkPropertyBindings(declaration); [...(declaration.attributes ?? []), ...(declaration.members ?? [])].forEach(item => { item.standaloneType = { text: expandStandaloneTypeText(item.type?.text, standaloneInterfaceTypes) }; @@ -833,11 +1000,28 @@ function getStandaloneTypesManifest(customElementsManifest) { return standaloneTypesManifestCache.get(customElementsManifest); } -function withStandaloneTypes(plugin) { +const vueTypesManifestCache = new WeakMap(); + +function getVueTypesManifest(customElementsManifest) { + if (!vueTypesManifestCache.has(customElementsManifest)) { + const vueTypesManifest = structuredClone(getStandaloneTypesManifest(customElementsManifest)); + + vueTypesManifest.modules + .flatMap(module => module.declarations ?? []) + .filter(declaration => declaration.tagName) + .forEach(omitVueBooleanAttributeAliases); + + vueTypesManifestCache.set(customElementsManifest, vueTypesManifest); + } + + return vueTypesManifestCache.get(customElementsManifest); +} + +function withStandaloneTypes(plugin, getManifest = getStandaloneTypesManifest) { return { ...plugin, packageLinkPhase({ customElementsManifest }) { - plugin.packageLinkPhase({ customElementsManifest: getStandaloneTypesManifest(customElementsManifest) }); + plugin.packageLinkPhase({ customElementsManifest: getManifest(customElementsManifest) }); } }; } @@ -872,7 +1056,8 @@ function vueTypesPlugin() { outdir: resolve('dist'), fileName: 'custom-elements-vue.d.ts', typesSrc: 'standaloneType' - }) + }), + getVueTypesManifest ); } @@ -978,17 +1163,19 @@ function deduplicateByName(members) { return [...seen.values()]; } -function getMemberAttributeName(manifest, member) { - if (member.attribute) { - return member.attribute; - } - +function getMemberAttribute(manifest, member) { const normalizedMemberName = member.name.toLowerCase(); - const attribute = manifest.attributes?.find( + return manifest.attributes?.find( attr => - attr.fieldName === member.name || attr.name === member.name || attr.name.toLowerCase() === normalizedMemberName + attr.name === member.attribute || + attr.fieldName === member.name || + attr.name === member.name || + attr.name.toLowerCase() === normalizedMemberName ); - return attribute?.name; +} + +function getMemberAttributeName(manifest, member) { + return member.attribute ?? getMemberAttribute(manifest, member)?.name; } function memberAttributesPlugin() { @@ -1010,10 +1197,18 @@ function memberAttributesPlugin() { }; } -function elementMetadataToMarkdown(manifest) { +function isPublicMember(member) { + return member.privacy == null || member.privacy === 'public'; +} + +function escapeMarkdownTableType(type) { + return type.replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + +export function elementMetadataToMarkdown(manifest) { if (manifest.tagName) { const slots = manifest.slots?.filter(i => !i.description?.includes('deprecated')) ?? []; - const members = deduplicateByName(manifest.members?.filter(i => !i.deprecated) ?? []); + const members = deduplicateByName(manifest.members?.filter(i => !i.deprecated && isPublicMember(i)) ?? []); return ` ## ${manifest.tagName} ${manifest.description ? `\n${manifest.description}\n` : ''}${manifest.metadata.example ? `\n### Example\n\n\`\`\`html\n${manifest.metadata.example}\n\`\`\`\n` : ''} @@ -1043,7 +1238,15 @@ ${ | -------------------- | ----- | ----------- | ${members .map(i => { - let type = i.type?.text ? `\`${i.type?.text.replace(/\|/g, '\\|')}\`` : ''; + const propertyType = i.type?.text; + const attributeType = getMemberAttribute(manifest, i)?.type?.text; + const formattedPropertyType = propertyType ? escapeMarkdownTableType(propertyType) : ''; + const formattedAttributeType = attributeType ? escapeMarkdownTableType(attributeType) : ''; + let type = formattedPropertyType ? `\`${formattedPropertyType}\`` : ''; + + if (propertyType && attributeType && propertyType !== attributeType) { + type = `property: \`${formattedPropertyType}\`; attribute: \`${formattedAttributeType}\``; + } if (manifest.tagName.startsWith('nve-icon') && i.name === 'name') { const values = i.type?.values @@ -1143,53 +1346,6 @@ function rewriteExportedStringLiteralTypeAliasesPlugin() { return node.jsDoc.map(doc => doc.comment || '').join('\n'); } - function rewriteTypesText(entry) { - const text = entry.type?.text; - if (!text) { - return; - } - - if (text.startsWith('Extract')) { - entry.type.text = text.replace('Extract<', '').replace('> | ', ' | ').split(',')[1].trim(); - } - - let types = text.split('|').map(value => value.trim()); - - const rewrittenTypes = new Set(); - let performRewrite = false; - const sourceAliases = []; - for (const type of types) { - const stringLiterals = stringLiteralsByTypeAlias.get(type); - if (stringLiterals !== undefined) { - performRewrite = true; - sourceAliases.push(type); - for (const stringLiteral of stringLiterals) { - // NOTE: This has() check is necessary to retain TypeScript's first-declaration-wins ordering. - if (!rewrittenTypes.has(stringLiteral)) { - rewrittenTypes.add(stringLiteral); - } - } - } else { - rewrittenTypes.add(type); - } - } - if (performRewrite) { - entry.type.text = Array.from(rewrittenTypes).join(' | '); - } - - entry.type._sourceAliases = sourceAliases; - - const hasArbitraryType = entry.type.text - .split(' | ') - .map(value => value.trim()) - .some(isArbitraryType); - - entry.type.text = entry.type.text - .split(' | ') - .map(value => (!hasArbitraryType && (value === 'undefined' || value === '') ? '"default"' : value)) - .join(' | '); - } - function isArbitraryType(type) { const cleanType = type.trim(); if (/^(['"]).*\1$/.test(cleanType)) { @@ -1230,16 +1386,13 @@ function rewriteExportedStringLiteralTypeAliasesPlugin() { } const rawTypes = entry.type.text.split('|').map(t => t.trim()); - const types = rawTypes.map(t => t.replace(/^['"]|['"]$/g, '')); + const types = getDocumentedTypeValues(entry.type.text); let hasAnyDescriptions = false; // Check if this is a string literal union (contains quoted strings) const isStringLiteralUnion = rawTypes.some(type => /^['"].*['"]$/.test(type)) && - rawTypes.every(type => { - const cleanType = type.replace(/^['"]|['"]$/g, ''); - return /^['"].*['"]$/.test(type) || cleanType === 'undefined' || cleanType === 'default' || cleanType === ''; - }); + rawTypes.every(type => /^['"].*['"]$/.test(type) || isOmittedTypeBranch(type)); // Initialize values array if it doesn't exist if (!entry.type.values) { @@ -1297,19 +1450,17 @@ function rewriteExportedStringLiteralTypeAliasesPlugin() { hasAnyDescriptions = true; // Add descriptions for values that match this type alias - types - .filter(type => !type.includes('undefined') && type !== 'default' && type !== '') - .forEach(type => { - const cleanType = type.replace(/^['"]|['"]$/g, ''); - - // Only add if we have a description for this value and it's not already in the array - if (valueDescriptions[cleanType] && !entry.type.values.some(v => v.value === cleanType)) { - entry.type.values.push({ - value: cleanType, - description: valueDescriptions[cleanType] - }); - } - }); + types.forEach(type => { + const cleanType = type.replace(/^['"]|['"]$/g, ''); + + // Only add if we have a description for this value and it's not already in the array + if (valueDescriptions[cleanType] && !entry.type.values.some(v => v.value === cleanType)) { + entry.type.values.push({ + value: cleanType, + description: valueDescriptions[cleanType] + }); + } + }); } } @@ -1318,42 +1469,36 @@ function rewriteExportedStringLiteralTypeAliasesPlugin() { const inlineDescriptions = parseValueDescriptions(entry.description); if (Object.keys(inlineDescriptions).length > 0) { hasAnyDescriptions = true; - types - .filter(type => !type.includes('undefined') && type !== 'default' && type !== '') - .forEach(type => { - const cleanType = type.replace(/^['"]|['"]$/g, ''); - if (inlineDescriptions[cleanType] && !entry.type.values.some(v => v.value === cleanType)) { - entry.type.values.push({ - value: cleanType, - description: inlineDescriptions[cleanType] - }); - } - }); + types.forEach(type => { + const cleanType = type.replace(/^['"]|['"]$/g, ''); + if (inlineDescriptions[cleanType] && !entry.type.values.some(v => v.value === cleanType)) { + entry.type.values.push({ + value: cleanType, + description: inlineDescriptions[cleanType] + }); + } + }); } } // Add all remaining values (with or without descriptions) - types - .filter(type => !type.includes('undefined') && type !== 'default' && type !== '') - .forEach(type => { - const cleanType = type.replace(/^['"]|['"]$/g, ''); - if (!entry.type.values.some(v => v.value === cleanType)) { - entry.type.values.push({ - value: cleanType, - description: '' - }); - } - }); - } else { - // For all other types (string, number, HTMLElement, etc.), add a single value - types - .filter(type => type !== '' && type !== 'undefined') - .forEach(type => { + types.forEach(type => { + const cleanType = type.replace(/^['"]|['"]$/g, ''); + if (!entry.type.values.some(v => v.value === cleanType)) { entry.type.values.push({ - value: type, + value: cleanType, description: '' }); + } + }); + } else { + // For all other types (string, number, HTMLElement, etc.), add a single value + types.forEach(type => { + entry.type.values.push({ + value: type, + description: '' }); + }); } // Only modify description if we found actual descriptions @@ -1515,30 +1660,38 @@ function rewriteExportedStringLiteralTypeAliasesPlugin() { switch (declaration.kind) { case 'class': for (const member of declaration.members ?? []) { - // name is excluded due to icon overloading it for svg icon name + rewriteTypesText(member, stringLiteralsByTypeAlias); + if ( member.name !== 'name' && member.name !== 'direction' && + shouldUseStandardDescription(declaration, member) && baseInterface[member.name] && - baseInterface[member.name].docs.length + baseInterface[member.name].docs.length && + typesMatch(member, baseInterface[member.name]) ) { member.description = baseInterface[member.name].docs[0]?.description; } - rewriteTypesText(member); addValueDescriptions(member); } for (const attribute of declaration.attributes ?? []) { - if (baseInterface[attribute.name] && baseInterface[attribute.name].docs.length) { + rewriteTypesText(attribute, stringLiteralsByTypeAlias); + + if ( + shouldUseStandardDescription(declaration, attribute) && + baseInterface[attribute.name] && + baseInterface[attribute.name].docs.length && + typesMatch(attribute, baseInterface[attribute.name]) + ) { attribute.description = baseInterface[attribute.name].docs[0]?.description; } - rewriteTypesText(attribute); addValueDescriptions(attribute); } break; case 'function': for (const parameter of declaration.parameters ?? []) { - rewriteTypesText(parameter); + rewriteTypesText(parameter, stringLiteralsByTypeAlias); } break; } @@ -1548,14 +1701,22 @@ function rewriteExportedStringLiteralTypeAliasesPlugin() { }; } -/** filter subset of all default members to exclude private and non documented APIs/properties */ -function publicPropertiesPlugin() { +/** Filters members by kind, privacy, name, readonly, and static status, plus attributes linked to non-public members. */ +export function publicPropertiesPlugin() { return { name: 'public-properties-plugin', packageLinkPhase({ customElementsManifest }) { for (const module of customElementsManifest.modules) { for (const declaration of module.declarations) { if (declaration.tagName) { + const nonPublicMemberNames = new Set( + declaration.members + ?.filter(member => member.privacy != null && member.privacy !== 'public') + .map(member => member.name) + ); + declaration.attributes = declaration.attributes?.filter( + attribute => !attribute.fieldName || !nonPublicMemberNames.has(attribute.fieldName) + ); declaration.members = declaration.members?.filter( m => @@ -1628,6 +1789,7 @@ export default { commandPlugin(), mixinApiProjectionPlugin(), rewriteExportedStringLiteralTypeAliasesPlugin(), + attributeTypesPlugin(), publicPropertiesPlugin(), superClassMetadataPlugin(), dynamicSlotsPlugin(), diff --git a/projects/internals/vite/src/plugins/cem.test.js b/projects/internals/vite/src/plugins/cem.test.js index 926c73d295..a9124a90f5 100644 --- a/projects/internals/vite/src/plugins/cem.test.js +++ b/projects/internals/vite/src/plugins/cem.test.js @@ -1,6 +1,25 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generateJsxTypes } from 'custom-element-jsx-integration'; +import { generateVuejsTypes } from 'custom-element-vuejs-integration'; import { getCustomDataOutputs } from './cem.js'; +import { + addUniqueMember, + attributeTypesPlugin, + elementMetadataToMarkdown, + getAttributeFacingTypeText, + getDocumentedTypeValues, + omitVueBooleanAttributeAliases, + projectFrameworkPropertyBindings, + publicPropertiesPlugin, + rewriteTypesText, + shouldProjectMixinAttribute, + shouldUseStandardDescription, + typesMatch +} from './cem.config.mjs'; const packageDirectory = '/package'; @@ -84,3 +103,524 @@ test('rejects malformed Custom Data contributions', () => { /must be a non-empty array/ ); }); + +test('matches equivalent standard property types', () => { + assert.equal( + typesMatch({ type: { text: `"horizontal" | "vertical" | undefined` } }, { type: `'vertical'\n| 'horizontal'` }), + true + ); +}); + +test('preserves default when it is a declared type value', () => { + assert.equal(typesMatch({ type: { text: `'compact'` } }, { type: `'default' | 'compact'` }), false); + assert.equal(typesMatch({ type: { text: `"default" | "compact"` } }, { type: `'default' | 'compact'` }), true); +}); + +test('preserves empty strings when they are declared type values', () => { + assert.equal(typesMatch({ type: { text: `'fixed' | 'sticky'` } }, { type: `'fixed' | 'sticky' | ''` }), false); +}); + +test('preserves optionality when rewriting type aliases', () => { + const entry = { type: { text: 'Size | undefined' } }; + + rewriteTypesText(entry, new Map([['Size', [`'sm'`, `'md'`]]])); + + assert.equal(entry.type.text, `'sm' | 'md' | undefined`); + assert.deepEqual(entry.type._sourceAliases, ['Size']); +}); + +test('rewrites aliases within Extract types while preserving union order', () => { + const entry = { type: { text: "Extract | undefined" } }; + + rewriteTypesText(entry, new Map([['Size', [`'sm'`, `'md'`]]])); + + assert.equal(entry.type.text, `'sm' | 'md' | 'full' | undefined`); + assert.deepEqual(entry.type._sourceAliases, ['Size']); +}); + +test('removes a terminal Extract delimiter without an outer union', () => { + const entry = { type: { text: "Extract" } }; + + rewriteTypesText(entry); + + assert.equal(entry.type.text, `'emphasis'`); +}); + +test('preserves declared default literals when rewriting type aliases', () => { + const entry = { type: { text: 'Scale | undefined' } }; + + rewriteTypesText(entry, new Map([['Scale', [`'default'`, `'compact'`]]])); + + assert.equal(entry.type.text, `'default' | 'compact' | undefined`); +}); + +test('documents declared default literals without documenting optionality', () => { + assert.deepEqual(getDocumentedTypeValues(`'default' | 'compact' | '' | undefined`), ['default', 'compact']); +}); + +test('adds inherited descriptions to existing projected members', () => { + const existingMember = { kind: 'field', name: 'value', type: { text: 'number' } }; + const declaration = { members: [existingMember] }; + + addUniqueMember(declaration, { + kind: 'field', + name: 'value', + type: { text: 'T' }, + description: 'The current form control value.' + }); + + assert.equal(declaration.members.length, 1); + assert.equal(existingMember.description, 'The current form control value.'); + assert.equal(existingMember.type.text, 'number'); +}); + +test('narrows inferred string members to inherited finite unions', () => { + const existingMember = { kind: 'field', name: 'type', type: { text: 'string' }, default: "'button'" }; + const declaration = { members: [existingMember] }; + + addUniqueMember(declaration, { + kind: 'field', + name: 'type', + type: { text: "'button' | 'submit' | 'reset'" }, + description: 'Defines button behavior.' + }); + + assert.equal(existingMember.type.text, "'button' | 'submit' | 'reset'"); + assert.equal(existingMember.default, "'button'"); +}); + +test('uses ID strings for element-reference attributes', () => { + assert.equal(getAttributeFacingTypeText('string | HTMLElement'), 'string'); + assert.equal(getAttributeFacingTypeText('HTMLFormElement | null | string'), 'string'); + assert.equal(getAttributeFacingTypeText('HTMLElement | null'), 'string'); + assert.equal(getAttributeFacingTypeText('File[]', 'File[]'), 'File[]'); +}); + +test('normalizes linked element-reference attribute types', () => { + const declaration = { + tagName: 'nve-example', + members: [{ kind: 'field', name: 'anchor', type: { text: 'string | HTMLElement' } }], + attributes: [{ name: 'anchor', fieldName: 'anchor', type: { text: 'string | HTMLElement' } }] + }; + + attributeTypesPlugin().packageLinkPhase({ + customElementsManifest: { modules: [{ declarations: [declaration] }] } + }); + + assert.equal(declaration.attributes[0].type.text, 'string'); +}); + +test('preserves explicit attribute types and descriptions over Lit property inference', () => { + const declaration = { + name: 'Example', + attributes: [ + { + name: 'step-sizes', + type: { text: 'number[]' }, + description: 'Page-size options available in the selector.' + } + ] + }; + const sourceFile = {}; + const node = { + kind: 1, + name: { getText: () => declaration.name }, + getSourceFile: () => sourceFile, + jsDoc: [ + { + tags: [ + { + tagName: { getText: () => 'attribute' }, + getText: () => + '@attribute {string} step-sizes - A JSON-serialized array of page-size options available in the selector.' + } + ] + } + ] + }; + + attributeTypesPlugin().analyzePhase({ + ts: { SyntaxKind: { ClassDeclaration: 1 } }, + node, + moduleDoc: { declarations: [declaration] } + }); + + assert.deepEqual(declaration.attributes[0], { + name: 'step-sizes', + type: { text: 'string' }, + description: 'A JSON-serialized array of page-size options available in the selector.' + }); +}); + +test('projects the complete property and attribute relationship matrix for frameworks', () => { + const declaration = { + members: [ + { kind: 'field', name: 'propertyOnly', type: { text: 'number' } }, + { + kind: 'field', + name: 'sameNameSameType', + attribute: 'sameNameSameType', + type: { text: 'string' }, + description: 'Property description.' + }, + { + kind: 'field', + name: 'sameNameDifferentType', + attribute: 'sameNameDifferentType', + type: { text: '{ value: string }' }, + description: 'Property description.' + }, + { + kind: 'field', + name: 'differentNameSameType', + attribute: 'different-name-same-type', + type: { text: 'boolean' } + }, + { + kind: 'field', + name: 'differentNameDifferentType', + attribute: 'different-name-different-type', + type: { text: 'string | HTMLElement' } + } + ], + attributes: [ + { name: 'attribute-only', type: { text: 'string' } }, + { + name: 'sameNameSameType', + fieldName: 'sameNameSameType', + type: { text: 'string' }, + description: 'Attribute description.' + }, + { + name: 'sameNameDifferentType', + type: { text: 'string' }, + description: 'Attribute description.' + }, + { + name: 'different-name-same-type', + fieldName: 'differentNameSameType', + type: { text: 'boolean' } + }, + { + name: 'different-name-different-type', + fieldName: 'differentNameDifferentType', + type: { text: 'string' } + } + ] + }; + + projectFrameworkPropertyBindings(declaration); + + assert.deepEqual( + declaration.members.map(({ name, attribute, description }) => ({ name, attribute, description })), + [ + { name: 'propertyOnly', attribute: undefined, description: undefined }, + { name: 'sameNameSameType', attribute: undefined, description: 'Property description.' }, + { name: 'sameNameDifferentType', attribute: undefined, description: 'Property description.' }, + { name: 'differentNameSameType', attribute: undefined, description: undefined }, + { name: 'differentNameDifferentType', attribute: undefined, description: undefined } + ] + ); + assert.deepEqual( + declaration.attributes.map(attribute => attribute.name), + ['attribute-only', 'different-name-same-type', 'different-name-different-type'] + ); +}); + +test('generates JSX and Vue bindings from property and attribute relationships', () => { + const outputDirectory = mkdtempSync(join(tmpdir(), 'cem-framework-types-')); + const manifest = { + modules: [ + { + declarations: [ + { + kind: 'class', + customElement: true, + name: 'Pagination', + tagName: 'nve-pagination', + members: [ + { + kind: 'field', + name: 'stepSizes', + attribute: 'step-sizes', + type: { text: 'number[]' }, + description: 'Page-size options available in the selector.' + } + ], + attributes: [ + { + name: 'step-sizes', + fieldName: 'stepSizes', + type: { text: 'string' }, + description: 'A JSON-serialized array of page-size options available in the selector.' + } + ] + }, + { + kind: 'class', + customElement: true, + name: 'PreferencesInput', + tagName: 'nve-preferences-input', + members: [ + { + kind: 'field', + name: 'value', + attribute: 'value', + type: { text: '{ theme?: string }' }, + description: 'Contains the current preferences.' + } + ], + attributes: [ + { + name: 'value', + type: { text: 'string' }, + description: 'A JSON-serialized preferences object.' + } + ] + }, + { + kind: 'class', + customElement: true, + name: 'Dropdown', + tagName: 'nve-dropdown', + members: [ + { + kind: 'field', + name: 'anchor', + attribute: 'anchor', + type: { text: 'string | HTMLElement' }, + description: 'Sets the positioning anchor.' + } + ], + attributes: [ + { + name: 'anchor', + fieldName: 'anchor', + type: { text: 'string' }, + description: 'Sets the positioning anchor ID.' + } + ] + }, + { + kind: 'class', + customElement: true, + name: 'Accordion', + tagName: 'nve-accordion', + members: [ + { + kind: 'field', + name: 'behaviorExpand', + attribute: 'behavior-expand', + type: { text: 'boolean' }, + description: 'Enables stateful expansion behavior.' + } + ], + attributes: [ + { + name: 'behavior-expand', + fieldName: 'behaviorExpand', + type: { text: 'boolean' }, + description: 'Enables stateful expansion behavior.' + }, + { + name: 'boolean-attribute-only', + type: { text: 'boolean' }, + description: 'An attribute-only Boolean API.' + } + ] + } + ] + } + ] + }; + + const jsxManifest = structuredClone(manifest); + const vueManifest = structuredClone(manifest); + + jsxManifest.modules[0].declarations.forEach(declaration => { + projectFrameworkPropertyBindings(declaration); + [...declaration.members, ...declaration.attributes].forEach(item => { + item.standaloneType = { text: item.type.text }; + }); + }); + vueManifest.modules[0].declarations.forEach(declaration => { + projectFrameworkPropertyBindings(declaration); + omitVueBooleanAttributeAliases(declaration); + [...declaration.members, ...declaration.attributes].forEach(item => { + item.standaloneType = { text: item.type.text }; + }); + }); + + try { + generateJsxTypes(jsxManifest, { + outdir: outputDirectory, + fileName: 'jsx.d.ts', + typesSrc: 'standaloneType', + hideLogs: true + }); + generateVuejsTypes(vueManifest, { + outdir: outputDirectory, + fileName: 'vue.d.ts', + typesSrc: 'standaloneType', + hideLogs: true + }); + + for (const fileName of ['jsx.d.ts', 'vue.d.ts']) { + const output = readFileSync(join(outputDirectory, fileName), 'utf8'); + assert.match(output, /stepSizes\?: number\[\];/); + assert.match(output, /"step-sizes"\?: string;/); + assert.match(output, /\/\*\* Contains the current preferences\. \*\/\s+value\?: \{ theme\?: string \};/); + assert.doesNotMatch(output, /A JSON-serialized preferences object/); + assert.match(output, /anchor\?: string \| HTMLElement;/); + assert.doesNotMatch(output, /anchor\?: string;/); + assert.match(output, /behaviorExpand\?: boolean;/); + assert.match(output, /"boolean-attribute-only"\?: boolean;/); + } + + assert.match(readFileSync(join(outputDirectory, 'jsx.d.ts'), 'utf8'), /"behavior-expand"\?: boolean;/); + assert.doesNotMatch(readFileSync(join(outputDirectory, 'vue.d.ts'), 'utf8'), /"behavior-expand"\?: boolean;/); + } finally { + rmSync(outputDirectory, { recursive: true, force: true }); + } +}); + +test('omits non-serializable inherited attributes', () => { + const valueAttribute = { name: 'value', fieldName: 'value' }; + + assert.equal(shouldProjectMixinAttribute({ tagName: 'nve-dropzone' }, valueAttribute), false); + assert.equal(shouldProjectMixinAttribute({ tagName: 'nve-preferences-input' }, valueAttribute), true); +}); + +test('rejects standard property types with different values', () => { + assert.equal( + typesMatch( + { type: { text: `'fixed' | 'sticky' | ''` } }, + { type: `'top-start'\n| 'top-end'\n| 'bottom-start'\n| 'bottom-end'` } + ), + false + ); +}); + +test('excludes semantically overloaded properties from standard descriptions', () => { + assert.equal(shouldUseStandardDescription({ tagName: 'nve-copy-button' }, { name: 'value' }), false); + assert.equal(shouldUseStandardDescription({ tagName: 'nve-preferences-input' }, { name: 'value' }), false); + assert.equal( + shouldUseStandardDescription( + { tagName: 'nve-button' }, + { name: 'readonly', fieldName: 'readOnly', inheritedFrom: { name: 'ButtonFormControlMixin' } } + ), + false + ); + assert.equal( + shouldUseStandardDescription( + { tagName: 'nve-input' }, + { name: 'readonly', fieldName: 'readOnly', inheritedFrom: { name: 'FormControlMixin' } } + ), + true + ); + assert.equal( + shouldUseStandardDescription( + { tagName: 'nve-button' }, + { name: 'value', inheritedFrom: { name: 'ButtonFormControlMixin' } } + ), + true + ); +}); + +test('renders distinct property and attribute types in generated Markdown', () => { + const markdown = elementMetadataToMarkdown({ + tagName: 'nve-example', + metadata: { entrypoint: '@nvidia-elements/example' }, + members: [ + { + kind: 'field', + name: 'form', + attribute: 'form', + type: { text: 'string | HTMLFormElement | null' }, + description: 'Associates the button with a form.' + } + ], + attributes: [{ name: 'form', fieldName: 'form', type: { text: 'string' } }] + }); + + assert.ok( + markdown.includes( + '| form | property: `string \\| HTMLFormElement \\| null`; attribute: `string` | Associates the button with a form. |' + ) + ); +}); + +test('escapes type backslashes before Markdown table separators', () => { + const markdown = elementMetadataToMarkdown({ + tagName: 'nve-example', + metadata: { entrypoint: '@nvidia-elements/example' }, + members: [ + { + kind: 'field', + name: 'pattern', + attribute: 'pattern', + type: { text: "'\\\\d+' | RegExp" }, + description: 'Defines a pattern.' + } + ], + attributes: [{ name: 'pattern', fieldName: 'pattern', type: { text: "'\\\\w+' | string" } }] + }); + + assert.ok( + markdown.includes( + "| pattern | property: `'\\\\\\\\d+' \\| RegExp`; attribute: `'\\\\\\\\w+' \\| string` | Defines a pattern. |" + ) + ); +}); + +test('includes only public members in generated Markdown', () => { + const markdown = elementMetadataToMarkdown({ + tagName: 'nve-example', + metadata: { entrypoint: '@nvidia-elements/example' }, + members: [ + { kind: 'field', name: 'implicitPublic', type: { text: 'string' } }, + { kind: 'field', name: 'explicitPublic', privacy: 'public', type: { text: 'string' } }, + { kind: 'field', name: 'protectedField', privacy: 'protected', type: { text: 'string' } }, + { kind: 'method', name: 'protectedMethod', privacy: 'protected', type: { text: '() => void' } }, + { kind: 'field', name: 'privateField', privacy: 'private', type: { text: 'string' } } + ] + }); + + assert.match(markdown, /implicitPublic/); + assert.match(markdown, /explicitPublic/); + assert.doesNotMatch(markdown, /protectedField/); + assert.doesNotMatch(markdown, /protectedMethod/); + assert.doesNotMatch(markdown, /privateField/); +}); + +test('removes attributes linked to non-public members', () => { + const declaration = { + tagName: 'nve-example', + members: [ + { kind: 'field', name: 'implicitPublic' }, + { kind: 'field', name: 'explicitPublic', privacy: 'public' }, + { kind: 'field', name: 'protectedField', privacy: 'protected' }, + { kind: 'field', name: 'privateField', privacy: 'private' } + ], + attributes: [ + { name: 'implicit-public', fieldName: 'implicitPublic' }, + { name: 'explicit-public', fieldName: 'explicitPublic' }, + { name: 'protected-field', fieldName: 'protectedField' }, + { name: 'private-field', fieldName: 'privateField' }, + { name: 'orphan-attribute', fieldName: 'orphanAttribute' } + ] + }; + + publicPropertiesPlugin().packageLinkPhase({ + customElementsManifest: { modules: [{ declarations: [declaration] }] } + }); + + assert.deepEqual( + declaration.members.map(member => member.name), + ['implicitPublic', 'explicitPublic', 'protectedField'] + ); + assert.deepEqual( + declaration.attributes.map(attribute => attribute.name), + ['implicit-public', 'explicit-public', 'orphan-attribute'] + ); +});