diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000000..db6aa408223a --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,35 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +jobs: + claude-review: + # Claude Code reviews are estimated to cost $15-25. use only when necessary and only internally. + if: | + (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') && + contains(github.event.pull_request.labels.*.name, 'claude-review') + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000000..7921c3a3c925 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,68 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # Only org OWNERs and MEMBERs may trigger Claude. author_association is set by + # GitHub on the comment/review/issue payload; everything else (COLLABORATOR, + # CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, NONE) is rejected. + if: | + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && ( + (github.event.action == 'opened' && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.issue.author_association) && + (contains(github.event.issue.body, '`@claude`') || contains(github.event.issue.title, '`@claude`'))) || + (github.event.action == 'assigned' && github.event.assignee.login == 'claude') + )) + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + assignee_trigger: 'claude' + base_branch: 'next' + prompt: 'Respect the PR description template and repository guidelines in AGENTS.md.' + # Allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Tool permissions Claude needs to follow AGENTS.md (install, compile, + # typecheck, lint, format, test). Bash is NOT allowed by default, so + # without this Claude cannot run any of the repo's build/test commands. + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: | + --allowed-tools "Bash(yarn:*),Bash(cd:*),Bash(node:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*)" + --disallowed-tools "Bash(yarn start),Bash(yarn task dev)" diff --git a/.github/workflows/trigger-circle-ci-workflow.yml b/.github/workflows/trigger-circle-ci-workflow.yml index db7dd9893e50..d2f16bf0ec28 100644 --- a/.github/workflows/trigger-circle-ci-workflow.yml +++ b/.github/workflows/trigger-circle-ci-workflow.yml @@ -44,8 +44,13 @@ on: - next - main +# For PR events, group per-PR (github.ref is refs/pull//merge) so redundant +# re-triggers on the same PR cancel each other. For pushes to a shared branch +# (next/main), key on the commit SHA instead: otherwise two merges landing close +# together share the refs/heads/ group and the earlier merge's trigger is +# cancelled before it can POST to CircleCI, silently skipping its `merged` workflow. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.event.pull_request.number }} cancel-in-progress: true jobs: diff --git a/.vscode/settings.json b/.vscode/settings.json index b623588b5666..a4b94099da83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,8 +60,6 @@ "typescript.preferences.quoteStyle": "single", "typescript.preferGoToSourceDefinition": true, "typescript.tsdk": "./node_modules/typescript/lib", - "vitest.workspaceConfig": "./code/vitest.workspace.ts", - "vitest.rootConfig": "./code/vitest.workspace.ts", "oxc.fmt.configPath": ".oxfmtrc.json", "oxc.enable.oxlint": false } diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 5a1d36ff946a..dfedf5dd9cf9 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,19 @@ +## 10.5.0-alpha.10 + +- A11y: Handle lang attribute throughout preview - [#35321](https://github.com/storybookjs/storybook/pull/35321), thanks @Sidnioulz! +- A11y: Surface required args and keyboard-reachable Setup controls in ArgsTable - [#35306](https://github.com/storybookjs/storybook/pull/35306), thanks @Sidnioulz! +- Addon-Vitest: Subscribe for run completion before triggering it - [#35291](https://github.com/storybookjs/storybook/pull/35291), thanks @tsushanth! +- Core: Compose core annotations before preview beforeAll - [#35323](https://github.com/storybookjs/storybook/pull/35323), thanks @JReinhold! +- Docgen: Run experimentalDocgenServer extraction in a worker thread - [#35324](https://github.com/storybookjs/storybook/pull/35324), thanks @JReinhold! +- DocgenServer: Fix OOM by recycling the shared TS program - [#35305](https://github.com/storybookjs/storybook/pull/35305), thanks @ndelangen! +- Next: Call link onClick before preventing default - [#35311](https://github.com/storybookjs/storybook/pull/35311), thanks @yatishgoel! +- Review: Dynamic thumbnail scaling based on content dimensions - [#35282](https://github.com/storybookjs/storybook/pull/35282), thanks @ghengeveld! +- Review: Improve review thumbnail scaling and loading UX - [#35335](https://github.com/storybookjs/storybook/pull/35335), thanks @ghengeveld! +- Review: Notify on review arrival instead of auto-navigating - [#35276](https://github.com/storybookjs/storybook/pull/35276), thanks @ghengeveld! +- Review: Simplify layout handling - [#35322](https://github.com/storybookjs/storybook/pull/35322), thanks @ghengeveld! +- UI: Hide onboarding menu with feature flag - [#35299](https://github.com/storybookjs/storybook/pull/35299), thanks @Sidnioulz! +- Vue: Fix Unicode Default Values In Docs - [#34909](https://github.com/storybookjs/storybook/pull/34909), thanks @Arunsiva003! + ## 10.5.0-alpha.9 - Addon Vitest: Avoid erroring out on benign Win process exits - [#35287](https://github.com/storybookjs/storybook/pull/35287), thanks @Sidnioulz! diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 7fc0aa5e8e3f..e6258be49aaf 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -356,7 +356,11 @@ const decorators = [ ] satisfies Decorator[]; const parameters = { + // Redundant but useful for testing lang parameter propagation in stories. + htmlLang: 'en-CA', docs: { + // Redundant but useful for testing lang parameter propagation in docs. + lang: 'en-US', theme: themes.light, codePanel: true, source: { diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx index d917b117c356..93b5b409d739 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx @@ -107,6 +107,7 @@ function renderArgTypesTables({ include, exclude, sort, + docsLang, }: { mainName?: string; mainRows: StrictArgTypes; @@ -114,11 +115,12 @@ function renderArgTypesTables({ include?: PropDescriptor; exclude?: PropDescriptor; sort?: SortType; + docsLang?: string; }) { const filteredMainRows = filterArgTypes(mainRows, include, exclude); if (Object.keys(subcomponentRows).length === 0) { - return ; + return ; } const tabs = { @@ -134,7 +136,7 @@ function renderArgTypesTables({ ), }; - return ; + return ; } const LegacyArgTypes: FC = (props) => { @@ -148,6 +150,7 @@ const LegacyArgTypes: FC = (props) => { mainName: getComponentName(component), mainRows: argTypes, subcomponentRows: extractSubcomponentArgTypes(subcomponents, parameters), + docsLang: parameters?.docs?.lang, ...filterProps, }); }; @@ -175,6 +178,7 @@ const DocgenServiceArgTypes: FC = (props) => { mainName: getComponentName(component) ?? serviceRows.serviceComponentName, mainRows: serviceRows.mainRows, subcomponentRows: serviceRows.subcomponentRows, + docsLang: parameters?.docs?.lang, ...filterProps, }); }; diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index f0f3bbe8d8b7..ad046cf5bcfb 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -6,8 +6,8 @@ import { useId } from '@react-aria/utils'; import type { Args, Globals, Renderer, StrictArgTypes } from 'storybook/internal/csf'; import type { DocsContextProps, ModuleExports, PreparedStory } from 'storybook/internal/types'; -import { filterArgTypes } from 'storybook/preview-api'; import type { PropDescriptor } from 'storybook/preview-api'; +import { filterArgTypes } from 'storybook/preview-api'; import type { SortType } from '../components'; import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; @@ -50,6 +50,7 @@ type ControlsTablesProps = ControlsInteractiveState & { exclude?: PropDescriptor; sort?: SortType; storyId: string; + docsLang?: string; }; function getControlsFilterProps(story: PreparedStory, props: ControlsProps): ControlsParameters { @@ -89,6 +90,7 @@ const ControlsTables: FC = ({ globals, updateArgs, resetArgs, + docsLang, }) => { const filteredMainRows = filterArgTypes(mainRows, include, exclude); @@ -107,6 +109,7 @@ const ControlsTables: FC = ({ globals={globals} updateArgs={updateArgs} resetArgs={resetArgs} + docsLang={docsLang} /> ); } @@ -134,6 +137,7 @@ const ControlsTables: FC = ({ resetArgs={resetArgs} storyId={storyId} controlsId={controlsId} + docsLang={docsLang} /> ); }; @@ -155,6 +159,7 @@ const LegacyControls: FC = ({ story, context, ...props }) => {...filterProps} storyId={story.id} {...interactiveState} + docsLang={parameters?.docs?.lang} /> ); }; @@ -187,6 +192,7 @@ const DocgenServiceControls: FC = ({ story, context, ...prop {...filterProps} storyId={story.id} {...interactiveState} + docsLang={parameters?.docs?.lang} /> ); }; diff --git a/code/addons/docs/src/blocks/blocks/Description.tsx b/code/addons/docs/src/blocks/blocks/Description.tsx index c717be1ef6c6..f9ef0d6bd1eb 100644 --- a/code/addons/docs/src/blocks/blocks/Description.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.tsx @@ -5,10 +5,10 @@ import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; import { DocsContext } from './DocsContext'; import { Markdown } from './Markdown'; -import type { Of } from './useOf'; -import { useOf } from './useOf'; import { useServiceDocgen } from './use-service-docgen.ts'; import { useServiceStoryDoc } from './use-service-story-docs.ts'; +import type { Of } from './useOf'; +import { useOf } from './useOf'; import { withMdxComponentOverride } from './with-mdx-component-override'; export enum DescriptionType { @@ -78,22 +78,30 @@ type ResolvedOf = ReturnType; const DescriptionBody: FC<{ resolvedOf: ResolvedOf; serviceComponentDescription?: string; + lang?: string; storyDocsDescription?: string | null; -}> = ({ resolvedOf, serviceComponentDescription, storyDocsDescription }) => { +}> = ({ resolvedOf, serviceComponentDescription, lang, storyDocsDescription }) => { const markdown = getDescriptionFromResolvedOf( resolvedOf, serviceComponentDescription, storyDocsDescription ); - return markdown ? {markdown} : null; + return markdown ? {markdown} : null; }; -const DescriptionStoryWithServices: FC<{ resolvedOf: Extract }> = ({ - resolvedOf, -}) => { +const DescriptionStoryWithServices: FC<{ + resolvedOf: Extract; +}> = ({ resolvedOf }) => { const storyDocsDescription = useServiceStoryDoc(resolvedOf.story.id).data?.description; - return ; + const lang = resolvedOf.story.parameters.docs?.lang || 'en'; + return ( + + ); }; const DescriptionComponentWithServices: FC<{ @@ -101,22 +109,27 @@ const DescriptionComponentWithServices: FC<{ componentId: string; }> = ({ resolvedOf, componentId }) => { const serviceComponentDescription = useServiceDocgen(componentId).data?.description || undefined; + const lang = + resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.parameters.docs?.lang || 'en' + : resolvedOf.projectAnnotations.parameters?.docs?.lang || 'en'; return ( ); }; const DescriptionImpl: FC = (props) => { const { of } = props; + const context = useContext(DocsContext); if ('of' in props && of === undefined) { throw new InvalidBlockOfPropError(); } const resolvedOf = useOf(of || 'meta'); - const context = useContext(DocsContext); // The docgen service contributes a fallback description, but its two sources need different hooks // (story-docs by story id, docgen by component id), so each lives in its own child component to @@ -137,7 +150,14 @@ const DescriptionImpl: FC = (props) => { } } - return ; + const parameters = + resolvedOf.type === 'story' + ? resolvedOf.story.parameters + : resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.parameters + : resolvedOf.projectAnnotations.parameters; + const lang = parameters?.docs?.lang || 'en'; + return ; }; export const Description = withMdxComponentOverride('Description', DescriptionImpl); diff --git a/code/addons/docs/src/blocks/blocks/DocsContainer.tsx b/code/addons/docs/src/blocks/blocks/DocsContainer.tsx index 3cae103afe80..8567c7afb7a2 100644 --- a/code/addons/docs/src/blocks/blocks/DocsContainer.tsx +++ b/code/addons/docs/src/blocks/blocks/DocsContainer.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useMemo } from 'react'; import type { Renderer } from 'storybook/internal/types'; import type { ThemeVars } from 'storybook/theming'; -import { ThemeProvider, ensure as ensureTheme } from 'storybook/theming'; +import { ensure as ensureTheme, ThemeProvider } from 'storybook/theming'; import { DocsPageWrapper } from '../components'; import { TableOfContents } from '../components/TableOfContents'; @@ -28,13 +28,18 @@ export const DocsContainer: FC> = ({ }) => { const slugger = useMemo(() => createDocsSlugger(), []); let toc; + // Language of docs prose (descriptions, ArgTypes description cells, free MDX prose). + let lang; try { const meta = context.resolveOf('meta', ['meta']); - toc = meta.preparedMeta.parameters?.docs?.toc; + const metaParameters = meta.preparedMeta.parameters; + toc = metaParameters?.docs?.toc; + lang = metaParameters?.docs?.lang || 'en'; } catch (err) { // No meta, falling back to project annotations toc = context?.projectAnnotations?.parameters?.docs?.toc; + lang = context?.projectAnnotations?.parameters?.docs?.lang || 'en'; } useEffect(() => { @@ -61,6 +66,7 @@ export const DocsContainer: FC> = ({ { + await waitFor(() => { + const inner = canvasElement.querySelector('[id$="-inner"]'); + expect(inner).toHaveAttribute('lang', 'fr'); + }); + }, +}; + export const InlineWithHeightProps: Story = { ...Inline, args: { diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx index 700a90eeffdb..1cc3b2e697e1 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx @@ -20,7 +20,7 @@ export interface ArgControlProps { row: ArgType; arg: any; updateArgs: (args: Args) => void; - isHovered: boolean; + isRequired: boolean; storyId?: string; controlsId?: string; } @@ -49,7 +49,7 @@ export const ArgControl: FC = ({ row, arg, updateArgs, - isHovered, + isRequired, storyId, controlsId, }) => { @@ -79,16 +79,26 @@ export const ArgControl: FC = ({ if (!control || control.disable) { const canBeSetup = control?.disable !== true && row?.type?.name !== 'function'; - return isHovered && canBeSetup ? ( - - Setup controls - - ) : ( - + if (!canBeSetup) { + return ; + } + // Both nodes are always rendered; the parent row toggles their visibility with CSS on + // :hover and :focus-within, so the link stays reachable for keyboard users. + return ( + <> + + + Setup controls + + + + + + ); } // row.name is a display name and not a suitable DOM input id or name - i might contain whitespace etc. @@ -99,6 +109,7 @@ export const ArgControl: FC = ({ controlsId, argType: row, value: boxedValue.value, + required: isRequired, onChange, onBlur, onFocus, diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx index adb6f33e8453..d21e05930587 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react'; -import React, { useState } from 'react'; +import React from 'react'; import { codeCommon } from 'storybook/internal/components'; import Markdown from 'markdown-to-jsx'; import { transparentize } from 'polished'; import type { CSSObject } from 'storybook/theming'; -import { styled } from 'storybook/theming'; +import { srOnlyStyles, srOnlyUnsetStyles, styled } from 'storybook/theming'; import type { ArgControlProps } from './ArgControl'; import { ArgControl } from './ArgControl'; @@ -23,6 +23,7 @@ interface ArgRowProps { initialExpandedArgs?: boolean; storyId?: string; controlsId?: string; + docsLang?: string; } const Name = styled.span({ fontWeight: 'bold' }); @@ -80,6 +81,22 @@ const StyledTd = styled.td<{ expandable: boolean }>(({ expandable }) => ({ paddingLeft: expandable ? '40px !important' : '20px !important', })); +// When a row has no usable control, the "Setup controls" link is hidden and a placeholder dash is +// shown. Hovering or focusing within the row swaps them, so keyboard users reach the link too. +const StyledTr = styled.tr({ + [`span.sbdocs-argcontrol-setup`]: { + ...srOnlyStyles, + }, + '&:hover, &:focus-within': { + [`span.sbdocs-argcontrol-setup`]: { + ...srOnlyUnsetStyles, + }, + [`span.sbdocs-argcontrol-placeholder`]: { + ...srOnlyStyles, + }, + }, +}); + const toSummary = (value: any) => { if (!value) { return value; @@ -89,8 +106,7 @@ const toSummary = (value: any) => { }; export const ArgRow: FC = (props) => { - const [isHovered, setIsHovered] = useState(false); - const { row, updateArgs, compact, expandable, initialExpandedArgs } = props; + const { row, updateArgs, compact, expandable, initialExpandedArgs, docsLang } = props; const { name, description } = row; const table = (row.table || {}) as TableAnnotation; const type = table.type || toSummary(row.type); @@ -99,15 +115,19 @@ export const ArgRow: FC = (props) => { const hasDescription = description != null && description !== ''; return ( - setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + {name} - {required ? * : null} + {required ? ( + + * + + ) : null} {compact ? null : ( {hasDescription && ( - + {description} )} @@ -132,9 +152,9 @@ export const ArgRow: FC = (props) => { )} {updateArgs ? ( - + ) : null} - + ); }; diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx new file mode 100644 index 000000000000..71bacd19bbf1 --- /dev/null +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import React from 'react'; + +import { ThemeProvider, convert, themes } from 'storybook/theming'; + +import * as ArgRow from './ArgRow.stories'; +import { ArgsTable } from './ArgsTable'; + +const renderTable = (docsLang?: string) => + render( + + {/* Wrap in a non-en region to prove chrome re-asserts English */} +
+ +
+
+ ); + +describe('ArgsTable chrome stays English', () => { + afterEach(() => cleanup()); + + it('declares English on the table so the chrome stays English under a non-en region', () => { + const { container } = renderTable(); + expect(container.querySelector('.docblock-argstable')?.getAttribute('lang')).toBe('en'); + }); + + it('keeps the arg-name cell announced in English', () => { + const { container } = renderTable(); + const nameCell = container.querySelector('tbody tr td:first-of-type'); + // The cell inherits English from the table wrapper rather than declaring its own lang. + expect(nameCell?.closest('[lang]')?.getAttribute('lang')).toBe('en'); + }); + + it('languages argument descriptions with docs.lang', () => { + const { container } = renderTable('de'); + const description = container.querySelector('tbody tr td:nth-of-type(2) > div'); + expect(description?.getAttribute('lang')).toBe('de'); + }); +}); diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx index 9663230196b6..6b142951e62d 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx @@ -217,6 +217,7 @@ export interface ArgsTableOptionProps { sort?: SortType; storyId?: string; controlsId?: string; + docsLang?: string; } interface ArgsTableDataProps { rows: ArgTypes; @@ -342,6 +343,7 @@ export const ArgsTable: FC = (props) => { isLoading, storyId, controlsId, + docsLang, } = props; const { rows, args, globals } = @@ -431,6 +433,7 @@ export const ArgsTable: FC = (props) => { onClick={handleResetClick} disabled={isResetting} ariaLabel={isResetting ? 'Resetting controls...' : 'Reset controls'} + lang="en" > @@ -440,6 +443,7 @@ export const ArgsTable: FC = (props) => { @@ -465,7 +469,13 @@ export const ArgsTable: FC = (props) => { {groups.ungrouped.map((row) => ( - + ))} {Object.entries(groups.ungroupedSubsections).map(([subcategory, subsection]) => ( diff --git a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx index f23770faef6b..7d75120d7b83 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx @@ -30,7 +30,7 @@ export const TabbedArgsTable: FC = ({ tabs, ...props }) => // are subcomponents, never controllable, so we filter out the props indicating // controllability. Pass a stable element (not an inline function component) so React // reconciles the tab panel instead of remounting it on every args update. - const argsTableProps = index === 0 ? props : { sort: props.sort }; + const argsTableProps = index === 0 ? props : { sort: props.sort, docsLang: props.docsLang }; return { id: `prop_table_div_${label}`, diff --git a/code/addons/docs/src/blocks/components/DocsPage.stories.tsx b/code/addons/docs/src/blocks/components/DocsPage.stories.tsx index f46cb44b5bb0..cef070b0ff88 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.stories.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.stories.tsx @@ -1,6 +1,7 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import React from 'react'; +import { expect, within } from 'storybook/test'; import { Global, css } from 'storybook/theming'; import { ArgsTable, Source } from '.'; @@ -90,6 +91,20 @@ export const Markdown = () => (
); +export const ForwardsLang = { + name: 'Forwards lang', + render: () => ( + + Sprache + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const content = within(canvasElement).getByText('Sprache').closest('.sbdocs-content'); + await expect(content).toHaveAttribute('lang', 'de'); + }, +}; + export const Html = { name: 'HTML', render: () => ( diff --git a/code/addons/docs/src/blocks/components/DocsPage.tsx b/code/addons/docs/src/blocks/components/DocsPage.tsx index 2f74ac7ebc0e..24a00590cfbc 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.tsx @@ -452,11 +452,14 @@ export const DocsWrapper = styled.div(({ theme }) => ({ interface DocsPageWrapperProps { children?: React.ReactNode; toc?: React.ReactNode; + lang?: string; } -export const DocsPageWrapper: FC = ({ children, toc }) => ( +export const DocsPageWrapper: FC = ({ children, toc, lang = 'en' }) => ( {toc} - {children} + + {children} + ); diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index db7d102e2d24..9743c34d9f19 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -2,8 +2,8 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react'; import React, { Children, useCallback, useContext, useMemo, useState } from 'react'; import { logger } from 'storybook/internal/client-logger'; -import { Bar, Button, ToggleButton, Zoom } from 'storybook/internal/components'; import type { ActionItem } from 'storybook/internal/components'; +import { Bar, Button, ToggleButton, Zoom } from 'storybook/internal/components'; import { CopyIcon, MarkupIcon } from '@storybook/icons'; @@ -232,6 +232,7 @@ export const Preview: FC = ({ {hasSourceError && ( diff --git a/code/addons/docs/src/blocks/components/Story.tsx b/code/addons/docs/src/blocks/components/Story.tsx index 144d14445fba..bdb153d4162b 100644 --- a/code/addons/docs/src/blocks/components/Story.tsx +++ b/code/addons/docs/src/blocks/components/Story.tsx @@ -82,7 +82,12 @@ const InlineStory: FunctionComponent = (props) => { )} { min-height: ${height}; transform: translateZ(0); overflow: auto }`} ) : null} {showLoader && } -
+
); }; diff --git a/code/addons/docs/src/blocks/components/TableOfContents.tsx b/code/addons/docs/src/blocks/components/TableOfContents.tsx index 75ff1364f242..9c5cd0102ef7 100644 --- a/code/addons/docs/src/blocks/components/TableOfContents.tsx +++ b/code/addons/docs/src/blocks/components/TableOfContents.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; import type { FC, ReactElement } from 'react'; +import React, { useEffect } from 'react'; import type { Channel } from 'storybook/internal/channels'; import { NAVIGATE_URL } from 'storybook/internal/core-events'; @@ -145,7 +145,12 @@ const Title: FC<{ // General case. if (typeof title === 'string' || !title) { return ( - + {title || 'Table of contents'} ); diff --git a/code/addons/docs/src/blocks/components/Toolbar.tsx b/code/addons/docs/src/blocks/components/Toolbar.tsx index a9faf0be1280..68470d7f4f55 100644 --- a/code/addons/docs/src/blocks/components/Toolbar.tsx +++ b/code/addons/docs/src/blocks/components/Toolbar.tsx @@ -65,7 +65,11 @@ export const Toolbar: FC = ({ onReloadStory, ...rest }) => ( - + {isLoading ? ( [1, 2, 3].map((key) => ) diff --git a/code/addons/docs/src/blocks/controls/Boolean.tsx b/code/addons/docs/src/blocks/controls/Boolean.tsx index e302aa6f2b93..861d955d62bd 100644 --- a/code/addons/docs/src/blocks/controls/Boolean.tsx +++ b/code/addons/docs/src/blocks/controls/Boolean.tsx @@ -129,6 +129,7 @@ export const BooleanControl: FC = ({ onBlur, onFocus, argType, + required, }) => { const onSetFalse = useCallback(() => onChange(false), [onChange]); const readonly = !!argType?.table?.readonly; @@ -159,6 +160,7 @@ export const BooleanControl: FC = ({ checked={parsedValue} role="switch" disabled={readonly} + aria-required={required || undefined} {...{ name, onBlur, onFocus }} /> diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx index f837fdfa52ed..8702bb2072b1 100644 --- a/code/addons/docs/src/blocks/controls/Color.tsx +++ b/code/addons/docs/src/blocks/controls/Color.tsx @@ -376,6 +376,7 @@ export const ColorControl: FC = ({ maxPresetColors, startOpen = false, argType, + required, }) => { const debouncedOnChange = useCallback(debounce(onChange, 200), [onChange]); const { value, realValue, updateValue, color, colorSpace, cycleColorSpace } = useColorInput( @@ -399,6 +400,7 @@ export const ColorControl: FC = ({ onChange={(e: ChangeEvent) => updateValue(e.target.value)} onFocus={(e: FocusEvent) => e.target.select()} readOnly={readOnly} + aria-required={required || undefined} placeholder="Choose color..." /> = ({ onFocus, onBlur, argType, + required, }) => { const [valid, setValid] = useState(true); const dateRef = useRef(); @@ -138,6 +139,7 @@ export const DateControl: FC = ({ id={`${controlId}-date`} name={`${controlId}-date`} readOnly={readonly} + aria-required={required || undefined} onChange={onDateChange} {...{ onFocus, onBlur }} /> @@ -151,6 +153,7 @@ export const DateControl: FC = ({ ref={timeRef as RefObject} onChange={onTimeChange} readOnly={readonly} + aria-required={required || undefined} {...{ onFocus, onBlur }} /> {!valid ?
invalid
: null} diff --git a/code/addons/docs/src/blocks/controls/Files.tsx b/code/addons/docs/src/blocks/controls/Files.tsx index e795b71e1238..d0a6970dd060 100644 --- a/code/addons/docs/src/blocks/controls/Files.tsx +++ b/code/addons/docs/src/blocks/controls/Files.tsx @@ -45,6 +45,7 @@ export const FilesControl: FC = ({ accept = 'image/*', value, argType, + required, }) => { const inputElement = useRef(null); const readonly = argType?.control?.readOnly; @@ -81,6 +82,7 @@ export const FilesControl: FC = ({ disabled={readonly} onChange={handleFileChange} accept={accept} + aria-required={required || undefined} size="flex" /> diff --git a/code/addons/docs/src/blocks/controls/Number.tsx b/code/addons/docs/src/blocks/controls/Number.tsx index df5941d53c05..2705dcccd61e 100644 --- a/code/addons/docs/src/blocks/controls/Number.tsx +++ b/code/addons/docs/src/blocks/controls/Number.tsx @@ -37,6 +37,7 @@ export const NumberControl: FC = ({ onBlur, onFocus, argType, + required, }) => { const [inputValue, setInputValue] = useState(typeof value === 'number' ? value : ''); const [forceVisible, setForceVisible] = useState(false); @@ -125,6 +126,7 @@ export const NumberControl: FC = ({ value={inputValue} valid={parseError ? 'error' : undefined} autoFocus={forceVisible} + aria-required={required || undefined} readOnly={readonly} {...{ name, min, max, step, onFocus, onBlur }} /> diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index 3bc5e1c3580c..98dcedabf08c 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -170,6 +170,7 @@ export const ObjectControl: FC = ({ value, onChange, argType, + required, }) => { const data = useMemo(() => value && cloneDeep(value), [value]); const hasData = data !== null && data !== undefined; @@ -248,6 +249,7 @@ export const ObjectControl: FC = ({ autoFocus={forceVisible} valid={parseError ? 'error' : undefined} readOnly={readonly} + aria-required={required || undefined} /> ); diff --git a/code/addons/docs/src/blocks/controls/Range.tsx b/code/addons/docs/src/blocks/controls/Range.tsx index d9bb83101444..983999ac08fb 100644 --- a/code/addons/docs/src/blocks/controls/Range.tsx +++ b/code/addons/docs/src/blocks/controls/Range.tsx @@ -187,6 +187,7 @@ export const RangeControl: FC = ({ onBlur, onFocus, argType, + required, }) => { const handleChange = (event: ChangeEvent) => { onChange(parse(event.target.value)); @@ -209,6 +210,7 @@ export const RangeControl: FC = ({ type="range" disabled={readonly} onChange={handleChange} + aria-required={required || undefined} {...{ name, min, max, step, onFocus, onBlur }} value={value ?? min} /> diff --git a/code/addons/docs/src/blocks/controls/Text.tsx b/code/addons/docs/src/blocks/controls/Text.tsx index 642b35c9b353..6f6ec2280e5a 100644 --- a/code/addons/docs/src/blocks/controls/Text.tsx +++ b/code/addons/docs/src/blocks/controls/Text.tsx @@ -30,6 +30,7 @@ export const TextControl: FC = ({ onBlur, maxLength, argType, + required, }) => { const handleChange = (event: ChangeEvent) => { onChange(event.target.value); @@ -71,6 +72,7 @@ export const TextControl: FC = ({ size="flex" placeholder="Edit string..." autoFocus={forceVisible} + aria-required={required || undefined} valid={isValid ? undefined : 'error'} {...{ name, value: isValid ? value : '', onFocus, onBlur }} /> diff --git a/code/addons/docs/src/blocks/controls/options/Checkbox.tsx b/code/addons/docs/src/blocks/controls/options/Checkbox.tsx index 775ea915be1c..d4857dc1b04f 100644 --- a/code/addons/docs/src/blocks/controls/options/Checkbox.tsx +++ b/code/addons/docs/src/blocks/controls/options/Checkbox.tsx @@ -63,6 +63,7 @@ export const CheckboxControl: FC = ({ onChange, isInline, argType, + required, }) => { if (!options) { logger.warn(`Checkbox with no options: ${name}`); @@ -94,7 +95,10 @@ export const CheckboxControl: FC = ({ return ( - {name} + + {name} + {required ? ' (required)' : ''} + {Object.keys(options).map((key, index) => { const id = `${controlId}-${index}`; return ( diff --git a/code/addons/docs/src/blocks/controls/options/Radio.tsx b/code/addons/docs/src/blocks/controls/options/Radio.tsx index 838b9f19a9a0..41e3d9e8709a 100644 --- a/code/addons/docs/src/blocks/controls/options/Radio.tsx +++ b/code/addons/docs/src/blocks/controls/options/Radio.tsx @@ -63,6 +63,7 @@ export const RadioControl: FC = ({ onChange, isInline, argType, + required, }) => { if (!options) { logger.warn(`Radio with no options: ${name}`); @@ -75,7 +76,10 @@ export const RadioControl: FC = ({ return ( - {name} + + {name} + {required ? ' (required)' : ''} + {Object.keys(options).map((key, index) => { const id = `${controlId}-${index}`; return ( diff --git a/code/addons/docs/src/blocks/controls/options/Select.tsx b/code/addons/docs/src/blocks/controls/options/Select.tsx index 9d24d4b348e4..612678a0698c 100644 --- a/code/addons/docs/src/blocks/controls/options/Select.tsx +++ b/code/addons/docs/src/blocks/controls/options/Select.tsx @@ -112,6 +112,7 @@ const SingleSelect: FC = ({ options, onChange, argType, + required, }) => { const handleChange = (e: ChangeEvent) => { onChange(options[e.currentTarget.value]); @@ -127,7 +128,13 @@ const SingleSelect: FC = ({ - + @@ -149,6 +156,7 @@ const MultiSelect: FC = ({ options, onChange, argType, + required, }) => { const handleChange = (e: ChangeEvent) => { const selection = Array.from(e.currentTarget.options) @@ -172,6 +180,7 @@ const MultiSelect: FC = ({ multiple value={selection} onChange={handleChange} + aria-required={required || undefined} > {Object.keys(options).map((key) => (