From 9f293b85b9f741ace41822be21c8f1178d7796f1 Mon Sep 17 00:00:00 2001 From: Dave Rupert Date: Tue, 26 May 2026 15:18:58 -0500 Subject: [PATCH 01/14] docs: update docs and make sure CEM and storybook options align --- .../web-components/.storybook/docs-root.css | 6 +- .../web-components/.storybook/preview.mjs | 34 ++ .../web-components/docs/web-components.api.md | 6 - packages/web-components/package.json | 1 + .../scripts/verify-storybook.js | 482 ++++++++++++++++++ .../src/accordion/accordion.stories.ts | 6 - .../src/avatar/avatar.stories.ts | 6 + packages/web-components/src/avatar/avatar.ts | 4 +- .../web-components/src/badge/badge.stories.ts | 22 + .../web-components/src/button/button.base.ts | 11 - .../src/button/button.stories.ts | 1 - .../src/checkbox/checkbox.base.ts | 11 - .../src/checkbox/checkbox.spec.ts | 8 + .../src/checkbox/checkbox.stories.ts | 11 +- .../src/combobox/combobox.stories.ts | 41 +- .../compound-button.stories.ts | 53 +- .../src/compound-button/compound-button.ts | 3 + .../counter-badge/counter-badge.stories.ts | 6 +- .../src/counter-badge/counter-badge.ts | 3 + .../src/dialog-body/dialog-body.ts | 6 + .../src/dialog/dialog.stories.ts | 21 +- .../src/drawer/drawer.stories.ts | 12 + .../src/dropdown/dropdown.stories.ts | 23 + .../web-components/src/field/field.stories.ts | 2 +- packages/web-components/src/field/field.ts | 4 + .../web-components/src/image/image.stories.ts | 4 +- .../web-components/src/link/link.stories.ts | 24 + .../src/menu-button/menu-button.stories.ts | 6 +- .../src/menu-list/menu-list.stories.ts | 2 +- .../web-components/src/menu/menu.stories.ts | 4 +- .../src/message-bar/message-bar.ts | 1 + .../src/option/option.stories.ts | 17 +- packages/web-components/src/option/option.ts | 1 + .../src/progress-bar/progress-bar.base.ts | 6 +- .../src/radio-group/radio-group.stories.ts | 6 + .../web-components/src/radio/radio.stories.ts | 26 + .../rating-display/rating-display.stories.ts | 8 + .../web-components/src/select/select.spec.md | 1 - .../src/slider/slider.stories.ts | 11 + .../src/split-button/split-button.stories.ts | 38 ++ .../src/switch/switch.stories.ts | 15 + .../web-components/src/text-input/README.md | 2 - .../src/text-input/text-input.base.ts | 11 - .../src/text-input/text-input.stories.ts | 29 +- .../src/text-input/text-input.template.ts | 1 - .../web-components/src/text/text.stories.ts | 2 +- .../src/textarea/textarea.stories.ts | 9 +- .../toggle-button/toggle-button.stories.ts | 6 +- .../src/tooltip/tooltip.stories.ts | 6 + .../src/tree-item/tree-item.stories.ts | 25 +- .../web-components/src/tree-item/tree-item.ts | 5 + 51 files changed, 948 insertions(+), 101 deletions(-) create mode 100644 packages/web-components/scripts/verify-storybook.js diff --git a/packages/web-components/.storybook/docs-root.css b/packages/web-components/.storybook/docs-root.css index 3bda6c5cae729b..6a3b8c0dd050af 100644 --- a/packages/web-components/.storybook/docs-root.css +++ b/packages/web-components/.storybook/docs-root.css @@ -371,12 +371,8 @@ letter-spacing: -0.01em; } -#storybook-docs .docblock-argstable tr > :nth-child(1) { - width: 4%; -} - #storybook-docs .docblock-argstable tr > :nth-child(2) { - width: 100%; + width: auto; } #storybook-docs .os-padding { diff --git a/packages/web-components/.storybook/preview.mjs b/packages/web-components/.storybook/preview.mjs index 2913b43e5b12c6..30760cf78a861c 100644 --- a/packages/web-components/.storybook/preview.mjs +++ b/packages/web-components/.storybook/preview.mjs @@ -7,6 +7,8 @@ import '../src/index-rollup.js'; import './docs-root.css'; const FAST_EXPRESSION_COMMENTS = //g; // Matches comments that contain FAST expressions +const ARIA_ATTRIBUTE_PATTERN = /^aria(?:$|[-A-Z])/; +const ARIA_ATTRIBUTES_CATEGORY = 'aria-attributes'; const themes = { 'web-light': webLightTheme, @@ -75,6 +77,38 @@ export const decorators = [ }, ]; +function withAriaAttributesCategory(context) { + if (!context.argTypes) { + return context.argTypes; + } + + return Object.fromEntries( + Object.entries(context.argTypes).map(([key, argType]) => { + const argName = argType?.name; + const isAriaAttribute = + (typeof key === 'string' && ARIA_ATTRIBUTE_PATTERN.test(key)) || + (typeof argName === 'string' && ARIA_ATTRIBUTE_PATTERN.test(argName)); + + if (!isAriaAttribute) { + return [key, argType]; + } + + return [ + key, + { + ...argType, + table: { + ...argType?.table, + category: ARIA_ATTRIBUTES_CATEGORY, + }, + }, + ]; + }), + ); +} + +export const argTypesEnhancers = [withAriaAttributesCategory]; + export const parameters = { layout: 'fullscreen', controls: { expanded: true }, diff --git a/packages/web-components/docs/web-components.api.md b/packages/web-components/docs/web-components.api.md index b76ecb4bfd2461..e93ee362c25b7f 100644 --- a/packages/web-components/docs/web-components.api.md +++ b/packages/web-components/docs/web-components.api.md @@ -493,7 +493,6 @@ export class BaseAvatar extends FASTElement { // @public export class BaseButton extends FASTElement { constructor(); - autofocus: boolean; // @internal clickHandler(e: Event): boolean | void; // (undocumented) @@ -532,7 +531,6 @@ export class BaseButton extends FASTElement { // @public export class BaseCheckbox extends FASTElement { - autofocus: boolean; get checked(): boolean; set checked(next: boolean); checkValidity(): boolean; @@ -772,18 +770,15 @@ export class BaseProgressBar extends FASTElement { indicator?: HTMLElement; // @internal protected indicatorChanged(): void; - // @internal max?: number; // @internal protected maxChanged(prev: number | undefined, next: number | undefined): void; - // @internal min?: number; protected minChanged(prev: number | undefined, next: number | undefined): void; // @internal protected setIndicatorWidth(): void; validationState: ProgressBarValidationState | null; validationStateChanged(prev: ProgressBarValidationState | undefined, next: ProgressBarValidationState | undefined): void; - // @internal value?: number; // @internal protected valueChanged(prev: number | undefined, next: number | undefined): void; @@ -1010,7 +1005,6 @@ export class BaseTextArea extends FASTElement { // @public export class BaseTextInput extends FASTElement { autocomplete?: string; - autofocus: boolean; // @internal changeHandler(e: InputEvent): boolean | void; checkValidity(): boolean; diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 654315cfcb7f53..f26acbe7edb1fc 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -72,6 +72,7 @@ ], "scripts": { "analyze": "cem analyze", + "verify-storybook": "node ./scripts/verify-storybook.js --no-fail", "verify-packaging": "node ./scripts/verify-packaging", "type-check": "node ./scripts/type-check", "benchmark": "yarn clean && yarn compile:benchmark && yarn compile && node ./scripts/run-benchmarks", diff --git a/packages/web-components/scripts/verify-storybook.js b/packages/web-components/scripts/verify-storybook.js new file mode 100644 index 00000000000000..4e9ca388dda15c --- /dev/null +++ b/packages/web-components/scripts/verify-storybook.js @@ -0,0 +1,482 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const packageRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const srcDir = path.join(packageRoot, 'src'); +const customElementsPath = path.join(packageRoot, 'custom-elements.json'); + +const STORY_TAG_OVERRIDES = new Map([ + // Combobox is a dropdown variant story. + ['src/combobox/combobox.stories.ts', 'fluent-dropdown'], + // Split-button is a menu composition story. + ['src/split-button/split-button.stories.ts', 'fluent-menu'], +]); + +const SKIPPED_STORIES = new Map([ + // Utility/demo stories that are not single-component API docs. + ['src/theme/set-theme.stories.ts', 'theme utility story with many components'], + ['src/theme/theme.stories.ts', 'token demo story without a component contract'], + // Tags currently not present in custom-elements.json. + ['src/accordion-item/accordion-item.stories.ts', 'no fluent-accordion-item tagName in CEM'], + ['src/anchor-button/anchor-button.stories.ts', 'no fluent-anchor-button tagName in CEM'], +]); + +const STORY_CATEGORY_TO_CEM = new Map([ + ['attributes', 'attributes'], + ['attribute', 'attributes'], + ['slots', 'slots'], + ['slot', 'slots'], + ['csscustomproperties', 'cssProperties'], + ['cssproperties', 'cssProperties'], + ['cssvariables', 'cssProperties'], + ['cssvars', 'cssProperties'], + ['cssparts', 'cssParts'], + ['parts', 'cssParts'], + ['events', 'events'], + ['event', 'events'], + ['fires', 'events'], + ['cssstates', 'cssStates'], + ['states', 'cssStates'], + ['methods', 'methods'], + ['method', 'methods'], + ['properties', 'properties'], + ['property', 'properties'], +]); + +const CATEGORY_LABELS = { + attributes: 'attributes', + slots: 'slots', + cssProperties: 'css custom properties', + cssParts: 'css parts', + events: 'events', + cssStates: 'css states', + methods: 'methods', + properties: 'properties', +}; + +const CORE_ALWAYS_VALIDATED_CATEGORIES = new Set(['attributes', 'slots']); + +const readFile = filePath => fs.readFileSync(filePath, 'utf8'); + +function walkStories(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...walkStories(fullPath)); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.stories.ts') && entry.name !== 'helpers.stories.ts') { + files.push(fullPath); + } + } + + return files; +} + +function getPropertyName(node) { + if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + return node.text; + } + + return undefined; +} + +function getStringInitializer(property) { + if (!property || !property.initializer) { + return undefined; + } + + if (ts.isStringLiteralLike(property.initializer)) { + return property.initializer.text; + } + + return undefined; +} + +function getObjectProperty(objectLiteral, propertyName) { + for (const property of objectLiteral.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + + const key = getPropertyName(property.name); + if (key === propertyName) { + return property; + } + } + + return undefined; +} + +function unwrapObjectLiteral(node) { + if (!node) { + return undefined; + } + + if (ts.isObjectLiteralExpression(node)) { + return node; + } + + if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) { + return unwrapObjectLiteral(node.expression); + } + + return undefined; +} + +function parseStoryArgTypes(storyPath) { + const sourceText = readFile(storyPath); + const sourceFile = ts.createSourceFile(storyPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + let exportedObject = undefined; + + for (const statement of sourceFile.statements) { + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + exportedObject = unwrapObjectLiteral(statement.expression); + break; + } + } + + if (!exportedObject) { + return { + documented: { + attributes: new Set(), + slots: new Set(), + cssProperties: new Set(), + cssParts: new Set(), + events: new Set(), + cssStates: new Set(), + methods: new Set(), + properties: new Set(), + }, + tagName: undefined, + sourceText, + }; + } + + const argTypesProperty = getObjectProperty(exportedObject, 'argTypes'); + const argTypesObject = unwrapObjectLiteral(argTypesProperty?.initializer); + + const documented = { + attributes: new Set(), + slots: new Set(), + cssProperties: new Set(), + cssParts: new Set(), + events: new Set(), + cssStates: new Set(), + methods: new Set(), + properties: new Set(), + }; + + if (argTypesObject) { + for (const prop of argTypesObject.properties) { + if (!ts.isPropertyAssignment(prop)) { + continue; + } + + const argTypeKey = getPropertyName(prop.name); + if (!argTypeKey) { + continue; + } + + const argTypeConfig = unwrapObjectLiteral(prop.initializer); + if (!argTypeConfig) { + continue; + } + + const topLevelCategory = getStringInitializer(getObjectProperty(argTypeConfig, 'category')); + + let tableCategory; + const tableProp = getObjectProperty(argTypeConfig, 'table'); + const tableObject = unwrapObjectLiteral(tableProp?.initializer); + if (tableObject) { + tableCategory = getStringInitializer(getObjectProperty(tableObject, 'category')); + } + + const category = tableCategory ?? topLevelCategory; + const categoryKey = String(category ?? '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + const mappedCategory = STORY_CATEGORY_TO_CEM.get(categoryKey); + + if (!mappedCategory) { + continue; + } + + const alias = getStringInitializer(getObjectProperty(argTypeConfig, 'name')); + const documentedName = (alias ?? argTypeKey).trim(); + + if (mappedCategory === 'slots') { + documented.slots.add(documentedName); + } else if (documentedName) { + documented[mappedCategory].add(documentedName); + } + } + } + + const fluentTagMatches = [...sourceText.matchAll(/<\s*(fluent-[a-z0-9-]+)/g)].map(match => match[1]); + const componentName = path.basename(storyPath).replace(/\.stories\.ts$/, ''); + + return { + documented, + componentName, + tagCandidates: [...new Set(fluentTagMatches)], + sourceText, + }; +} + +function loadCustomElementsMap() { + const cem = JSON.parse(readFile(customElementsPath)); + const byTag = new Map(); + + for (const moduleDef of cem.modules ?? []) { + for (const declaration of moduleDef.declarations ?? []) { + if (!declaration || declaration.customElement !== true || typeof declaration.tagName !== 'string') { + continue; + } + + const attributes = new Set((declaration.attributes ?? []).map(attribute => attribute?.name).filter(Boolean)); + const slots = new Set((declaration.slots ?? []).map(slot => slot?.name ?? '')); + const cssProperties = new Set((declaration.cssProperties ?? []).map(property => property?.name).filter(Boolean)); + const cssParts = new Set((declaration.cssParts ?? []).map(part => part?.name).filter(Boolean)); + const events = new Set((declaration.events ?? []).map(event => event?.name).filter(Boolean)); + const cssStates = new Set((declaration.cssStates ?? []).map(state => state?.name).filter(Boolean)); + + const methods = new Set( + (declaration.members ?? []) + .filter(member => member?.kind === 'method' && member?.privacy !== 'private') + .map(member => member?.name) + .filter(Boolean), + ); + + const properties = new Set( + (declaration.members ?? []) + .filter(member => member?.kind === 'field' && member?.privacy !== 'private') + .map(member => member?.name) + .filter(Boolean), + ); + + byTag.set(declaration.tagName, { + attributes, + slots, + cssProperties, + cssParts, + events, + cssStates, + methods, + properties, + }); + } + } + + return byTag; +} + +function toCanonicalName(name) { + return String(name) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +} + +function toCanonicalMap(values) { + const map = new Map(); + + for (const value of values) { + const key = toCanonicalName(value); + if (!key) { + continue; + } + + if (!map.has(key)) { + map.set(key, new Set()); + } + + map.get(key).add(value); + } + + return map; +} + +function canonicalDifference(aValues, bValues) { + const a = toCanonicalMap(aValues); + const b = toCanonicalMap(bValues); + + const canonicalOnlyInA = [...a.keys()].filter(key => !b.has(key)); + const valuesOnlyInA = canonicalOnlyInA.flatMap(key => [...a.get(key)]).sort(); + + return valuesOnlyInA; +} + +function pickTagName({ tagCandidates, componentName, cemByTag }) { + const componentKey = toCanonicalName(componentName); + const knownCandidates = tagCandidates.filter(tag => cemByTag.has(tag)); + + const exactMatch = knownCandidates.find(tag => toCanonicalName(tag.replace(/^fluent-/, '')) === componentKey); + if (exactMatch) { + return exactMatch; + } + + const looseMatches = knownCandidates.filter(tag => { + const tagKey = toCanonicalName(tag.replace(/^fluent-/, '')); + return tagKey.includes(componentKey); + }); + + if (looseMatches.length === 1) { + return looseMatches[0]; + } + + const allMatches = [...cemByTag.keys()].filter(tag => { + const tagKey = toCanonicalName(tag.replace(/^fluent-/, '')); + return tagKey.includes(componentKey); + }); + + if (allMatches.length === 1) { + return allMatches[0]; + } + + return undefined; +} + +function relativeFromPackage(filePath) { + return path.relative(packageRoot, filePath).replaceAll(path.sep, '/'); +} + +function validate({ failOnMismatch = true } = {}) { + const cemByTag = loadCustomElementsMap(); + const storyFiles = walkStories(srcDir); + + const errors = []; + const warnings = []; + const skipped = []; + + for (const storyFile of storyFiles) { + const storyRelativePath = relativeFromPackage(storyFile); + + if (SKIPPED_STORIES.has(storyRelativePath)) { + skipped.push(`${storyRelativePath}: ${SKIPPED_STORIES.get(storyRelativePath)}`); + continue; + } + + const { documented: storyDocumented, componentName, tagCandidates } = parseStoryArgTypes(storyFile); + + storyDocumented.slots = new Set([...storyDocumented.slots].filter(slot => slot !== '')); + const overriddenTag = STORY_TAG_OVERRIDES.get(storyRelativePath); + const tagName = overriddenTag ?? pickTagName({ tagCandidates, componentName, cemByTag }); + + if (overriddenTag && !cemByTag.has(overriddenTag)) { + warnings.push(`${storyRelativePath}: override tag ${overriddenTag} is not present in CEM; skipping.`); + continue; + } + + if (!tagName) { + warnings.push( + `${storyRelativePath}: unable to resolve primary tag from candidates [${tagCandidates.join(', ')}]; skipping.`, + ); + continue; + } + + const cemEntry = cemByTag.get(tagName); + if (!cemEntry) { + warnings.push(`${storyRelativePath}: tag ${tagName} not found in CEM; skipping.`); + continue; + } + + const normalizedCem = { + ...cemEntry, + slots: new Set([...cemEntry.slots].filter(slot => slot !== '')), + }; + + const categoriesToValidate = new Set(CORE_ALWAYS_VALIDATED_CATEGORIES); + + for (const category of Object.keys(storyDocumented)) { + if (storyDocumented[category].size > 0) { + categoriesToValidate.add(category); + } + } + + const categoryMismatches = []; + + for (const category of categoriesToValidate) { + const storySet = storyDocumented[category] ?? new Set(); + const cemSet = normalizedCem[category] ?? new Set(); + const storyOnly = canonicalDifference(storySet, cemSet); + const missingInStory = canonicalDifference(cemSet, storySet); + + if (storyOnly.length > 0 || missingInStory.length > 0) { + categoryMismatches.push({ + category, + storyOnly, + missingInStory, + }); + } + } + + if (categoryMismatches.length > 0) { + errors.push({ + file: storyRelativePath, + tagName, + categoryMismatches, + }); + } + } + + if (skipped.length > 0) { + console.log('Skipped:'); + for (const entry of skipped) { + console.log(` - ${entry}`); + } + console.log(''); + } + + if (warnings.length > 0) { + console.log('Warnings:'); + for (const warning of warnings) { + console.log(` - ${warning}`); + } + console.log(''); + } + + console.log( + `Checked ${storyFiles.length} story files. Skipped: ${skipped.length}. Warnings: ${warnings.length}. Mismatches: ${errors.length}.`, + ); + + if (errors.length === 0) { + console.log(`Validated ${storyFiles.length} stories. No Storybook/custom-elements API mismatches found.`); + return 0; + } + + console.error(`Found ${errors.length} Storybook/custom-elements API mismatches:`); + for (const error of errors) { + console.error(`\n- ${error.file} (${error.tagName})`); + + for (const mismatch of error.categoryMismatches) { + const categoryLabel = CATEGORY_LABELS[mismatch.category] ?? mismatch.category; + + if (mismatch.storyOnly.length > 0) { + console.error(` Storybook ${categoryLabel} not in CEM: ${mismatch.storyOnly.join(', ')}`); + } + + if (mismatch.missingInStory.length > 0) { + console.error(` CEM ${categoryLabel} not documented in Storybook: ${mismatch.missingInStory.join(', ')}`); + } + } + } + + if (!failOnMismatch) { + console.warn('\nMismatches found, but continuing with exit code 0 because --no-fail was provided.'); + return 0; + } + + return 1; +} + +const args = new Set(process.argv.slice(2)); +const failOnMismatch = !args.has('--no-fail'); + +process.exitCode = validate({ failOnMismatch }); diff --git a/packages/web-components/src/accordion/accordion.stories.ts b/packages/web-components/src/accordion/accordion.stories.ts index 5534dec3817e6d..676ba2b95d992f 100644 --- a/packages/web-components/src/accordion/accordion.stories.ts +++ b/packages/web-components/src/accordion/accordion.stories.ts @@ -55,12 +55,6 @@ export default { name: '', table: { category: 'slots', type: {} }, }, - headingSlottedContent: { - control: false, - description: 'The slot for the heading content', - name: 'heading', - table: { category: 'slots', type: {} }, - }, }, } as Meta; diff --git a/packages/web-components/src/avatar/avatar.stories.ts b/packages/web-components/src/avatar/avatar.stories.ts index 20f842e677c4b8..a6c0b6c0e540c5 100644 --- a/packages/web-components/src/avatar/avatar.stories.ts +++ b/packages/web-components/src/avatar/avatar.stories.ts @@ -53,6 +53,12 @@ export default { type: { summary: Object.values(AvatarColor).join('|') }, }, }, + colorId: { + control: 'text', + description: 'Provides a deterministic colorful avatar color id.', + name: 'color-id', + table: { category: 'attributes', type: { summary: 'string' } }, + }, initials: { control: 'text', description: 'Provide custom initials rather than one generated via the name', diff --git a/packages/web-components/src/avatar/avatar.ts b/packages/web-components/src/avatar/avatar.ts index d9f7334295ce7a..f765133fc278d3 100644 --- a/packages/web-components/src/avatar/avatar.ts +++ b/packages/web-components/src/avatar/avatar.ts @@ -16,6 +16,8 @@ import { * * @tag fluent-avatar * + * @slot badge - Optional badge content displayed with the avatar. + * * @public */ export class Avatar extends BaseAvatar { @@ -176,7 +178,7 @@ const getHashCode = (str: string): number => { for (let len: number = str.length - 1; len >= 0; len--) { const ch = str.charCodeAt(len); const shift = len % 8; - hashCode ^= (ch << shift) + (ch >> (8 - shift)); // eslint-disable-line no-bitwise + hashCode ^= (ch << shift) + (ch >> (8 - shift)); } return hashCode; diff --git a/packages/web-components/src/badge/badge.stories.ts b/packages/web-components/src/badge/badge.stories.ts index f9a1a27b7c4ba0..7d738404c8b8f7 100644 --- a/packages/web-components/src/badge/badge.stories.ts +++ b/packages/web-components/src/badge/badge.stories.ts @@ -27,6 +27,28 @@ export default { size: BadgeSize.medium, }, argTypes: { + count: { + control: 'number', + description: 'Sets the count shown by a counter badge variant.', + table: { category: 'attributes', type: { summary: 'number' } }, + }, + dot: { + control: 'boolean', + description: 'Shows a dot badge for a counter badge variant.', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + overflowCount: { + control: 'number', + description: 'Sets the overflow threshold shown by a counter badge variant.', + name: 'overflow-count', + table: { category: 'attributes', type: { summary: 'number' } }, + }, + showZero: { + control: 'boolean', + description: 'Shows zero when count is 0 for a counter badge variant.', + name: 'show-zero', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, appearance: { description: 'Sets the appearance of the badge to one of the predefined styles', options: Object.values(BadgeAppearance), diff --git a/packages/web-components/src/button/button.base.ts b/packages/web-components/src/button/button.base.ts index 510c6ba31ec3fb..e42c2a858fedc2 100644 --- a/packages/web-components/src/button/button.base.ts +++ b/packages/web-components/src/button/button.base.ts @@ -13,17 +13,6 @@ import { type ButtonFormTarget, ButtonType } from './button.options.js'; * @public */ export class BaseButton extends FASTElement { - /** - * Indicates the button should be focused when the page is loaded. - * @see The {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#autofocus | `autofocus`} attribute - * - * @public - * @remarks - * HTML Attribute: `autofocus` - */ - @attr({ mode: 'boolean' }) - public autofocus!: boolean; - /** * Default slotted content. * diff --git a/packages/web-components/src/button/button.stories.ts b/packages/web-components/src/button/button.stories.ts index 9b18ae1a84852a..08de9ab37de344 100644 --- a/packages/web-components/src/button/button.stories.ts +++ b/packages/web-components/src/button/button.stories.ts @@ -7,7 +7,6 @@ type Story = StoryObj; const storyTemplate = html>` { await expect(element).toHaveJSProperty('checked', false); }); + + test('should focus the element when the `autofocus` attribute is present', async ({ fastPage }) => { + const { element } = fastPage; + + await fastPage.setTemplate({ attributes: { autofocus: true } }); + + await expect(element).toBeFocused(); + }); }); diff --git a/packages/web-components/src/checkbox/checkbox.stories.ts b/packages/web-components/src/checkbox/checkbox.stories.ts index bb6e088483c77d..d90b2f123006df 100644 --- a/packages/web-components/src/checkbox/checkbox.stories.ts +++ b/packages/web-components/src/checkbox/checkbox.stories.ts @@ -38,11 +38,6 @@ export default { disabled: false, }, argTypes: { - autofocus: { - control: 'boolean', - description: 'Sets the checkbox to autofocus', - table: { category: 'attributes', type: { summary: 'boolean' } }, - }, checked: { control: 'boolean', description: 'Sets the checked state of the checkbox', @@ -61,7 +56,7 @@ export default { indeterminate: { control: 'boolean', description: 'Sets the indeterminate state of the checkbox', - table: { category: 'attributes', type: { summary: 'boolean' } }, + table: { category: 'properties', type: { summary: 'boolean' } }, }, name: { control: 'text', @@ -101,13 +96,13 @@ export default { checkedIndicatorContent: { control: false, description: 'Slot for checked indicator', - name: 'start', + name: 'checked-indicator', table: { category: 'slots', type: {} }, }, indeterminateIndicatorContent: { control: false, description: 'Slot for indeterminate indicator', - name: 'end', + name: 'indeterminate-indicator', table: { category: 'slots', type: {} }, }, label: { table: { disable: true } }, diff --git a/packages/web-components/src/combobox/combobox.stories.ts b/packages/web-components/src/combobox/combobox.stories.ts index a079f4c1e158f4..fdd9e6acef26c0 100644 --- a/packages/web-components/src/combobox/combobox.stories.ts +++ b/packages/web-components/src/combobox/combobox.stories.ts @@ -43,8 +43,10 @@ export default { parameters: { docs: { description: { - component: `The Combobox component is a variant of the Dropdown component. - To use a combobox, use <fluent-dropdown type="combobox">.`, + component: ` +The Combobox component is a variant of the Dropdown component.
+To use a combobox, use \`\` +`, }, }, }, @@ -53,11 +55,28 @@ export default { type: DropdownType.combobox, }, argTypes: { + ariaLabelledby: { + control: 'text', + name: 'aria-labelledby', + table: { category: 'attributes' }, + }, appearance: { control: 'select', options: ['', ...Object.values(DropdownAppearance)], table: { category: 'attributes' }, }, + disabled: { + control: 'boolean', + table: { category: 'attributes' }, + }, + id: { + control: 'text', + table: { category: 'attributes' }, + }, + name: { + control: 'text', + table: { category: 'attributes' }, + }, type: { control: 'radio', options: Object.values(DropdownType), @@ -71,11 +90,29 @@ export default { control: 'boolean', table: { category: 'attributes' }, }, + required: { + control: 'boolean', + table: { category: 'attributes' }, + }, size: { control: 'select', options: ['', ...Object.values(DropdownSize)], table: { category: 'attributes' }, }, + value: { + control: 'text', + table: { category: 'attributes' }, + }, + controlSlottedContent: { + control: false, + name: 'control', + table: { category: 'slots', type: {} }, + }, + indicatorSlottedContent: { + control: false, + name: 'indicator', + table: { category: 'slots', type: {} }, + }, slottedContent: { table: { disable: true } }, slot: { table: { disable: true } }, }, diff --git a/packages/web-components/src/compound-button/compound-button.stories.ts b/packages/web-components/src/compound-button/compound-button.stories.ts index c45f87c77a8191..fc022d715ef385 100644 --- a/packages/web-components/src/compound-button/compound-button.stories.ts +++ b/packages/web-components/src/compound-button/compound-button.stories.ts @@ -70,6 +70,57 @@ export default { name: 'disabled-focusable', table: { category: 'attributes', type: { summary: 'boolean' } }, }, + form: { + control: 'text', + description: 'The id of a form to associate the element to.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + formaction: { + control: 'text', + description: 'The URL that processes the form submission.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + formenctype: { + control: 'text', + description: 'The encoding type for the form submission.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + formmethod: { + control: 'text', + description: 'The HTTP method used to submit the form.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + formnovalidate: { + control: 'boolean', + description: 'Disables form validation on submit.', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + formtarget: { + control: 'text', + description: 'Target frame or window for form submission.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + iconOnly: { + control: 'boolean', + description: 'Indicates the button should only display as an icon.', + name: 'icon-only', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + name: { + control: 'text', + description: 'The name used during form submission.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + type: { + control: 'text', + description: 'The button type.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + value: { + control: 'text', + description: 'The initial value of the button.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, slottedContent: { control: false, description: 'The default slot', @@ -79,7 +130,7 @@ export default { descriptionSlottedContent: { control: false, description: 'The description slot', - name: '', + name: 'description', table: { category: 'slots', type: {} }, }, startSlottedContent: { diff --git a/packages/web-components/src/compound-button/compound-button.ts b/packages/web-components/src/compound-button/compound-button.ts index b6936196922126..5a0b843463bfe0 100644 --- a/packages/web-components/src/compound-button/compound-button.ts +++ b/packages/web-components/src/compound-button/compound-button.ts @@ -5,6 +5,9 @@ import { Button } from '../button/button.js'; * * @tag fluent-compound-button * + * @slot - The default slot for the main content of the compound button + * @slot description - The description of the compound button, shown below the main content + * * @public */ export class CompoundButton extends Button {} diff --git a/packages/web-components/src/counter-badge/counter-badge.stories.ts b/packages/web-components/src/counter-badge/counter-badge.stories.ts index 136ce5892d3165..65c8095ee69912 100644 --- a/packages/web-components/src/counter-badge/counter-badge.stories.ts +++ b/packages/web-components/src/counter-badge/counter-badge.stories.ts @@ -82,13 +82,13 @@ export default { count: { control: 'number', description: "Sets the badge's count attribute", - name: 'formmethod', + name: 'count', table: { category: 'attributes', type: { summary: 'number' } }, }, overflowCount: { - control: 'text', + control: 'number', description: "Sets the badge's overflow count attribute", - name: 'formmethod', + name: 'overflow-count', table: { category: 'attributes', type: { summary: 'number' } }, }, startSlottedContent: { diff --git a/packages/web-components/src/counter-badge/counter-badge.ts b/packages/web-components/src/counter-badge/counter-badge.ts index dca9f526a8d629..ee29ce64946da7 100644 --- a/packages/web-components/src/counter-badge/counter-badge.ts +++ b/packages/web-components/src/counter-badge/counter-badge.ts @@ -15,6 +15,9 @@ import { * * @tag fluent-counter-badge * + * @slot start - Content which can be provided before the badge content. + * @slot end - Content which can be provided after the badge content. + * * @public */ export class CounterBadge extends BaseCounterBadge { diff --git a/packages/web-components/src/dialog-body/dialog-body.ts b/packages/web-components/src/dialog-body/dialog-body.ts index 6e81ff041ac6b6..91bbf03ff87158 100644 --- a/packages/web-components/src/dialog-body/dialog-body.ts +++ b/packages/web-components/src/dialog-body/dialog-body.ts @@ -5,6 +5,12 @@ import { isDialog } from '../dialog/dialog.options.js'; * * @tag fluent-dialog-body * + * @slot title - Content for the dialog title. + * @slot title-action - Content for actions shown near the title. + * @slot close - Content for the close action. + * @slot action - Content for footer actions. + * @slot - Default dialog body content. + * * @public * @extends FASTElement */ diff --git a/packages/web-components/src/dialog/dialog.stories.ts b/packages/web-components/src/dialog/dialog.stories.ts index e9df63d8ca5b07..33f08b82b19ead 100644 --- a/packages/web-components/src/dialog/dialog.stories.ts +++ b/packages/web-components/src/dialog/dialog.stories.ts @@ -79,6 +79,24 @@ export default { closeSlottedContent: () => closeTemplate, }, argTypes: { + ariaDescribedby: { + control: 'text', + description: 'Sets aria-describedby on the dialog.', + name: 'aria-describedby', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + ariaLabel: { + control: 'text', + description: 'Sets aria-label on the dialog.', + name: 'aria-label', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + ariaLabelledby: { + control: 'text', + description: 'Sets aria-labelledby on the dialog.', + name: 'aria-labelledby', + table: { category: 'attributes', type: { summary: 'string' } }, + }, type: { control: 'select', description: @@ -94,9 +112,10 @@ export default { slottedContent: { control: false, name: '', - description: 'Default slot for the dialog content.', + description: 'Default slot for the dialog content, e.g. ``.', table: { category: 'slots', type: {} }, }, + closeSlottedContent: { table: { disable: true } }, actionSlottedContent: { table: { disable: true } }, titleSlottedContent: { table: { disable: true } }, titleActionSlottedContent: { table: { disable: true } }, diff --git a/packages/web-components/src/drawer/drawer.stories.ts b/packages/web-components/src/drawer/drawer.stories.ts index 5714a9acb2ddc5..03f0ebfbeef396 100644 --- a/packages/web-components/src/drawer/drawer.stories.ts +++ b/packages/web-components/src/drawer/drawer.stories.ts @@ -97,6 +97,18 @@ export default { '--dialog-backdrop': 'var(--colorBackgroundOverlay)', }, argTypes: { + ariaDescribedby: { + control: 'text', + name: 'aria-describedby', + description: 'Sets aria-describedby on the drawer.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + ariaLabelledby: { + control: 'text', + name: 'aria-labelledby', + description: 'Sets aria-labelledby on the drawer.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, position: { control: 'select', description: 'Sets the position of drawer', diff --git a/packages/web-components/src/dropdown/dropdown.stories.ts b/packages/web-components/src/dropdown/dropdown.stories.ts index 3bb215087a2b11..813744492c8565 100644 --- a/packages/web-components/src/dropdown/dropdown.stories.ts +++ b/packages/web-components/src/dropdown/dropdown.stories.ts @@ -58,6 +58,11 @@ export default { title: 'Components/Dropdown', render: renderComponent(storyTemplate), argTypes: { + ariaLabelledby: { + control: 'text', + name: 'aria-labelledby', + table: { category: 'attributes' }, + }, appearance: { control: 'select', options: ['', ...Object.values(DropdownAppearance)], @@ -93,6 +98,24 @@ export default { options: ['', ...Object.values(DropdownSize)], table: { category: 'attributes' }, }, + id: { + control: 'text', + table: { category: 'attributes' }, + }, + value: { + control: 'text', + table: { category: 'attributes' }, + }, + controlSlottedContent: { + control: false, + name: 'control', + table: { category: 'slots', type: {} }, + }, + indicatorSlottedContent: { + control: false, + name: 'indicator', + table: { category: 'slots', type: {} }, + }, slottedContent: { table: { disable: true } }, slot: { table: { disable: true } }, }, diff --git a/packages/web-components/src/field/field.stories.ts b/packages/web-components/src/field/field.stories.ts index dc68b3313a2a53..a31cb43bae5f17 100644 --- a/packages/web-components/src/field/field.stories.ts +++ b/packages/web-components/src/field/field.stories.ts @@ -66,7 +66,7 @@ export default { labelPosition: { control: 'select', description: 'Sets the position of the label relative to the input', - name: 'size', + name: 'label-position', mapping: { '': null, ...LabelPosition }, options: ['', ...Object.values(LabelPosition)], table: { diff --git a/packages/web-components/src/field/field.ts b/packages/web-components/src/field/field.ts index 79de69684aae77..03c857036c6305 100644 --- a/packages/web-components/src/field/field.ts +++ b/packages/web-components/src/field/field.ts @@ -8,6 +8,10 @@ import { LabelPosition } from './field.options.js'; * * @tag fluent-field * + * @slot label - Label content associated with the control. + * @slot input - Input control content. + * @slot message - Validation and helper message content. + * * @public */ export class Field extends BaseField { diff --git a/packages/web-components/src/image/image.stories.ts b/packages/web-components/src/image/image.stories.ts index b6e1586bedf1dd..8b2c9f3e61669b 100644 --- a/packages/web-components/src/image/image.stories.ts +++ b/packages/web-components/src/image/image.stories.ts @@ -46,7 +46,7 @@ export default { fit: { control: 'select', description: 'Determines how the image will be scaled and positioned within its parent container.', - name: 'size', + name: 'fit', mapping: { '': null, ...ImageFit }, options: ['', ...Object.values(ImageFit)], table: { @@ -66,7 +66,7 @@ export default { shape: { control: 'select', description: 'Image Shape', - name: 'size', + name: 'shape', mapping: { '': null, ...ImageShape }, options: ['', ...Object.values(ImageShape)], table: { diff --git a/packages/web-components/src/link/link.stories.ts b/packages/web-components/src/link/link.stories.ts index e1a6902bb50e0a..357bbf83fa841c 100644 --- a/packages/web-components/src/link/link.stories.ts +++ b/packages/web-components/src/link/link.stories.ts @@ -53,6 +53,18 @@ export default { name: 'rel', table: { category: 'attributes', type: { summary: 'string' } }, }, + download: { + control: 'text', + description: 'The download attribute.', + name: 'download', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + ping: { + control: 'text', + description: 'The ping attribute.', + name: 'ping', + table: { category: 'attributes', type: { summary: 'string' } }, + }, type: { control: 'text', description: 'The type attribute.', @@ -91,6 +103,18 @@ export default { name: '', table: { category: 'slots', type: {} }, }, + startSlottedContent: { + control: false, + description: 'Content which can be provided before the link content.', + name: 'start', + table: { category: 'slots', type: {} }, + }, + endSlottedContent: { + control: false, + description: 'Content which can be provided after the link content.', + name: 'end', + table: { category: 'slots', type: {} }, + }, }, } as Meta; diff --git a/packages/web-components/src/menu-button/menu-button.stories.ts b/packages/web-components/src/menu-button/menu-button.stories.ts index f15c3cf90721f1..6a93b99c62c906 100644 --- a/packages/web-components/src/menu-button/menu-button.stories.ts +++ b/packages/web-components/src/menu-button/menu-button.stories.ts @@ -7,7 +7,6 @@ type Story = StoryObj; const storyTemplate = html>` ; diff --git a/packages/web-components/src/menu/menu.stories.ts b/packages/web-components/src/menu/menu.stories.ts index 0c67adaa0f5d29..161416d33a6a95 100644 --- a/packages/web-components/src/menu/menu.stories.ts +++ b/packages/web-components/src/menu/menu.stories.ts @@ -94,13 +94,13 @@ export default { primaryActionSlottedContent: { control: false, description: 'The primary action slot. Used when the menu is `split`', - name: '', + name: 'primary-action', table: { category: 'slots', type: {} }, }, triggerSlottedContent: { control: false, description: 'The trigger slot', - name: '', + name: 'trigger', table: { category: 'slots', type: {} }, }, }, diff --git a/packages/web-components/src/message-bar/message-bar.ts b/packages/web-components/src/message-bar/message-bar.ts index 7a3b5f8b0ab7f0..0bd1f3e3b35fa1 100644 --- a/packages/web-components/src/message-bar/message-bar.ts +++ b/packages/web-components/src/message-bar/message-bar.ts @@ -8,6 +8,7 @@ import { MessageBarIntent, MessageBarLayout, MessageBarShape } from './message-b * * @slot actions - Content that can be provided for the actions * @slot dismiss - Content that can be provided for the dismiss button + * @slot icon - Content that can be provided for the leading icon * @slot - The default slot for the content * @public */ diff --git a/packages/web-components/src/option/option.stories.ts b/packages/web-components/src/option/option.stories.ts index 85a8b08b4cbcdc..9bd4bd203ad3d4 100644 --- a/packages/web-components/src/option/option.stories.ts +++ b/packages/web-components/src/option/option.stories.ts @@ -38,6 +38,21 @@ export default { description: 'Sets the disabled state of the option', table: { category: 'attributes', type: { summary: 'boolean' } }, }, + form: { + control: 'text', + description: 'The form element that the option belongs to', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + freeform: { + control: 'boolean', + description: 'Sets whether the option behaves as freeform content', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + id: { + control: 'text', + description: 'The id of the option', + table: { category: 'attributes', type: { summary: 'string' } }, + }, name: { control: 'text', description: 'The name of the option', @@ -56,7 +71,7 @@ export default { selectedIndicatorContent: { control: false, description: 'Slot for selected indicator', - name: 'indicator', + name: 'checked-indicator', table: { category: 'slots', type: {} }, }, slottedContent: { diff --git a/packages/web-components/src/option/option.ts b/packages/web-components/src/option/option.ts index 7bba3f11b5b7db..fa22cf19b1eebb 100644 --- a/packages/web-components/src/option/option.ts +++ b/packages/web-components/src/option/option.ts @@ -10,6 +10,7 @@ import { uniqueId } from '../utils/unique-id.js'; * @tag fluent-dropdown-option * * @slot - The default slot for the option's content. + * @slot start - Optional content shown at the start of the option. * @slot checked-indicator - The checked indicator. * @slot description - Optional description content. * diff --git a/packages/web-components/src/progress-bar/progress-bar.base.ts b/packages/web-components/src/progress-bar/progress-bar.base.ts index 0b2c0ce48d7ec5..fd935e8d3aef43 100644 --- a/packages/web-components/src/progress-bar/progress-bar.base.ts +++ b/packages/web-components/src/progress-bar/progress-bar.base.ts @@ -61,7 +61,7 @@ export class BaseProgressBar extends FASTElement { * * HTML Attribute: `value` * - * @internal + * @public */ @attr({ converter: nullableNumberConverter }) public value?: number; @@ -85,7 +85,7 @@ export class BaseProgressBar extends FASTElement { * * HTML Attribute: `min` * - * @internal + * @public */ @attr({ converter: nullableNumberConverter }) public min?: number; @@ -110,7 +110,7 @@ export class BaseProgressBar extends FASTElement { * * HTML Attribute: `max` * - * @internal + * @public */ @attr({ converter: nullableNumberConverter }) public max?: number; diff --git a/packages/web-components/src/radio-group/radio-group.stories.ts b/packages/web-components/src/radio-group/radio-group.stories.ts index b6683e87e2dbf5..ccc5d847424408 100644 --- a/packages/web-components/src/radio-group/radio-group.stories.ts +++ b/packages/web-components/src/radio-group/radio-group.stories.ts @@ -82,6 +82,12 @@ export default { description: 'The name of the radio group.', type: 'string', }, + required: { + control: 'boolean', + table: { category: 'attributes' }, + description: 'Marks the radio group as required.', + type: 'boolean', + }, orientation: { control: 'select', description: 'The orientation of the radio group.', diff --git a/packages/web-components/src/radio/radio.stories.ts b/packages/web-components/src/radio/radio.stories.ts index f71842259faaec..a63dc334bd8974 100644 --- a/packages/web-components/src/radio/radio.stories.ts +++ b/packages/web-components/src/radio/radio.stories.ts @@ -29,6 +29,32 @@ export default { description: 'Sets disabled state on radio', table: { category: 'attributes', type: { summary: 'boolean' } }, }, + form: { + control: 'text', + description: 'The form element that the radio belongs to', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + name: { + control: 'text', + description: 'The name of the radio', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + required: { + control: 'boolean', + description: 'Marks radio as required', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + value: { + control: 'text', + description: 'The value of the radio', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + checkedIndicatorContent: { + control: false, + description: 'Slot for checked indicator', + name: 'checked-indicator', + table: { category: 'slots', type: {} }, + }, }, } as Meta; diff --git a/packages/web-components/src/rating-display/rating-display.stories.ts b/packages/web-components/src/rating-display/rating-display.stories.ts index bc2b509bd266e7..8b98a4442580ba 100644 --- a/packages/web-components/src/rating-display/rating-display.stories.ts +++ b/packages/web-components/src/rating-display/rating-display.stories.ts @@ -55,7 +55,9 @@ export default { }, 'icon-view-box': { control: 'text', + description: 'The viewBox attribute value used for the slotted icon.', table: { + category: 'attributes', type: { summary: 'The `viewBox` attribute of the icon SVG element', }, @@ -74,6 +76,12 @@ export default { description: 'The value of the rating', table: { category: 'attributes', type: { summary: 'number' } }, }, + ariaLabel: { + control: 'text', + name: 'aria-label', + description: 'Accessible label for assistive technologies.', + table: { category: 'aria-attributes', type: { summary: 'string' } }, + }, iconSlottedContent: { control: false, description: 'The slot for the SVG element used as the rating icon', diff --git a/packages/web-components/src/select/select.spec.md b/packages/web-components/src/select/select.spec.md index 7b31d02c1877ea..7048d67d588b25 100644 --- a/packages/web-components/src/select/select.spec.md +++ b/packages/web-components/src/select/select.spec.md @@ -17,7 +17,6 @@ The browser-native select control component allows users to choose one option fr - @attr disabled: boolean - @attr required: boolean - @attr name: string -- @attr autofocus: boolean - @attr autocomplete: "on" "off" | "off" - @attr aria-label: string - @attr aria-labelledby: string diff --git a/packages/web-components/src/slider/slider.stories.ts b/packages/web-components/src/slider/slider.stories.ts index 255372853fc586..8cbd227d3cd363 100644 --- a/packages/web-components/src/slider/slider.stories.ts +++ b/packages/web-components/src/slider/slider.stories.ts @@ -68,6 +68,17 @@ export default { type: Object.values(SliderSetOrientation).join('|'), }, }, + mode: { + control: 'text', + description: 'The slider mode.', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + thumbSlottedContent: { + control: false, + description: 'Slot for custom thumb content.', + name: 'thumb', + table: { category: 'slots', type: {} }, + }, }, } as Meta; diff --git a/packages/web-components/src/split-button/split-button.stories.ts b/packages/web-components/src/split-button/split-button.stories.ts index 2b8683abb163b4..6d457834c9a950 100644 --- a/packages/web-components/src/split-button/split-button.stories.ts +++ b/packages/web-components/src/split-button/split-button.stories.ts @@ -75,6 +75,26 @@ export default { type: { summary: 'boolean' }, }, }, + closeOnScroll: { + control: 'boolean', + name: 'close-on-scroll', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + openOnContext: { + control: 'boolean', + name: 'open-on-context', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + openOnHover: { + control: 'boolean', + name: 'open-on-hover', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, + persistOnItemClick: { + control: 'boolean', + name: 'persist-on-item-click', + table: { category: 'attributes', type: { summary: 'boolean' } }, + }, appearance: { control: 'select', description: 'Indicates the styled appearance of the button.', @@ -105,6 +125,24 @@ export default { type: { summary: Object.values(ButtonShape).join('|') }, }, }, + slottedContent: { + control: false, + description: 'The default slot', + name: '', + table: { category: 'slots', type: {} }, + }, + primaryActionSlottedContent: { + control: false, + description: 'The primary action slot. Used when the menu is `split`', + name: 'primary-action', + table: { category: 'slots', type: {} }, + }, + triggerSlottedContent: { + control: false, + description: 'The menu trigger slot', + name: 'trigger', + table: { category: 'slots', type: {} }, + }, }, } as Meta; diff --git a/packages/web-components/src/switch/switch.stories.ts b/packages/web-components/src/switch/switch.stories.ts index 527347e1e4534d..384eee6d08184c 100644 --- a/packages/web-components/src/switch/switch.stories.ts +++ b/packages/web-components/src/switch/switch.stories.ts @@ -44,11 +44,26 @@ export default { control: 'boolean', table: { category: 'attributes', type: { summary: 'boolean' } }, }, + form: { + description: 'The form element that the switch belongs to', + control: 'text', + table: { category: 'attributes', type: { summary: 'string' } }, + }, + name: { + description: 'The name of the switch', + control: 'text', + table: { category: 'attributes', type: { summary: 'string' } }, + }, required: { description: 'Sets the switch as required', control: 'boolean', table: { category: 'attributes', type: { summary: 'boolean' } }, }, + value: { + description: 'The value of the switch', + control: 'text', + table: { category: 'attributes', type: { summary: 'string' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/text-input/README.md b/packages/web-components/src/text-input/README.md index 3cdb61261a7dde..c0eb7d05d00e86 100644 --- a/packages/web-components/src/text-input/README.md +++ b/packages/web-components/src/text-input/README.md @@ -29,7 +29,6 @@ The `` is intended to match feature parity with the Fluent UI | ------------------- | ------- | ----------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `appearance` | public | `TextInputAppearance` | `'outline'` | Indicates the styled appearance of the element. | | `autocomplete` | public | `string \| undefined` | | Indicates the element's autocomplete state. | -| `autofocus` | public | `boolean` | | Indicates that the element should get focus after the page finishes loading. | | `controlSize` | public | `TextInputControlSize \| undefined` | `'medium'` | Sets the size of the control. | | `dirname` | public | `string \| undefined` | | Sets the directionality of the element to be submitted with form data. | | `disabled` | public | `boolean \| undefined` | | Sets the element's disabled state. | @@ -67,7 +66,6 @@ The `` is intended to match feature parity with the Fluent UI | -------------- | ------------- | | `appearance` | appearance | | `autocomplete` | autocomplete | -| `autofocus` | autofocus | | `control-size` | controlSize | | `dirname` | dirname | | `disabled` | disabled | diff --git a/packages/web-components/src/text-input/text-input.base.ts b/packages/web-components/src/text-input/text-input.base.ts index dfcb652490ca98..e6a4989714d17e 100644 --- a/packages/web-components/src/text-input/text-input.base.ts +++ b/packages/web-components/src/text-input/text-input.base.ts @@ -33,17 +33,6 @@ export class BaseTextInput extends FASTElement { @attr public autocomplete?: string; - /** - * Indicates that the element should get focus after the page finishes loading. - * @see The {@link https://developer.mozilla.org/docs/Web/HTML/Element/input#autofocus | `autofocus`} attribute - * - * @public - * @remarks - * HTML Attribute: `autofocus` - */ - @attr({ mode: 'boolean' }) - public autofocus!: boolean; - /** * The current value of the input. * @public diff --git a/packages/web-components/src/text-input/text-input.stories.ts b/packages/web-components/src/text-input/text-input.stories.ts index 8ec4050f721553..31da7393481041 100644 --- a/packages/web-components/src/text-input/text-input.stories.ts +++ b/packages/web-components/src/text-input/text-input.stories.ts @@ -26,7 +26,6 @@ const storyTemplate = html>` (options: TextInputOptions id="control" @change="${(x, c) => x.changeHandler(c.event as InputEvent)}" @input="${(x, c) => x.inputHandler(c.event as InputEvent)}" - ?autofocus="${x => x.autofocus}" autocomplete="${x => x.autocomplete}" ?disabled="${x => x.disabled}" list="${x => x.list}" diff --git a/packages/web-components/src/text/text.stories.ts b/packages/web-components/src/text/text.stories.ts index 7ac1f4f6646d74..e35d78a15d52ee 100644 --- a/packages/web-components/src/text/text.stories.ts +++ b/packages/web-components/src/text/text.stories.ts @@ -64,7 +64,7 @@ export default { mapping: { '': null, ...['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre', 'span'] }, options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre', 'span'], table: { - category: 'attributes', + category: 'demo', type: { summary: Object.values(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre', 'span']).join('|') }, }, }, diff --git a/packages/web-components/src/textarea/textarea.stories.ts b/packages/web-components/src/textarea/textarea.stories.ts index 28c58518da628b..98a12cac4c7ff6 100644 --- a/packages/web-components/src/textarea/textarea.stories.ts +++ b/packages/web-components/src/textarea/textarea.stories.ts @@ -10,7 +10,6 @@ const storyTemplate = html>` ` for the input.', table: { category: 'slots', type: {} }, }, diff --git a/packages/web-components/src/toggle-button/toggle-button.stories.ts b/packages/web-components/src/toggle-button/toggle-button.stories.ts index 1d32a19db6a0cd..94d6f521997c2c 100644 --- a/packages/web-components/src/toggle-button/toggle-button.stories.ts +++ b/packages/web-components/src/toggle-button/toggle-button.stories.ts @@ -7,7 +7,6 @@ type Story = StoryObj; const storyTemplate = html>` Date: Wed, 27 May 2026 17:23:46 -0500 Subject: [PATCH 02/14] docs: add cssParts and events to storybook --- .../scripts/verify-storybook.js | 152 +++++++++++++++++- .../src/accordion/accordion.stories.ts | 5 + .../src/button/button.stories.ts | 5 + .../src/checkbox/checkbox.stories.ts | 10 ++ .../compound-button.stories.ts | 5 + .../src/drawer/drawer.stories.ts | 15 ++ packages/web-components/src/drawer/drawer.ts | 2 - .../web-components/src/link/link.stories.ts | 10 ++ .../src/menu-button/menu-button.stories.ts | 5 + .../web-components/src/radio/radio.stories.ts | 10 ++ .../src/slider/slider.stories.ts | 15 ++ .../src/text-input/text-input.stories.ts | 22 ++- .../src/textarea/textarea.stories.ts | 25 +++ .../toggle-button/toggle-button.stories.ts | 5 + 14 files changed, 275 insertions(+), 11 deletions(-) diff --git a/packages/web-components/scripts/verify-storybook.js b/packages/web-components/scripts/verify-storybook.js index 4e9ca388dda15c..ca737b55304bec 100644 --- a/packages/web-components/scripts/verify-storybook.js +++ b/packages/web-components/scripts/verify-storybook.js @@ -56,7 +56,7 @@ const CATEGORY_LABELS = { properties: 'properties', }; -const CORE_ALWAYS_VALIDATED_CATEGORIES = new Set(['attributes', 'slots']); +const CORE_ALWAYS_VALIDATED_CATEGORIES = new Set(['attributes', 'slots', 'cssParts', 'events']); const readFile = filePath => fs.readFileSync(filePath, 'utf8'); @@ -347,9 +347,141 @@ function relativeFromPackage(filePath) { return path.relative(packageRoot, filePath).replaceAll(path.sep, '/'); } +function toCanonicalSet(values) { + const set = new Set(); + + for (const value of values) { + const canonical = toCanonicalName(value); + if (canonical) { + set.add(canonical); + } + } + + return set; +} + +function listMissingByCanonical(expected, actual) { + const expectedCanonical = toCanonicalSet(expected); + const actualCanonical = toCanonicalSet(actual); + + return [...expectedCanonical].filter(value => !actualCanonical.has(value)).sort(); +} + +function walkSourceComponentDirectories(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + return entries.filter(entry => entry.isDirectory()).map(entry => path.join(dir, entry.name)); +} + +function listTsFiles(dir) { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.ts')) + .map(entry => path.join(dir, entry.name)); +} + +function extractTemplateParts(sourceText) { + const parts = new Set(); + + for (const match of sourceText.matchAll(/\bpart\s*=\s*['"]([^'"]+)['"]/g)) { + const [, rawParts = ''] = match; + + for (const part of rawParts.split(/\s+/)) { + const normalizedPart = part.trim(); + if (normalizedPart) { + parts.add(normalizedPart); + } + } + } + + return parts; +} + +function extractEmittedEvents(sourceText) { + const events = new Set(); + + for (const match of sourceText.matchAll(/\$emit\(\s*['"`]([a-z0-9-]+)['"`]/gi)) { + events.add(match[1]); + } + + for (const match of sourceText.matchAll( + /dispatchEvent\(\s*new\s+[A-Za-z0-9_.]*Event\s*\(\s*['"`]([a-z0-9-]+)['"`]/gi, + )) { + events.add(match[1]); + } + + return events; +} + +function auditSourceTagsAgainstCem(cemByTag) { + const componentDirs = walkSourceComponentDirectories(srcDir); + const issues = []; + + for (const componentDir of componentDirs) { + const componentName = path.basename(componentDir); + const tagName = pickTagName({ tagCandidates: [], componentName, cemByTag }); + + if (!tagName) { + continue; + } + + const cemEntry = cemByTag.get(tagName); + if (!cemEntry) { + continue; + } + + const tsFiles = listTsFiles(componentDir); + if (tsFiles.length === 0) { + continue; + } + + const templateFiles = tsFiles.filter(filePath => filePath.endsWith('.template.ts')); + const componentFiles = tsFiles.filter( + filePath => + !filePath.endsWith('.stories.ts') && + !filePath.endsWith('.template.ts') && + !filePath.endsWith('.options.ts') && + !filePath.endsWith('.styles.ts') && + !filePath.endsWith('.spec.ts') && + !filePath.endsWith('.test.ts'), + ); + + const discoveredParts = new Set(); + for (const templateFile of templateFiles) { + const parts = extractTemplateParts(readFile(templateFile)); + for (const part of parts) { + discoveredParts.add(part); + } + } + + const emittedEvents = new Set(); + for (const componentFile of componentFiles) { + const events = extractEmittedEvents(readFile(componentFile)); + for (const eventName of events) { + emittedEvents.add(eventName); + } + } + + const missingCssPartsInCem = listMissingByCanonical(discoveredParts, cemEntry.cssParts); + const missingEventsInCem = listMissingByCanonical(emittedEvents, cemEntry.events); + + if (missingCssPartsInCem.length > 0 || missingEventsInCem.length > 0) { + issues.push({ + componentPath: relativeFromPackage(componentDir), + tagName, + missingCssPartsInCem, + missingEventsInCem, + }); + } + } + + return issues; +} + function validate({ failOnMismatch = true } = {}) { const cemByTag = loadCustomElementsMap(); const storyFiles = walkStories(srcDir); + const sourceTagIssues = auditSourceTagsAgainstCem(cemByTag); const errors = []; const warnings = []; @@ -442,8 +574,24 @@ function validate({ failOnMismatch = true } = {}) { console.log(''); } + if (sourceTagIssues.length > 0) { + console.log('Source tag audit (potential missing @csspart/@fires tags):'); + for (const issue of sourceTagIssues) { + console.log(` - ${issue.componentPath} (${issue.tagName})`); + + if (issue.missingCssPartsInCem.length > 0) { + console.log(` template parts missing from CEM cssParts: ${issue.missingCssPartsInCem.join(', ')}`); + } + + if (issue.missingEventsInCem.length > 0) { + console.log(` emitted events missing from CEM events: ${issue.missingEventsInCem.join(', ')}`); + } + } + console.log(''); + } + console.log( - `Checked ${storyFiles.length} story files. Skipped: ${skipped.length}. Warnings: ${warnings.length}. Mismatches: ${errors.length}.`, + `Checked ${storyFiles.length} story files. Skipped: ${skipped.length}. Warnings: ${warnings.length}. Source tag issues: ${sourceTagIssues.length}. Mismatches: ${errors.length}.`, ); if (errors.length === 0) { diff --git a/packages/web-components/src/accordion/accordion.stories.ts b/packages/web-components/src/accordion/accordion.stories.ts index 676ba2b95d992f..9f53acd1586744 100644 --- a/packages/web-components/src/accordion/accordion.stories.ts +++ b/packages/web-components/src/accordion/accordion.stories.ts @@ -55,6 +55,11 @@ export default { name: '', table: { category: 'slots', type: {} }, }, + change: { + control: false, + description: 'Fires when the active accordion item changes.', + table: { category: 'events', type: { summary: 'CustomEvent' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/button/button.stories.ts b/packages/web-components/src/button/button.stories.ts index 08de9ab37de344..0659ef34e854c4 100644 --- a/packages/web-components/src/button/button.stories.ts +++ b/packages/web-components/src/button/button.stories.ts @@ -159,6 +159,11 @@ export default { name: 'end', table: { category: 'slots', type: {} }, }, + content: { + control: false, + description: 'The button content container.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/checkbox/checkbox.stories.ts b/packages/web-components/src/checkbox/checkbox.stories.ts index d90b2f123006df..aa1de34556bfd2 100644 --- a/packages/web-components/src/checkbox/checkbox.stories.ts +++ b/packages/web-components/src/checkbox/checkbox.stories.ts @@ -106,6 +106,16 @@ export default { table: { category: 'slots', type: {} }, }, label: { table: { disable: true } }, + change: { + control: false, + description: 'Fires when the checked state changes.', + table: { category: 'events', type: { summary: 'CustomEvent' } }, + }, + input: { + control: false, + description: 'Fires while the checked state changes.', + table: { category: 'events', type: { summary: 'CustomEvent' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/compound-button/compound-button.stories.ts b/packages/web-components/src/compound-button/compound-button.stories.ts index fc022d715ef385..473aeda5c2adfd 100644 --- a/packages/web-components/src/compound-button/compound-button.stories.ts +++ b/packages/web-components/src/compound-button/compound-button.stories.ts @@ -145,6 +145,11 @@ export default { name: 'end', table: { category: 'slots', type: {} }, }, + content: { + control: false, + description: 'The button content container.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/drawer/drawer.stories.ts b/packages/web-components/src/drawer/drawer.stories.ts index 03f0ebfbeef396..e4a3804e3afbf9 100644 --- a/packages/web-components/src/drawer/drawer.stories.ts +++ b/packages/web-components/src/drawer/drawer.stories.ts @@ -156,6 +156,21 @@ export default { type: { summary: 'Defaults to --colorBackgroundOverlay' }, }, }, + dialog: { + control: false, + description: 'The dialog element of the drawer.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, + beforetoggle: { + control: false, + description: "Emitted before the dialog's open state changes.", + table: { category: 'events', type: { summary: 'Event' } }, + }, + toggle: { + control: false, + description: "Emitted after the dialog's open state changes.", + table: { category: 'events', type: { summary: 'Event' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/drawer/drawer.ts b/packages/web-components/src/drawer/drawer.ts index 91c16ca6c0c754..03e8ba48470115 100644 --- a/packages/web-components/src/drawer/drawer.ts +++ b/packages/web-components/src/drawer/drawer.ts @@ -4,8 +4,6 @@ import { DrawerPosition, DrawerSize, DrawerType } from './drawer.options.js'; /** * A Drawer component that allows content to be displayed in a side panel. It can be rendered as modal or non-modal. * - * @tag fluent-drawer - * * @extends FASTElement * * @attr type - Determines whether the drawer should be displayed as modal, non-modal, or alert. diff --git a/packages/web-components/src/link/link.stories.ts b/packages/web-components/src/link/link.stories.ts index 357bbf83fa841c..4468c87af5966f 100644 --- a/packages/web-components/src/link/link.stories.ts +++ b/packages/web-components/src/link/link.stories.ts @@ -115,6 +115,16 @@ export default { name: 'end', table: { category: 'slots', type: {} }, }, + control: { + control: false, + description: 'The internal anchor element.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, + content: { + control: false, + description: 'The element wrapping link content.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/menu-button/menu-button.stories.ts b/packages/web-components/src/menu-button/menu-button.stories.ts index 6a93b99c62c906..a3e8f239c69ec0 100644 --- a/packages/web-components/src/menu-button/menu-button.stories.ts +++ b/packages/web-components/src/menu-button/menu-button.stories.ts @@ -152,6 +152,11 @@ export default { name: 'end', table: { category: 'slots', type: {} }, }, + content: { + control: false, + description: 'The button content container.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/radio/radio.stories.ts b/packages/web-components/src/radio/radio.stories.ts index a63dc334bd8974..908355cf0ae531 100644 --- a/packages/web-components/src/radio/radio.stories.ts +++ b/packages/web-components/src/radio/radio.stories.ts @@ -55,6 +55,16 @@ export default { name: 'checked-indicator', table: { category: 'slots', type: {} }, }, + change: { + control: false, + description: 'Fires when the checked state changes.', + table: { category: 'events', type: { summary: 'CustomEvent' } }, + }, + input: { + control: false, + description: 'Fires while the checked state changes.', + table: { category: 'events', type: { summary: 'CustomEvent' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/slider/slider.stories.ts b/packages/web-components/src/slider/slider.stories.ts index 8cbd227d3cd363..1c92df5524dff6 100644 --- a/packages/web-components/src/slider/slider.stories.ts +++ b/packages/web-components/src/slider/slider.stories.ts @@ -79,6 +79,21 @@ export default { name: 'thumb', table: { category: 'slots', type: {} }, }, + 'thumb-container': { + control: false, + description: 'The container element of the thumb.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, + 'track-container': { + control: false, + description: 'The container element of the track.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, + change: { + control: false, + description: 'Fires when the value changes.', + table: { category: 'events', type: { summary: 'CustomEvent' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/text-input/text-input.stories.ts b/packages/web-components/src/text-input/text-input.stories.ts index 31da7393481041..a3663896195321 100644 --- a/packages/web-components/src/text-input/text-input.stories.ts +++ b/packages/web-components/src/text-input/text-input.stories.ts @@ -84,13 +84,6 @@ export default { }, }, }, - currentValue: { - control: 'text', - name: 'current-value', - table: { category: 'attributes' }, - description: 'The current value representation used by the control.', - type: 'string', - }, dirname: { control: 'text', table: { category: 'attributes' }, @@ -218,6 +211,21 @@ export default { description: 'Content in this slot is placed at the end of the input.', table: { category: 'slots', type: {} }, }, + label: { + control: false, + description: 'The internal label element.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, + root: { + control: false, + description: 'The root container for the internal control.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, + control: { + control: false, + description: 'The internal input control.', + table: { category: 'css parts', type: { summary: 'part' } }, + }, }, } as Meta; diff --git a/packages/web-components/src/textarea/textarea.stories.ts b/packages/web-components/src/textarea/textarea.stories.ts index 98a12cac4c7ff6..12177254420c24 100644 --- a/packages/web-components/src/textarea/textarea.stories.ts +++ b/packages/web-components/src/textarea/textarea.stories.ts @@ -177,6 +177,31 @@ export default { description: 'The label slot. Content in this slot is used as the `