diff --git a/README.md b/README.md index cb79ec89da..12aa57e087 100644 --- a/README.md +++ b/README.md @@ -130,15 +130,35 @@ Redoc uses the following [specification extensions](https://redocly.com/docs/api **The README for the `1.x` version is on the [v1.x](https://github.com/Redocly/redoc/tree/v1.x) branch.** -All the 2.x releases are deployed to npm and can be used with Redocly-cdn: +All 2.x releases are deployed to npm and can be used with Redocly CDN: - particular release, for example, `v2.0.0`: https://cdn.redoc.ly/redoc/v2.0.0/bundles/redoc.standalone.js - `latest` release: https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js -Additionally, all the 1.x releases are hosted on our GitHub Pages-based CDN **(deprecated)**: -- particular release, for example `v1.2.0`: https://rebilly.github.io/ReDoc/releases/v1.2.0/redoc.min.js -- `v1.x.x` release: https://rebilly.github.io/ReDoc/releases/v1.x.x/redoc.min.js -- `latest` release: https://rebilly.github.io/ReDoc/releases/latest/redoc.min.js - points to latest 1.x.x release since 2.x releases are not hosted on this CDN but on unpkg. +> **Note:** The 1.x CDN at `rebilly.github.io` is deprecated. Please use the latest 2.x releases from `cdn.redoc.ly`. +## Troubleshooting + +### Common Issues + +**Q: My API documentation is not displaying correctly** +- Ensure your OpenAPI spec is valid JSON or YAML and follows OpenAPI 3.0, 3.1, or Swagger 2.0 format +- Check browser console for errors using Developer Tools (F12) +- Verify that your `spec-url` points to a valid, accessible endpoint +- For local files, ensure they're being served via a web server (not `file://`) + +**Q: Search is not working** +- Confirm that `disableSearch` is not enabled in your Redoc configuration +- Check that your search term meets the configured `minCharacterLengthToInitSearch` value + +**Q: Performance is slow with large OpenAPI specs** +- Consider using the [Redocly CLI](https://redocly.com/docs/cli/) to split your spec into smaller, manageable files +- Check the [configuration documentation](docs/config.md) for options such as `disableSearch` and `jsonSamplesExpandLevel` + +**Q: How do I customize the appearance?** +- See the [configuration documentation](docs/config.md) for theming and customization options +- For advanced customization, you can use the [React component](https://redocly.com/docs/redoc/quickstart/react/) with custom CSS + +For additional help, please check the [official documentation](https://redocly.com/docs/redoc/) or open an issue on [GitHub](https://github.com/Redocly/redoc/issues). ## Development see [CONTRIBUTING.md](.github/CONTRIBUTING.md) diff --git a/src/components/JsonViewer/JsonViewer.tsx b/src/components/JsonViewer/JsonViewer.tsx index ba2738e85f..d41d8b9f03 100644 --- a/src/components/JsonViewer/JsonViewer.tsx +++ b/src/components/JsonViewer/JsonViewer.tsx @@ -60,7 +60,10 @@ const Json = (props: JsonProps) => { for (const collapsed of Array.prototype.slice.call(elements)) { const parentNode = collapsed.parentNode as Element; parentNode.classList.remove('collapsed'); - parentNode.querySelector('.collapser')!.setAttribute('aria-label', 'collapse'); + const collapser = parentNode.querySelector('.collapser'); + if (collapser) { + updateCollapserState(collapser, true); + } } }; @@ -72,42 +75,71 @@ const Json = (props: JsonProps) => { for (const expanded of elementsArr) { const parentNode = expanded.parentNode as Element; parentNode.classList.add('collapsed'); - parentNode.querySelector('.collapser')!.setAttribute('aria-label', 'expand'); - } - }; - - const collapseElement = (target: HTMLElement) => { - let collapsed; - if (target.className === 'collapser') { - collapsed = target.parentElement!.getElementsByClassName('collapsible')[0]; - if (collapsed.parentElement.classList.contains('collapsed')) { - collapsed.parentElement.classList.remove('collapsed'); - target.setAttribute('aria-label', 'collapse'); - } else { - collapsed.parentElement.classList.add('collapsed'); - target.setAttribute('aria-label', 'expand'); + const collapser = parentNode.querySelector('.collapser'); + if (collapser) { + updateCollapserState(collapser, false); } } }; - const clickListener = React.useCallback((event: MouseEvent) => { - collapseElement(event.target as HTMLElement); + const updateCollapserState = React.useCallback((target: Element, expanded: boolean) => { + const collapsible = target.parentElement?.getElementsByClassName('collapsible')[0]; + if (!collapsible) { + return; + } + const type = collapsible.classList.contains('array') ? 'array' : 'object'; + target.setAttribute('aria-expanded', String(expanded)); + target.setAttribute('aria-label', `${expanded ? 'collapse' : 'expand'} ${type}`); }, []); - const focusListener = React.useCallback((event: KeyboardEvent) => { - if (event.key === 'Enter') { + const collapseElement = React.useCallback( + (target: HTMLElement) => { + let collapsed; + if (target.classList?.contains('collapser')) { + collapsed = target.parentElement!.getElementsByClassName('collapsible')[0]; + if (!collapsed) { + return; + } + if (collapsed.parentElement.classList.contains('collapsed')) { + collapsed.parentElement.classList.remove('collapsed'); + updateCollapserState(target, true); + } else { + collapsed.parentElement.classList.add('collapsed'); + updateCollapserState(target, false); + } + } + }, + [updateCollapserState], + ); + + const clickListener = React.useCallback( + (event: MouseEvent) => { collapseElement(event.target as HTMLElement); - } - }, []); + }, + [collapseElement], + ); + + const keydownListener = React.useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter') { + const target = event.target as HTMLElement; + if (target.classList?.contains('collapser')) { + event.preventDefault(); + collapseElement(target); + } + } + }, + [collapseElement], + ); React.useEffect(() => { node?.addEventListener('click', clickListener); - node?.addEventListener('focus', focusListener); + node?.addEventListener('keydown', keydownListener); return () => { node?.removeEventListener('click', clickListener); - node?.removeEventListener('focus', focusListener); + node?.removeEventListener('keydown', keydownListener); }; - }, [clickListener, focusListener, node]); + }, [clickListener, keydownListener, node]); return {renderInner}; }; diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 8ffe255451..b64136da9a 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -143,7 +143,22 @@ export class SearchBox extends React.PureComponent - {this.state.term && ×} + {this.state.term && ( + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.clear(); + } + }} + > + × + + )} - + {results.map((res, idx) => ( )} {this.state.term && this.state.noResults ? ( - {l('noResultsFound')} + + {l('noResultsFound')} + ) : null} ); diff --git a/src/components/SelectOnClick/SelectOnClick.tsx b/src/components/SelectOnClick/SelectOnClick.tsx index 125fc30ba3..302d249604 100644 --- a/src/components/SelectOnClick/SelectOnClick.tsx +++ b/src/components/SelectOnClick/SelectOnClick.tsx @@ -15,8 +15,15 @@ export class SelectOnClick extends React.PureComponent (this.child = el)} onClick={this.selectElement} onFocus={this.selectElement} + onKeyDown={(event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.selectElement(); + } + }} tabIndex={0} role="button" + aria-label="Select all text" > {children} diff --git a/src/components/__tests__/JsonViewer.tsx b/src/components/__tests__/JsonViewer.tsx index ae3ba9de05..e15ce0937f 100644 --- a/src/components/__tests__/JsonViewer.tsx +++ b/src/components/__tests__/JsonViewer.tsx @@ -25,14 +25,39 @@ describe('Components', () => { }); test('should collapse/uncollapse', () => { - expect(component.html()).not.toContain('class="hoverable"'); // all are collapsed by default + expect(component.html()).not.toContain('class="hoverable"'); // nested values are collapsed by default + expect(component.html()).toContain('aria-expanded="false"'); + const expandAll = component.find('div > button[children=" Expand all "]'); expandAll.simulate('click'); - expect(component.html()).toContain('class="hoverable"'); // all are collapsed + expect(component.html()).toContain('class="hoverable"'); // nested values are expanded + expect(component.html()).toContain('aria-expanded="true"'); const collapseAll = component.find('div > button[children=" Collapse all "]'); collapseAll.simulate('click'); - expect(component.html()).not.toContain('class="hoverable"'); // all are collapsed + expect(component.html()).not.toContain('class="hoverable"'); // nested values are collapsed + expect(component.html()).toContain('aria-expanded="false"'); + }); + + test('should toggle collapsible items with Enter', () => { + const collapser = component + .getDOMNode() + .querySelector('button.collapser[aria-label="expand object"]'); + + expect(collapser).not.toBeNull(); + + act(() => { + collapser!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }), + ); + }); + component.update(); + + expect(component.html()).toContain('aria-label="collapse object"'); + expect(component.html()).toContain('aria-expanded="true"'); }); test('should collapse/uncollapse', () => { diff --git a/src/utils/jsonToHtml.ts b/src/utils/jsonToHtml.ts index 3db6cd9159..bd9952ca40 100644 --- a/src/utils/jsonToHtml.ts +++ b/src/utils/jsonToHtml.ts @@ -74,8 +74,10 @@ function valueToHTML(value, maxExpandLevel: number) { function arrayToHTML(json, maxExpandLevel: number) { const collapsed = level > maxExpandLevel ? 'collapsed' : ''; let output = `${punctuation('[')}
    `; + collapsed ? 'expand array' : 'collapse array' + }" aria-expanded="${level <= maxExpandLevel}">${punctuation( + '[', + )}
      `; let hasContents = false; const length = json.length; for (let i = 0; i < length; i++) { @@ -99,8 +101,10 @@ function objectToHTML(json, maxExpandLevel: number) { const keys = Object.keys(json); const length = keys.length; let output = `${punctuation('{')}
        `; + collapsed ? 'expand object' : 'collapse object' + }" aria-expanded="${level <= maxExpandLevel}">${punctuation( + '{', + )}
          `; let hasContents = false; for (let i = 0; i < length; i++) { const key = keys[i];