Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
80 changes: 56 additions & 24 deletions src/components/JsonViewer/JsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
};

Expand All @@ -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 <CopyButtonWrapper data={props.data}>{renderInner}</CopyButtonWrapper>;
};
Expand Down
29 changes: 26 additions & 3 deletions src/components/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,22 @@ export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxStat

return (
<SearchWrap role="search">
{this.state.term && <ClearIcon onClick={this.clear}>×</ClearIcon>}
{this.state.term && (
<ClearIcon
onClick={this.clear}
aria-label="Clear search"
role="button"
tabIndex={0}
onKeyDown={(event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.clear();
}
}}
>
×
</ClearIcon>
)}
<SearchIcon />
<SearchInput
value={this.state.term}
Expand All @@ -159,7 +174,13 @@ export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxStat
wheelPropagation: false,
}}
>
<SearchResultsBox data-role="search:results">
<SearchResultsBox
data-role="search:results"
role="menu"
aria-label={`Search results: ${results.length} result${
results.length !== 1 ? 's' : ''
} found`}
>
{results.map((res, idx) => (
<MenuItem
item={Object.create(res.item, {
Expand All @@ -177,7 +198,9 @@ export class SearchBox extends React.PureComponent<SearchBoxProps, SearchBoxStat
</PerfectScrollbarWrap>
)}
{this.state.term && this.state.noResults ? (
<SearchResultsBox data-role="search:results">{l('noResultsFound')}</SearchResultsBox>
<SearchResultsBox data-role="search:results" aria-live="polite">
{l('noResultsFound')}
</SearchResultsBox>
) : null}
</SearchWrap>
);
Expand Down
7 changes: 7 additions & 0 deletions src/components/SelectOnClick/SelectOnClick.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ export class SelectOnClick extends React.PureComponent<React.PropsWithChildren<a
ref={el => (this.child = el)}
onClick={this.selectElement}
onFocus={this.selectElement}
onKeyDown={(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.selectElement();
}
}}
tabIndex={0}
role="button"
aria-label="Select all text"
>
{children}
</div>
Expand Down
31 changes: 28 additions & 3 deletions src/components/__tests__/JsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 8 additions & 4 deletions src/utils/jsonToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ function valueToHTML(value, maxExpandLevel: number) {
function arrayToHTML(json, maxExpandLevel: number) {
const collapsed = level > maxExpandLevel ? 'collapsed' : '';
let output = `<button class="collapser" aria-label="${
level > maxExpandLevel + 1 ? 'expand' : 'collapse'
}"></button>${punctuation('[')}<span class="ellipsis"></span><ul class="array collapsible">`;
collapsed ? 'expand array' : 'collapse array'
}" aria-expanded="${level <= maxExpandLevel}"></button>${punctuation(
'[',
)}<span class="ellipsis"></span><ul class="array collapsible">`;
let hasContents = false;
const length = json.length;
for (let i = 0; i < length; i++) {
Expand All @@ -99,8 +101,10 @@ function objectToHTML(json, maxExpandLevel: number) {
const keys = Object.keys(json);
const length = keys.length;
let output = `<button class="collapser" aria-label="${
level > maxExpandLevel + 1 ? 'expand' : 'collapse'
}"></button>${punctuation('{')}<span class="ellipsis"></span><ul class="obj collapsible">`;
collapsed ? 'expand object' : 'collapse object'
}" aria-expanded="${level <= maxExpandLevel}"></button>${punctuation(
'{',
)}<span class="ellipsis"></span><ul class="obj collapsible">`;
let hasContents = false;
for (let i = 0; i < length; i++) {
const key = keys[i];
Expand Down