-
+
+
+
+ {variant === 'prompt' ? (
+
-
- {variant === 'prompt' ? (
-
- ) : (
- children
- )}
-
-
- ,
+ ) : (
+ children
+ )}
+
+ ,
(portal && 'current' in portal ? portal.current : portal) ||
// Dependent on "isBrowser" check above:
// eslint-disable-next-line ssr-friendly/no-dom-globals-in-react-fc
diff --git a/packages/react/src/components/RadioGroup/RadioGroup.test.tsx b/packages/react/src/components/RadioGroup/RadioGroup.test.tsx
index 5cdc1db6b..e46bf28f7 100644
--- a/packages/react/src/components/RadioGroup/RadioGroup.test.tsx
+++ b/packages/react/src/components/RadioGroup/RadioGroup.test.tsx
@@ -1,5 +1,5 @@
import React, { createRef } from 'react';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { spy } from 'sinon';
import { axe } from 'jest-axe';
@@ -167,7 +167,7 @@ test('should handle focus correctly', async () => {
expect(onFocus.calledOnce).toBeTruthy();
});
-test('should handle blur correctly', () => {
+test('should handle blur correctly', async () => {
const onBlur = spy();
const [input] = renderRadioGroup({ onBlur });
const radioIcon = input.parentElement!.querySelector(
@@ -176,9 +176,11 @@ test('should handle blur correctly', () => {
expect(radioIcon).not.toHaveClass('.Radio__overlay--focused');
expect(onBlur.notCalled).toBeTruthy();
- input.focus();
- input.blur();
- expect(input).not.toHaveFocus();
+ await waitFor(() => {
+ input.focus();
+ input.blur();
+ expect(input).not.toHaveFocus();
+ });
expect(radioIcon).not.toHaveClass('Radio__overlay--focused');
expect(onBlur.calledOnce).toBeTruthy();
});
diff --git a/packages/react/src/components/Scrim/index.test.tsx b/packages/react/src/components/Scrim/index.test.tsx
index 4467b3a1e..8b2ddb122 100644
--- a/packages/react/src/components/Scrim/index.test.tsx
+++ b/packages/react/src/components/Scrim/index.test.tsx
@@ -52,5 +52,8 @@ test('should return null when given a falsey show prop', () => {
test('returns no axe violations', async () => {
const { container } = render(
);
+ await waitFor(() => {
+ expect(container).toBeInTheDocument();
+ });
expect(await axe(container)).toHaveNoViolations();
});
diff --git a/packages/react/src/components/SearchField/SearchField.test.tsx b/packages/react/src/components/SearchField/SearchField.test.tsx
index 951b2c568..db19edb34 100644
--- a/packages/react/src/components/SearchField/SearchField.test.tsx
+++ b/packages/react/src/components/SearchField/SearchField.test.tsx
@@ -1,5 +1,9 @@
import React, { createRef, ComponentProps, useState } from 'react';
-import { render as testingRender, screen } from '@testing-library/react';
+import {
+ render as testingRender,
+ screen,
+ waitFor
+} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { spy } from 'sinon';
import { axe } from 'jest-axe';
@@ -193,13 +197,17 @@ test('SearchField should render trailingChildren as a component', async () => {
)
});
+ waitFor(() => {
+ expect(input).toBeInTheDocument();
+ });
+
expect(
input.parentElement!.contains(
screen.getByRole('button', {
name: 'go to previous match'
})
)
- ).toBeTruthy;
+ ).toBeTruthy();
});
test('SearchField should render trailingChildren as a string', async () => {
@@ -207,8 +215,9 @@ test('SearchField should render trailingChildren as a string', async () => {
trailingChildren: 'I am a string'
});
- expect(input.parentElement!.contains(screen.getByText('I am a string')))
- .toBeTruthy;
+ expect(
+ input.parentElement!.contains(screen.getByText('I am a string'))
+ ).toBeTruthy();
});
test('SearchField should render trailingChildren as an element', async () => {
@@ -222,7 +231,7 @@ test('SearchField should render trailingChildren as an element', async () => {
name: 'I am a button'
})
)
- ).toBeTruthy;
+ ).toBeTruthy();
});
test('SearchField should have no axe violations with default params', async () => {
diff --git a/packages/react/src/components/SideBar/SideBarItem.tsx b/packages/react/src/components/SideBar/SideBarItem.tsx
index 87fa4e8a1..318b467bc 100644
--- a/packages/react/src/components/SideBar/SideBarItem.tsx
+++ b/packages/react/src/components/SideBar/SideBarItem.tsx
@@ -9,7 +9,7 @@ export interface SideBarItemProps extends React.HTMLAttributes
{
const SideBarItem: React.ComponentType<
React.PropsWithChildren
-> = ({ children, autoClickLink, ...other }: SideBarItemProps) => {
+> = ({ children, autoClickLink = true, ...other }: SideBarItemProps) => {
const onClick = (e: React.MouseEvent) => {
if (!autoClickLink) {
return;
@@ -27,8 +27,5 @@ const SideBarItem: React.ComponentType<
};
SideBarItem.displayName = 'SideBarItem';
-SideBarItem.defaultProps = {
- autoClickLink: true
-};
export default SideBarItem;
diff --git a/packages/react/src/components/Table/Table.tsx b/packages/react/src/components/Table/Table.tsx
index 6ebf04e5f..785fa1fc0 100644
--- a/packages/react/src/components/Table/Table.tsx
+++ b/packages/react/src/components/Table/Table.tsx
@@ -5,6 +5,7 @@ import { TableProvider } from './TableContext';
export type Column = {
align: ColumnAlignment;
width?: ColumnWidth;
+ maxWidth?: ColumnWidth;
};
export type ColumnAlignment = 'start' | 'center' | 'end';
export type ColumnWidth =
@@ -31,6 +32,19 @@ type TableGridProps = {
type TableProps = (TableBaseProps | Partial) &
React.TableHTMLAttributes;
+function parseColumnWidth(width?: ColumnWidth): string {
+ const number = Number(width);
+ if (!isNaN(number)) {
+ return `${number}px`;
+ }
+
+ if (!width) {
+ return 'auto';
+ }
+
+ return width;
+}
+
const Table = React.forwardRef(
(
{
@@ -65,7 +79,15 @@ const Table = React.forwardRef(
}
return columns
- .map(({ width }) => width || 'auto')
+ .map(({ width, maxWidth }) => {
+ if (maxWidth) {
+ return `minmax(${parseColumnWidth(width)}, ${parseColumnWidth(
+ maxWidth
+ )})`;
+ }
+
+ return parseColumnWidth(width);
+ })
.join(' ');
}, [layout, columns]);
diff --git a/packages/react/src/components/Table/index.test.tsx b/packages/react/src/components/Table/index.test.tsx
index 267da3f11..20c02942c 100644
--- a/packages/react/src/components/Table/index.test.tsx
+++ b/packages/react/src/components/Table/index.test.tsx
@@ -346,6 +346,22 @@ test('should support column definitions with grid layout', () => {
expect(tableCells[1]).toHaveStyle('text-align: end');
});
+test('should support column definitions with maxWidth with grid layout', () => {
+ renderDefaultTable({
+ layout: 'grid',
+ columns: [
+ { width: '1fr', align: 'start' },
+ { width: 'min-content', align: 'end' },
+ { width: 'min-content', maxWidth: '789', align: 'end' },
+ { maxWidth: '789', align: 'end' }
+ ]
+ });
+ const table = screen.getByRole('table');
+ expect(table).toHaveStyle(
+ '--table-grid-template-columns: 1fr min-content minmax(min-content, 789px) minmax(auto, 789px)'
+ );
+});
+
test('should apply colspan styles to cells in grid layout', () => {
render(
diff --git a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx
index c79d89161..ff306830c 100644
--- a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx
+++ b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx
@@ -9,7 +9,7 @@ const sandbox = createSandbox();
beforeEach(() => {
global.ResizeObserver = global.ResizeObserver || (() => null);
sandbox.stub(global, 'ResizeObserver').callsFake((callback) => {
- callback();
+ callback([]);
return {
observe: sandbox.stub(),
disconnect: sandbox.stub()
@@ -52,7 +52,7 @@ test('should display tooltip with overflow', async () => {
act(() => {
button.focus();
});
- expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
});
test('should not display tooltip with no multiline overflow', () => {
@@ -68,7 +68,7 @@ test('should not display tooltip with no multiline overflow', () => {
expect(screen.getByTestId('text-ellipsis')).not.toHaveAttribute('tabindex');
});
-test('should display tooltip with multiline overflow', () => {
+test('should display tooltip with multiline overflow', async () => {
sandbox.stub(global.HTMLDivElement.prototype, 'clientHeight').value(100);
sandbox.stub(global.HTMLDivElement.prototype, 'scrollHeight').value(200);
render(Hello World );
@@ -81,7 +81,7 @@ test('should display tooltip with multiline overflow', () => {
act(() => {
button.focus();
});
- expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
});
test('should support className prop', () => {
diff --git a/packages/react/src/components/Toast/toast.test.tsx b/packages/react/src/components/Toast/toast.test.tsx
index abc905798..2cf1c5ee3 100644
--- a/packages/react/src/components/Toast/toast.test.tsx
+++ b/packages/react/src/components/Toast/toast.test.tsx
@@ -98,13 +98,15 @@ Object.entries(toastTypes).forEach(([key, value]) => {
);
- // wait for animation tiemouts / async setState calls
- await setTimeout(undefined, () => {
- expect(screen.getByTestId('toast')).toHaveClass(
- 'Toast',
- 'Toast--info',
- 'is--hidden'
- );
+ // wait for animation timeouts / async setState calls
+ await waitFor(async () => {
+ await setTimeout(undefined, () => {
+ expect(screen.getByTestId('toast')).toHaveClass(
+ 'Toast',
+ 'Toast--info',
+ 'is--hidden'
+ );
+ });
});
});
@@ -241,6 +243,9 @@ test('non-dismissible toast has no accessibility issues', async () => {
);
+ waitFor(() => {
+ expect(container).toBeInTheDocument();
+ });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
diff --git a/packages/react/src/components/Tooltip/Tooltip.test.tsx b/packages/react/src/components/Tooltip/Tooltip.test.tsx
index 2c56f42bb..2236f7837 100644
--- a/packages/react/src/components/Tooltip/Tooltip.test.tsx
+++ b/packages/react/src/components/Tooltip/Tooltip.test.tsx
@@ -1,11 +1,11 @@
import React, { createRef } from 'react';
-import { setTimeout } from 'timers/promises';
import {
render,
screen,
fireEvent,
- getByRole,
- waitFor
+ findByRole,
+ waitFor,
+ act
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { spy } from 'sinon';
@@ -40,48 +40,58 @@ const renderTooltip = ({
);
};
-test('should render tooltip', () => {
+test('should render tooltip', async () => {
renderTooltip();
expect(
- screen.getByRole('tooltip', { name: 'Hello Tooltip' })
+ await screen.findByRole('tooltip', { name: 'Hello Tooltip' })
).toBeInTheDocument();
expect(
- screen.getByRole('button', { name: 'button' })
+ await screen.findByRole('button', { name: 'button' })
).toHaveAccessibleDescription('Hello Tooltip');
});
-test('should auto generate ids', () => {
+test('should auto generate ids', async () => {
renderTooltip();
- const button = screen.getByRole('button');
- const tooltip = screen.getByRole('tooltip');
+ const button = await screen.findByRole('button');
+ const tooltip = await screen.findByRole('tooltip');
expect(tooltip.getAttribute('id')).toBeTruthy();
expect(tooltip.getAttribute('id')).toEqual(
button.getAttribute('aria-describedby')
);
});
-test('should not overwrite user provided ids', () => {
+test('should not overwrite user provided ids', async () => {
const buttonProps = { [`aria-describedby`]: 'foo tooltipid' };
const tooltipProps = { id: 'tooltipid' };
renderTooltip({ buttonProps, tooltipProps });
- expect(screen.getByRole('tooltip').getAttribute('id')).toEqual('tooltipid');
- expect(screen.getByRole('button').getAttribute('aria-describedby')).toEqual(
- 'foo tooltipid'
+ expect((await screen.findByRole('tooltip')).getAttribute('id')).toEqual(
+ 'tooltipid'
);
+ expect(
+ (await screen.findByRole('button')).getAttribute('aria-describedby')
+ ).toEqual('foo tooltipid');
});
test('should show tooltip on target element focus', async () => {
renderTooltip({ tooltipProps: { defaultShow: false } });
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
- await fireEvent.focusIn(screen.getByRole('button'));
- expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ await act(async () => {
+ await fireEvent.focusIn(screen.getByRole('button'));
+ });
+ waitFor(() => {
+ expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ });
});
test('should show tooltip on target element hover', async () => {
renderTooltip({ tooltipProps: { defaultShow: false } });
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
- await fireEvent.mouseEnter(screen.getByRole('button'));
- expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ await act(async () => {
+ await fireEvent.mouseEnter(screen.getByRole('button'));
+ });
+ waitFor(() => {
+ expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ });
});
test('should hide tooltip on target element blur', async () => {
@@ -111,9 +121,9 @@ test('should fire the "cauldron:tooltip:show" custom event when tooltip is shown
const show = spy();
renderTooltip();
- const button = screen.getByRole('button');
+ const button = await screen.findByRole('button');
button.addEventListener('cauldron:tooltip:show', show);
- await fireEvent.focusIn(screen.getByRole('button'));
+ await fireEvent.focusIn(button);
await waitFor(() => {
expect(show.calledOnce).toBeTruthy();
@@ -124,28 +134,29 @@ test('should fire the "cauldron:tooltip:hide" custom event when tooltip is hidde
const hide = spy();
renderTooltip();
- const button = screen.getByRole('button');
+ const button = await screen.findByRole('button');
button.addEventListener('cauldron:tooltip:hide', hide);
- await fireEvent.focusOut(screen.getByRole('button'));
+ await fireEvent.focusOut(button);
await waitFor(() => {
expect(hide.calledOnce).toBeTruthy();
});
});
-test('should support className prop', () => {
+test('should support className prop', async () => {
renderTooltip({ tooltipProps: { className: 'bananas' } });
- expect(screen.getByRole('tooltip')).toHaveClass('Tooltip', 'bananas');
+ expect(await screen.findByRole('tooltip')).toHaveClass('Tooltip', 'bananas');
});
-test('should support portal prop', () => {
+test('should support portal prop', async () => {
const portal = document.createElement('div');
renderTooltip({ tooltipProps: { portal } });
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
- expect(getByRole(portal, 'tooltip')).toBeTruthy();
+ const tooltipInPortal = await findByRole(portal, 'tooltip');
+ expect(tooltipInPortal).toBeTruthy();
});
-test('should support show prop', () => {
+test('should support show prop', async () => {
const ShowTooltip = ({ show }: { show?: boolean }) => {
const ref = createRef();
return (
@@ -161,23 +172,26 @@ test('should support show prop', () => {
};
const { rerender } = render( );
- expect(screen.queryByRole('tooltip')).toBeInTheDocument();
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
rerender( );
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
test('should support association prop', async () => {
renderTooltip({ tooltipProps: { association: 'aria-labelledby' } });
- expect(screen.queryByRole('button')).toHaveAccessibleName('Hello Tooltip');
+ expect(await screen.findByRole('button')).toHaveAccessibleName(
+ 'Hello Tooltip'
+ );
});
-test('should not add association when association is set to "none"', () => {
+test('should not add association when association is set to "none"', async () => {
renderTooltip({ tooltipProps: { association: 'none' } });
- expect(screen.queryByRole('button')).not.toHaveProperty('aria-describedby');
- expect(screen.queryByRole('button')).not.toHaveProperty('aria-labelledby');
+ const button = await screen.findByRole('button');
+ expect(button).not.toHaveProperty('aria-describedby');
+ expect(button).not.toHaveProperty('aria-labelledby');
});
-test('should clean up association when tooltip is no longer rendered', () => {
+test('should clean up association when tooltip is no longer rendered', async () => {
const ShowTooltip = ({ show = true }: { show?: boolean }) => {
const ref = createRef();
return (
@@ -192,23 +206,29 @@ test('should clean up association when tooltip is no longer rendered', () => {
);
};
const { rerender } = render( );
- expect(screen.getByRole('button').getAttribute('aria-describedby')).toContain(
- 'tooltip'
- );
+ expect(
+ (await screen.findByRole('button')).getAttribute('aria-describedby')
+ ).toContain('tooltip');
rerender( );
expect(
- screen.getByRole('button').getAttribute('aria-describedby')
+ (await screen.findByRole('button')).getAttribute('aria-describedby')
).not.toContain('tooltip');
});
test('should return no axe violations with default variant', async () => {
const { container } = renderTooltip();
+ waitFor(() => {
+ expect(container).toBeInTheDocument();
+ });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('should return no axe violations with info variant', async () => {
const { container } = renderTooltip({ tooltipProps: { variant: 'info' } });
+ waitFor(() => {
+ expect(container).toBeInTheDocument();
+ });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
@@ -223,6 +243,9 @@ test('should return no axe violations with big variant', async () => {
const { container } = renderTooltip({
tooltipProps: { variant: 'big', children }
});
+ waitFor(() => {
+ expect(container).toBeInTheDocument();
+ });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
diff --git a/packages/react/src/components/Tooltip/index.tsx b/packages/react/src/components/Tooltip/index.tsx
index 72a1b638e..5815d2623 100644
--- a/packages/react/src/components/Tooltip/index.tsx
+++ b/packages/react/src/components/Tooltip/index.tsx
@@ -2,11 +2,10 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import classnames from 'classnames';
import { createPortal } from 'react-dom';
import { useId } from 'react-id-generator';
-import { Placement } from '@popperjs/core';
-import { usePopper } from 'react-popper';
+import AnchoredOverlay from '../AnchoredOverlay';
import { isBrowser } from '../../utils/is-browser';
import { addIdRef, hasIdRef, removeIdRef } from '../../utils/idRefs';
-import useEscapeKey from '../../utils/useEscapeKey';
+import resolveElement from '../../utils/resolveElement';
const TIP_HIDE_DELAY = 100;
@@ -18,7 +17,7 @@ export interface TooltipProps extends React.HTMLAttributes {
association?: 'aria-labelledby' | 'aria-describedby' | 'none';
show?: boolean | undefined;
defaultShow?: boolean;
- placement?: Placement;
+ placement?: React.ComponentProps['placement'];
portal?: React.RefObject | HTMLElement;
hideElementOnHidden?: boolean;
}
@@ -57,52 +56,27 @@ export default function Tooltip({
const [id] = propId ? [propId] : useId(1, 'tooltip');
const hideTimeoutRef = useRef | null>(null);
const [showTooltip, setShowTooltip] = useState(!!showProp || defaultShow);
- const [targetElement, setTargetElement] = useState(null);
const [tooltipElement, setTooltipElement] = useState(
null
);
- const [arrowElement, setArrowElement] = useState(null);
+ const [placement, setPlacement] = useState(initialPlacement);
const hasAriaAssociation = association !== 'none';
- const { styles, attributes, update } = usePopper(
- targetElement,
- tooltipElement,
- {
- placement: initialPlacement,
- modifiers: [
- { name: 'preventOverflow', options: { padding: 8 } },
- {
- name: 'flip',
- options: { fallbackPlacements: ['left', 'right', 'top', 'bottom'] }
- },
- { name: 'offset', options: { offset: [0, 8] } },
- { name: 'arrow', options: { padding: 5, element: arrowElement } }
- ]
- }
- );
-
// Show the tooltip
const show: EventListener = useCallback(async () => {
+ const targetElement = resolveElement(target);
// Clear the hide timeout if there was one pending
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
- // Make sure we update the tooltip position when showing
- // in case the target's position changed without popper knowing
- if (update) {
- await update();
- }
setShowTooltip(true);
fireCustomEvent(true, targetElement);
- }, [
- targetElement,
- // update starts off as null
- update
- ]);
+ }, [target]);
// Hide the tooltip
const hide: EventListener = useCallback(() => {
+ const targetElement = resolveElement(target);
if (!hideTimeoutRef.current) {
hideTimeoutRef.current = setTimeout(() => {
hideTimeoutRef.current = null;
@@ -114,13 +88,6 @@ export default function Tooltip({
return () => {
clearTimeout(hideTimeoutRef.current as unknown as number);
};
- }, [targetElement]);
-
- // Keep targetElement in sync with target prop
- useEffect(() => {
- const targetElement =
- target && 'current' in target ? target.current : target;
- setTargetElement(targetElement);
}, [target]);
useEffect(() => {
@@ -129,27 +96,9 @@ export default function Tooltip({
}
}, [showProp]);
- // Get popper placement
- const placement: Placement =
- (attributes.popper &&
- (attributes.popper['data-popper-placement'] as Placement)) ||
- initialPlacement;
-
- // Only listen to key ups when the tooltip is visible
- useEscapeKey(
- {
- callback: (event) => {
- event.preventDefault();
- setShowTooltip(false);
- },
- capture: true,
- active: showTooltip && typeof showProp !== 'boolean'
- },
- [setShowTooltip]
- );
-
// Handle hover and focus events for the targetElement
useEffect(() => {
+ const targetElement = resolveElement(target);
if (typeof showProp !== 'boolean') {
targetElement?.addEventListener('mouseenter', show);
targetElement?.addEventListener('mouseleave', hide);
@@ -163,7 +112,7 @@ export default function Tooltip({
targetElement?.removeEventListener('focusin', show);
targetElement?.removeEventListener('focusout', hide);
};
- }, [targetElement, show, hide, showProp]);
+ }, [target, show, hide, showProp]);
// Handle hover events for the tooltipElement
useEffect(() => {
@@ -180,6 +129,7 @@ export default function Tooltip({
// Keep the target's id in sync
useEffect(() => {
+ const targetElement = resolveElement(target);
if (hasAriaAssociation) {
const idRefs = targetElement?.getAttribute(association);
if (!hasIdRef(idRefs, id)) {
@@ -193,14 +143,19 @@ export default function Tooltip({
targetElement.setAttribute(association, removeIdRef(idRefs, id));
}
};
- }, [targetElement, id, association]);
+ }, [target, id, association]);
return (
<>
{(showTooltip || hideElementOnHidden) && isBrowser()
? createPortal(
-
- {variant !== 'big' && (
-
- )}
+ {variant !== 'big' &&
}
{children}
-
,
+ ,
(portal && 'current' in portal ? portal.current : portal) ||
// eslint-disable-next-line ssr-friendly/no-dom-globals-in-react-fc
document?.body
diff --git a/packages/react/src/components/TooltipTabstop/index.test.tsx b/packages/react/src/components/TooltipTabstop/index.test.tsx
index 1cc43b564..fee26b07a 100644
--- a/packages/react/src/components/TooltipTabstop/index.test.tsx
+++ b/packages/react/src/components/TooltipTabstop/index.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, act } from '@testing-library/react';
import TooltipTabstop from './';
import axe from '../../axe';
@@ -12,9 +12,13 @@ test('should display tooltip on hover', async () => {
render(Hello );
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
- await fireEvent.focusIn(screen.getByRole('button'));
- expect(screen.queryByRole('tooltip')).toBeInTheDocument();
- expect(screen.getByRole('button')).toHaveAccessibleDescription('World');
+ act(() => {
+ fireEvent.focusIn(screen.getByRole('button'));
+ });
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+ expect(await screen.findByRole('button')).toHaveAccessibleDescription(
+ 'World'
+ );
});
test('should return no axe violations', async () => {
diff --git a/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx b/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx
index ec246b1c6..102aa8dd5 100644
--- a/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx
+++ b/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.test.tsx
@@ -8,7 +8,7 @@ import {
} from './';
import SkipLink from '../SkipLink';
import axe from '../../axe';
-import { render, screen, within } from '@testing-library/react';
+import { render, screen, within, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const sandbox = createSandbox();
@@ -36,7 +36,7 @@ afterEach(() => {
sandbox.restore();
});
-test('should render TwoColumnPanel', () => {
+test('should render TwoColumnPanel', async () => {
render(
{
name: /test-hide-panel/i
});
- expect(columnRightToggleButton).toBeInTheDocument();
- expect(columnRightToggleButton).toHaveAttribute('aria-expanded', 'true');
- expect(columnRightToggleButton).toHaveAttribute(
- 'aria-controls',
- 'column-left-id'
- );
+ waitFor(() => {
+ expect(columnRightToggleButton).toBeInTheDocument();
+ expect(columnRightToggleButton).toHaveAttribute('aria-expanded', 'true');
+ expect(columnRightToggleButton).toHaveAttribute(
+ 'aria-controls',
+ 'column-left-id'
+ );
+ });
});
-test('should render collapsed TwoColumnPanel', () => {
+test('should render collapsed TwoColumnPanel', async () => {
matchMediaStub.withArgs('(max-width: 45rem)').returns({
matches: true,
addEventListener: noop,
@@ -148,7 +150,9 @@ test('should render collapsed TwoColumnPanel', () => {
name: /test-hide-panel/i
});
- expect(columnRightToggleButton).not.toBeInTheDocument();
+ waitFor(() => {
+ expect(columnRightToggleButton).not.toBeInTheDocument();
+ });
});
test('should collapse panel when prefers-reduced-motion: reduce is set', async () => {
@@ -199,10 +203,12 @@ test('should collapse panel when prefers-reduced-motion: reduce is set', async (
})
);
- expect(screen.queryByTestId('column-left')).not.toBeInTheDocument();
+ waitFor(() => {
+ expect(screen.queryByTestId('column-left')).not.toBeInTheDocument();
+ });
});
-test('should render configurable collapsed TwoColumnPanel', () => {
+test('should render configurable collapsed TwoColumnPanel', async () => {
matchMediaStub.withArgs('(max-width: 999rem)').returns({
matches: true,
addEventListener: noop,
@@ -250,7 +256,9 @@ test('should render configurable collapsed TwoColumnPanel', () => {
expect(screen.queryByTestId('column-left')).not.toBeInTheDocument();
const columnRight = screen.getByTestId('column-right');
- expect(columnRight).toBeInTheDocument();
+ waitFor(() => {
+ expect(columnRight).toBeInTheDocument();
+ });
expect(
within(columnRight).getByRole('button', {
@@ -259,7 +267,7 @@ test('should render configurable collapsed TwoColumnPanel', () => {
).toHaveAttribute('aria-expanded', 'false');
});
-test('should accept a skip link', () => {
+test('should accept a skip link', async () => {
render(
{
);
- screen.getByRole('link', { name: /Test skip to Test content/i });
+ expect(
+ await screen.findByRole('link', { name: /Test skip to Test content/i })
+ ).toBeInTheDocument();
});
test('should return no axe violations', async () => {
@@ -344,7 +354,9 @@ test('should return no axe violations', async () => {
);
-
+ await waitFor(() => {
+ expect(container).toBeInTheDocument();
+ });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
diff --git a/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.tsx b/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.tsx
index 82e07c11e..87b3d34f8 100644
--- a/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.tsx
+++ b/packages/react/src/components/TwoColumnPanel/TwoColumnPanel.tsx
@@ -7,7 +7,6 @@ import React, {
useLayoutEffect
} from 'react';
import { useId } from 'react-id-generator';
-import FocusTrap from 'focus-trap-react';
import Icon from '../Icon';
import Tooltip from '../Tooltip';
import ClickOutsideListener from '../ClickOutsideListener';
@@ -16,6 +15,7 @@ import ColumnRight from './ColumnRight';
import classnames from 'classnames';
import SkipLink from '../SkipLink';
import useEscapeKey from '../../utils/useEscapeKey';
+import useFocusTrap from '../../utils/useFocusTrap';
interface TwoColumnPanelProps extends React.HTMLAttributes {
initialCollapsed?: boolean;
@@ -228,6 +228,10 @@ const TwoColumnPanel = forwardRef(
}
};
+ useFocusTrap(columnLeftRef, {
+ disabled: !showPanel || !isFocusTrap
+ });
+
return (
(
ref={ref}
>
<>
-
void,
@@ -43,6 +42,10 @@ const renderProvider = (themeProviderProps = {}) => {
);
};
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
test('it exposes the current theme (defaulting to light)', () => {
renderProvider();
expect(theme).toBe('light');
@@ -95,6 +98,10 @@ test('disconnects mutation observer when unmounted', () => {
});
test('throw an exception, without provider', () => {
+ // react will log an error when the context throws, but we don't want it
+ // to show up in our test output
+ jest.spyOn(console, 'error').mockImplementation(jest.fn());
+
const Component = () => {
const { toggleTheme } = useThemeContext();
toggleTheme();
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 4f0cfb56e..6ffefb785 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -132,6 +132,7 @@ export { default as TextEllipsis } from './components/TextEllipsis';
export { default as CopyButton } from './components/CopyButton';
export { default as Drawer } from './components/Drawer';
export { default as BottomSheet } from './components/BottomSheet';
+export { default as AnchoredOverlay } from './components/AnchoredOverlay';
/**
* Helpers / Utils
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index e187154d5..d59eaa635 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -12,3 +12,8 @@ export namespace Cauldron {
* Explicit equivalent of Exclude
*/
export type ContentNode = string | number | ReactPortal | ReactElement;
+
+export type ElementOrRef =
+ | E
+ | React.RefObject
+ | React.MutableRefObject;
diff --git a/packages/react/src/utils/resolveElement.ts b/packages/react/src/utils/resolveElement.ts
index 9c7fbc2e0..a294083bd 100644
--- a/packages/react/src/utils/resolveElement.ts
+++ b/packages/react/src/utils/resolveElement.ts
@@ -1,11 +1,11 @@
-import type { RefObject, MutableRefObject } from 'react';
+import type { ElementOrRef } from '../types';
/**
* When an element can be passed as a value that is either an element or an
* elementRef, this will resolve the property down to the resulting element
*/
export default function resolveElement(
- elementOrRef: T | RefObject | MutableRefObject | undefined
+ elementOrRef: ElementOrRef | undefined
): T | null {
if (elementOrRef instanceof Element) {
return elementOrRef;
diff --git a/packages/react/src/utils/useFocusTrap.test.tsx b/packages/react/src/utils/useFocusTrap.test.tsx
new file mode 100644
index 000000000..928a50334
--- /dev/null
+++ b/packages/react/src/utils/useFocusTrap.test.tsx
@@ -0,0 +1,225 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import useFocusTrap from './useFocusTrap';
+
+const ComponentOutsideFocusTrap = ({
+ children
+}: {
+ children: React.ReactNode;
+}) => {
+ return (
+ <>
+ outside before
+ {children}
+ outside after
+ >
+ );
+};
+
+const ComponentWithFocusableElements = ({
+ disableFocusTrap = false
+}: {
+ disableFocusTrap?: boolean;
+}) => {
+ const containerRef = React.useRef(null);
+
+ useFocusTrap(containerRef, {
+ returnFocus: true,
+ disabled: disableFocusTrap
+ });
+
+ return (
+
+ first
+ bananas
+ last
+
+ );
+};
+
+const ComponentWithInitialFocus = () => {
+ const containerRef = React.useRef(null);
+ const initialFocusRef = React.useRef(null);
+
+ useFocusTrap(containerRef, {
+ initialFocusElement: initialFocusRef
+ });
+
+ return (
+
+ first
+ initial focus
+ last
+
+ );
+};
+
+const ComponentWithReturnElement = ({
+ disableFocusTrap = false
+}: {
+ disableFocusTrap?: boolean;
+}) => {
+ const containerRef = React.useRef(null);
+ const returnFocusElement = React.useRef(null);
+
+ useFocusTrap(containerRef, {
+ returnFocus: true,
+ returnFocusElement,
+ disabled: disableFocusTrap
+ });
+
+ return (
+ <>
+ before
+
+ first
+ bananas
+ last
+
+ return focus element
+ >
+ );
+};
+
+describe('useFocusTrap', () => {
+ test('should trap focus within container', async () => {
+ render(
+
+
+
+ );
+
+ const buttons = screen.getAllByRole('button');
+
+ // First element should be focused initially
+ expect(buttons[1]).toHaveFocus();
+
+ // Tab forward through elements
+ await userEvent.tab();
+ expect(buttons[2]).toHaveFocus();
+ await userEvent.tab();
+ expect(buttons[3]).toHaveFocus();
+
+ // Should wrap to first element
+ await userEvent.tab();
+ expect(buttons[1]).toHaveFocus();
+
+ // Tab backward should wrap to last element
+ await userEvent.tab({ shift: true });
+ expect(buttons[3]).toHaveFocus();
+ });
+
+ test('should focus initial element with element ref', () => {
+ render(
+
+
+
+ );
+
+ const initialButton = screen.getByRole('button', { name: 'initial focus' });
+ expect(initialButton).toHaveFocus();
+ });
+
+ test('should allow for the trap to be enabled/disabled', async () => {
+ const { rerender } = render(
+
+
+
+ );
+
+ const buttonBefore = screen.getByRole('button', { name: 'outside before' });
+ const buttonAfter = screen.getByRole('button', { name: 'outside after' });
+
+ // Initially not trapped
+ await userEvent.tab();
+ expect(buttonBefore).toHaveFocus();
+
+ rerender(
+
+
+
+ );
+
+ // Focus should be trapped
+ const trappedButton = screen.getByRole('button', { name: 'first' });
+ expect(trappedButton).toHaveFocus();
+
+ // Check to see if tabbing remains within the trap
+ await userEvent.tab();
+ await userEvent.tab();
+ await userEvent.tab();
+ expect(trappedButton).toHaveFocus();
+
+ // Disable trap
+ rerender(
+
+
+
+ );
+
+ // Should be able to focus outside button
+ await userEvent.tab();
+ await userEvent.tab();
+ await userEvent.tab();
+ await userEvent.tab();
+ expect(buttonAfter).toHaveFocus();
+ });
+
+ test('should restore focus when unmounted', () => {
+ const outsideButton = document.createElement('button');
+ document.body.appendChild(outsideButton);
+ outsideButton.focus();
+
+ const { unmount } = render( );
+ expect(outsideButton).not.toHaveFocus();
+
+ unmount();
+ expect(outsideButton).toHaveFocus();
+
+ document.body.removeChild(outsideButton);
+ });
+
+ test('should handle nested focus traps', () => {
+ const NestedTraps = () => {
+ const outerRef = React.useRef(null);
+ const innerRef = React.useRef(null);
+
+ useFocusTrap(outerRef);
+ useFocusTrap(innerRef);
+
+ return (
+
+ );
+ };
+
+ render( );
+
+ const innerButton = screen.getByRole('button', { name: 'inner' });
+ expect(innerButton).toHaveFocus();
+ });
+
+ test('should return focus to specified returnFocusElement', async () => {
+ const { rerender } = render(
+
+ );
+
+ const buttonBefore = screen.getByRole('button', { name: 'before' });
+ const buttonAfter = screen.getByRole('button', {
+ name: 'return focus element'
+ });
+
+ await userEvent.tab();
+ expect(buttonBefore).toHaveFocus();
+
+ rerender( );
+ rerender( );
+
+ expect(buttonAfter).toHaveFocus();
+ });
+});
diff --git a/packages/react/src/utils/useFocusTrap.ts b/packages/react/src/utils/useFocusTrap.ts
new file mode 100644
index 000000000..74c4a326d
--- /dev/null
+++ b/packages/react/src/utils/useFocusTrap.ts
@@ -0,0 +1,245 @@
+import type { ElementOrRef } from '../types';
+import { useEffect, useRef } from 'react';
+import focusable from 'focusable';
+import resolveElement from './resolveElement';
+
+type FocusTrapMetadata = {
+ targetElement: Element;
+ lastFocusedElement: HTMLElement | null;
+ suspended: boolean;
+};
+
+type FocusTrap = {
+ initialFocusElement: HTMLElement | null;
+ destroy: () => void;
+} & FocusTrapMetadata;
+
+// When multiple focus traps are created, we need to keep track of all previous traps
+// in the stack and temporarily suspend any traps created before the most recent trap
+const focusTrapStack: Array = [];
+const removeFocusTrapFromStack = (focusTrap: FocusTrapMetadata): void => {
+ const focusTrapIndex = focusTrapStack.findIndex(
+ (trap) => focusTrap.targetElement === trap.targetElement
+ );
+ focusTrapStack.splice(focusTrapIndex, 1);
+};
+
+function getActiveElement(target: Element | null): HTMLElement {
+ return (
+ ((target?.ownerDocument.activeElement as HTMLElement) ??
+ (document.activeElement as HTMLElement)) ||
+ document.body
+ );
+}
+
+function elementContains(
+ containerElement: Element,
+ targetElement: Element | null
+): boolean {
+ if (!targetElement) {
+ return false;
+ }
+
+ if (containerElement.getRootNode() === targetElement.getRootNode()) {
+ return containerElement.contains(targetElement);
+ }
+
+ let root = targetElement.getRootNode();
+ while (root && root !== containerElement.getRootNode()) {
+ if (root.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+ // likely a shadow root, and we need to get the host
+ root = (root as ShadowRoot).host;
+ } else {
+ break;
+ }
+ }
+ return root && containerElement.contains(root);
+
+ return false;
+}
+
+function createTrapGuard(): HTMLSpanElement {
+ const trapGuard = document.createElement('span');
+ trapGuard.setAttribute('tabindex', '0');
+ trapGuard.setAttribute('aria-hidden', 'true');
+ return trapGuard;
+}
+
+function createFocusTrap(
+ targetElement: Element,
+ initialFocusElement: HTMLElement | null
+): FocusTrap {
+ const startGuard = createTrapGuard();
+ const endGuard = createTrapGuard();
+ targetElement.insertAdjacentElement('beforebegin', startGuard);
+ targetElement.insertAdjacentElement('afterend', endGuard);
+
+ const focusTrapMetadata: FocusTrapMetadata = {
+ targetElement,
+ lastFocusedElement: null,
+ suspended: false
+ };
+
+ const focusListener = (event: FocusEvent) => {
+ const eventTarget = event.target;
+ const elementContainsTarget = elementContains(
+ targetElement,
+ eventTarget as Element
+ );
+
+ if (focusTrapMetadata.suspended) {
+ return;
+ }
+
+ if (!elementContainsTarget) {
+ // If the event target element is not contained within the target element
+ // for this focus trap, we need to prevent focus from escaping the container
+ event.stopImmediatePropagation();
+ } else if (eventTarget instanceof HTMLElement) {
+ // Ensure we keep track of the most recent valid focus element if we
+ // need to redirect focus later
+ focusTrapMetadata.lastFocusedElement = eventTarget;
+ return;
+ }
+
+ const focusableElements = Array.from(
+ targetElement?.querySelectorAll(focusable) || []
+ ) as HTMLElement[];
+
+ // If focus reaches the trap guards, we need to wrap focus around to the leading
+ // or trailing focusable element depending on which guard obtained focus
+ if (focusableElements.length && eventTarget === startGuard) {
+ focusableElements.reverse()[0]?.focus();
+ return;
+ } else if (focusableElements.length && eventTarget === endGuard) {
+ focusableElements[0]?.focus();
+ return;
+ }
+
+ // If focus somehow escaped the trap, we need to try to restore focus to
+ // to a suitable focusable element within the focus trap target. Otherwise
+ // we'll need to focus on an alternative within the container.
+ if (elementContains(targetElement, focusTrapMetadata.lastFocusedElement)) {
+ focusTrapMetadata.lastFocusedElement?.focus();
+ } else if (focusableElements.length) {
+ focusableElements[0]?.focus();
+ } else {
+ // if there are no focusable elements, just focus the container
+ (targetElement as HTMLElement).focus();
+ }
+ };
+
+ document.addEventListener('focus', focusListener, true);
+
+ if (focusTrapStack.length >= 1) {
+ // Suspend any other traps in the stack while this one is active
+ focusTrapStack.forEach((trap) => {
+ trap.suspended = true;
+ });
+ }
+
+ focusTrapStack.push(focusTrapMetadata);
+
+ if (initialFocusElement) {
+ initialFocusElement.focus();
+ } else {
+ // Try to find a suitable focus element
+ const focusableElements = Array.from(
+ targetElement?.querySelectorAll(focusable) ||
+ /* istanbul ignore else */ []
+ ) as HTMLElement[];
+ focusableElements[0]?.focus();
+ }
+
+ return {
+ ...focusTrapMetadata,
+ initialFocusElement,
+ destroy: () => {
+ document.removeEventListener('focus', focusListener, true);
+ removeFocusTrapFromStack(focusTrapMetadata);
+ startGuard.parentNode?.removeChild(startGuard);
+ endGuard.parentNode?.removeChild(endGuard);
+ // If there are any remaining focus traps in the stack, we need
+ // to unsuspend the most recently added focus trap
+ if (focusTrapStack.length) {
+ focusTrapStack[focusTrapStack.length - 1].suspended = false;
+ }
+ }
+ };
+}
+
+export default function useFocusTrap<
+ TargetElement extends Element = Element,
+ FocusElement extends HTMLElement = HTMLElement
+>(
+ target: ElementOrRef,
+ options: {
+ /**
+ * When set to false, deactivates the focus trap. This can be necessary if
+ * a component needs to conditionally manage focus traps.
+ */
+ disabled?: boolean;
+ /**
+ * When the trap is activated, an optional custom element or ref
+ * can be provided to override the default initial focus element behavior.
+ */
+ initialFocusElement?: ElementOrRef;
+ /**
+ * When set to true and the trap is deactivated, this will return focus
+ * back to the original active element or the return focus element if
+ * provided.
+ */
+ returnFocus?: boolean;
+ /**
+ * When the trap is deactivated, an optional custom element or ref
+ * can be provided to override the default active element behavior.
+ */
+ returnFocusElement?: ElementOrRef;
+ } = {}
+): React.RefObject> {
+ const {
+ disabled = false,
+ returnFocus = true,
+ initialFocusElement: initialFocusElementOrRef,
+ returnFocusElement
+ } = options;
+ const focusTrap = useRef(null);
+ const returnFocusElementRef =
+ useRef() as React.MutableRefObject;
+
+ function restoreFocusToReturnFocusElement() {
+ const resolvedReturnFocusElement = resolveElement(returnFocusElement);
+ if (resolvedReturnFocusElement instanceof HTMLElement) {
+ resolvedReturnFocusElement.focus();
+ } else {
+ returnFocusElementRef.current?.focus();
+ returnFocusElementRef.current = null;
+ }
+ }
+
+ useEffect(() => {
+ const targetElement = resolveElement(target);
+ const initialFocusElement = resolveElement(
+ initialFocusElementOrRef
+ );
+
+ if (!targetElement || disabled) {
+ return;
+ }
+
+ returnFocusElementRef.current = getActiveElement(targetElement);
+
+ focusTrap.current = createFocusTrap(targetElement, initialFocusElement);
+
+ return () => {
+ focusTrap.current?.destroy();
+ focusTrap.current = null;
+ // istanbul ignore else
+ if (returnFocus) {
+ restoreFocusToReturnFocusElement();
+ }
+ };
+ }, [target, disabled]);
+
+ return focusTrap;
+}
diff --git a/packages/react/yarn.lock b/packages/react/yarn.lock
index c91915b79..0bd15aa32 100644
--- a/packages/react/yarn.lock
+++ b/packages/react/yarn.lock
@@ -1248,6 +1248,33 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
+"@floating-ui/core@^1.6.0":
+ version "1.6.8"
+ resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12"
+ integrity sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==
+ dependencies:
+ "@floating-ui/utils" "^0.2.8"
+
+"@floating-ui/dom@^1.0.0":
+ version "1.6.12"
+ resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
+ integrity sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==
+ dependencies:
+ "@floating-ui/core" "^1.6.0"
+ "@floating-ui/utils" "^0.2.8"
+
+"@floating-ui/react-dom@^2.1.2":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31"
+ integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==
+ dependencies:
+ "@floating-ui/dom" "^1.0.0"
+
+"@floating-ui/utils@^0.2.8":
+ version "0.2.8"
+ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
+ integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==
+
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@@ -1525,11 +1552,6 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
-"@popperjs/core@^2.5.4":
- version "2.11.8"
- resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
- integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
-
"@rollup/plugin-commonjs@^14.0.0":
version "14.0.0"
resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-14.0.0.tgz"
@@ -2688,9 +2710,9 @@ create-require@^1.1.0:
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-spawn@^7.0.0, cross-spawn@^7.0.3:
- version "7.0.3"
- resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
- integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+ version "7.0.6"
+ resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
+ integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
@@ -3103,21 +3125,6 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0"
path-exists "^4.0.0"
-focus-trap-react@^10.2.3:
- version "10.3.0"
- resolved "https://registry.yarnpkg.com/focus-trap-react/-/focus-trap-react-10.3.0.tgz#79e2b63459d30a2f5545cf8491a8b02c1779882e"
- integrity sha512-XrCTj44uNE0clTA47y1AbIb7tM7w6+zi6WrJzb4RxRe3uAIIivkBCwlsCqe7R3vPRT/LCQzfe4+N/KjtJMQMgw==
- dependencies:
- focus-trap "^7.6.0"
- tabbable "^6.2.0"
-
-focus-trap@^7.6.0:
- version "7.6.0"
- resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-7.6.0.tgz#7f3edab8135eaca92ab59b6e963eb5cc42ded715"
- integrity sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==
- dependencies:
- tabbable "^6.2.0"
-
focusable@^2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/focusable/-/focusable-2.3.0.tgz"
@@ -4171,7 +4178,7 @@ log-symbols@^2.2.0:
dependencies:
chalk "^2.0.1"
-loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
+loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -4790,11 +4797,6 @@ react-dom@^18:
loose-envify "^1.1.0"
scheduler "^0.23.2"
-react-fast-compare@^3.0.1:
- version "3.2.2"
- resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
- integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
-
react-id-generator@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/react-id-generator/-/react-id-generator-3.0.2.tgz#e5bc5bae463907755e809beb625fafd67ded5d56"
@@ -4815,14 +4817,6 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
-react-popper@^2.2.4:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
- integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
- dependencies:
- react-fast-compare "^3.0.1"
- warning "^4.0.2"
-
react-syntax-highlighter@^15.5.0:
version "15.6.1"
resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz#fa567cb0a9f96be7bbccf2c13a3c4b5657d9543e"
@@ -5325,11 +5319,6 @@ symbol-tree@^3.2.4:
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
-tabbable@^6.2.0:
- version "6.2.0"
- resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
- integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
-
test-exclude@^6.0.0:
version "6.0.0"
resolved "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz"
@@ -5544,13 +5533,6 @@ walker@^1.0.8:
dependencies:
makeerror "1.0.12"
-warning@^4.0.2:
- version "4.0.3"
- resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
- integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
- dependencies:
- loose-envify "^1.0.0"
-
webidl-conversions@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
diff --git a/packages/styles/package.json b/packages/styles/package.json
index 264107d99..3c8f7c617 100644
--- a/packages/styles/package.json
+++ b/packages/styles/package.json
@@ -1,6 +1,6 @@
{
"name": "@deque/cauldron-styles",
- "version": "6.12.0",
+ "version": "6.13.0",
"license": "MPL-2.0",
"description": "deque cauldron pattern library styles",
"repository": "https://github.com/dequelabs/cauldron",
diff --git a/packages/styles/popover.css b/packages/styles/popover.css
index 7594ea110..bea864060 100644
--- a/packages/styles/popover.css
+++ b/packages/styles/popover.css
@@ -37,6 +37,7 @@
/* TooltipArrow needs some dimensions to accurately calculate its positioning */
.Popover__popoverArrow {
+ position: absolute;
height: 0.1px;
width: 0.1px;
}
@@ -52,18 +53,22 @@
/* Adjust position to try to center the arrow in the tooltip's border */
[class*='Popover--top'] .Popover__popoverArrow {
+ left: 50%;
bottom: -1px;
}
[class*='Popover--bottom'] .Popover__popoverArrow {
+ left: 50%;
top: -1px;
}
[class*='Popover--left'] .Popover__popoverArrow {
+ top: 50%;
right: -1px;
}
[class*='Popover--right'] .Popover__popoverArrow {
+ top: 50%;
left: 0;
}
diff --git a/packages/styles/tooltip.css b/packages/styles/tooltip.css
index 7b7463f2a..273afdeb9 100644
--- a/packages/styles/tooltip.css
+++ b/packages/styles/tooltip.css
@@ -51,24 +51,29 @@
/* TooltipArrow needs some dimensions to accurately calculate its positioning */
.TooltipArrow {
+ position: absolute;
height: 0.1px;
width: 0.1px;
}
/* Adjust position to try to center the arrow in the tooltip's border */
[class*='Tooltip--top'] .TooltipArrow {
+ left: 50%;
bottom: -1px;
}
[class*='Tooltip--bottom'] .TooltipArrow {
+ left: 50%;
top: -1px;
}
[class*='Tooltip--left'] .TooltipArrow {
+ top: 50%;
right: -1px;
}
[class*='Tooltip--right'] .TooltipArrow {
+ top: 50%;
left: 0;
}
diff --git a/vpats/2024-12-04-cauldron.md b/vpats/2024-12-04-cauldron.md
new file mode 100644
index 000000000..83746e717
--- /dev/null
+++ b/vpats/2024-12-04-cauldron.md
@@ -0,0 +1,65 @@
+# Cauldron Accessibility Conformance Report WCAG Edition
+
+**Name of Product**: Cauldron
+
+**Report Date**: 2024-12-04
+
+## Table 1: Success Criteria, Level A
+
+| Criteria | Conformance Level | Remarks and Explanations |
+| --- | --- | --- |
+| [1.1.1 Non-text Content](http://www.w3.org/TR/WCAG20/#text-equiv-all) (Level A) | Supports | |
+| [1.2.1 Audio-only and Video-only (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-av-only-alt) (Level A) | Supports | |
+| [1.2.2 Captions (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-captions) (Level A) | Supports | |
+| [1.2.3 Audio Description or Media Alternative (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-audio-desc) (Level A) | Supports | |
+| [1.3.1 Info and Relationships](http://www.w3.org/TR/WCAG20/#content-structure-separation-programmatic) (Level A) | Supports | |
+| [1.3.2 Meaningful Sequence](http://www.w3.org/TR/WCAG20/#content-structure-separation-sequence) (Level A) | Supports | |
+| [1.3.3 Sensory Characteristics](http://www.w3.org/TR/WCAG20/#content-structure-separation-understanding) (Level A) | Supports | |
+| [1.4.1 Use of Color](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-without-color) (Level A) | Supports | |
+| [1.4.2 Audio Control](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-dis-audio) (Level A) | Supports | |
+| [2.1.1 Keyboard](http://www.w3.org/TR/WCAG20/#keyboard-operation-keyboard-operable) (Level A) | Supports | |
+| [2.1.2 No Keyboard Trap](http://www.w3.org/TR/WCAG20/#keyboard-operation-trapping) (Level A) | Supports | |
+| [2.1.4 Character Key Shortcuts](http://www.w3.org/TR/WCAG20/#keyboard-operation-keyboard-operable) (Level A) | Supports | |
+| [2.2.1 Timing Adjustable](http://www.w3.org/TR/WCAG20/#time-limits-required-behaviors) (Level A) | Supports | |
+| [2.2.2 Pause, Stop, Hide](http://www.w3.org/TR/WCAG20/#time-limits-pause) (Level A) | Supports | |
+| [2.3.1 Three Flashes or Below Threshold](http://www.w3.org/TR/WCAG20/#seizure-does-not-violate) (Level A) | Supports | |
+| [2.4.1 Bypass Blocks](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-skip) (Level A) | Supports | |
+| [2.4.2 Page Titled](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-title) (Level A) | Supports | |
+| [2.4.3 Focus Order](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-focus-order) (Level A) | Supports | |
+| [2.4.4 Link Purpose (In Context)](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-refs) (Level A) | Supports | |
+| [2.5.1 Pointer Gestures](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level A) | Supports | |
+| [2.5.2 Pointer Cancellation](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level A) | Supports | |
+| [2.5.3 Label in Name](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-descriptive) (Level A) | Supports | |
+| [2.5.4 Motion Actuation](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-motion-actuation) (Level A) | Supports | |
+| [3.1.1 Language of Page](http://www.w3.org/TR/WCAG20/#meaning-doc-lang-id) (Level A) | Supports | |
+| [3.2.1 On Focus](http://www.w3.org/TR/WCAG20/#consistent-behavior-receive-focus) (Level A) | Supports | |
+| [3.2.2 On Input](http://www.w3.org/TR/WCAG20/#consistent-behavior-unpredictable-change) (Level A) | Supports | |
+| [3.3.1 Error Identification](http://www.w3.org/TR/WCAG20/#minimize-error-identified) (Level A) | Supports | |
+| [3.3.2 Labels or Instructions](http://www.w3.org/TR/WCAG20/#minimize-error-cues) (Level A) | Supports | |
+| [4.1.1 Parsing](http://www.w3.org/TR/WCAG20/#ensure-compat-parses) (Level A) | Supports | |
+| [4.1.2 Name, Role, Value](http://www.w3.org/TR/WCAG20/#ensure-compat-rsv) (Level A) | Supports | |
+
+## Table 2: Success Criteria, Level AA
+
+| Criteria | Conformance Level | Remarks and Explanations |
+| --- | --- | --- |
+| [1.2.4 Captions (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-captions) (Level AA) | Supports | |
+| [1.2.5 Audio Description or Media Alternative (Prerecorded)](http://www.w3.org/TR/WCAG20/#media-equiv-audio-desc) (Level AA) | Supports | |
+| [1.3.4 Orientation](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-orientation) (Level AA) | Supports | |
+| [1.3.5 Identify Input Purpose](http://www.w3.org/TR/WCAG20/#input-purposes) (Level AA) | Supports | |
+| [1.4.3 Contrast (Minimum)](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast) (Level AA) | Supports | |
+| [1.4.4 Resize text](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-scale) (Level AA) | Supports | |
+| [1.4.5 Images of Text](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-text-presentation) (Level AA) | Supports | |
+| [1.4.10 Reflow](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-scale) (Level AA) | Supports | |
+| [1.4.11 Non-text Contrast](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast) (Level AA) | Supports | |
+| [1.4.12 Text Spacing](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-spacing) (Level AA) | Supports | |
+| [1.4.13 Content on Hover or Focus](http://www.w3.org/TR/WCAG20/#visual-audio-contrast-dis-audio) (Level AA) | Supports | |
+| [2.4.5 Multiple Ways](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-mult-loc) (Level AA) | Supports | |
+| [2.4.6 Headings and Labels](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-descriptive) (Level AA) | Partially Supports | [[#1393] [A11y] - Programmatic label does not convey purpose of control](https://github.com/dequelabs/cauldron/issues/1393) (2024-03-08) |
+| [2.4.7 Focus Visible](http://www.w3.org/TR/WCAG20/#navigation-mechanisms-focus-visible) (Level AA) | Supports | |
+| [3.1.2 Language of Parts](http://www.w3.org/TR/WCAG20/#meaning-doc-lang-id) (Level AA) | Supports | |
+| [3.2.3 Consistent Navigation](http://www.w3.org/TR/WCAG20/#consistent-behavior-consistent-locations) (Level AA) | Supports | |
+| [3.2.4 Consistent Identification](http://www.w3.org/TR/WCAG20/#consistent-behavior-consistent-functionality) (Level AA) | Supports | |
+| [3.3.3 Error Suggestion](http://www.w3.org/TR/WCAG20/#minimize-error-suggestions) (Level AA) | Supports | |
+| [3.3.4 Error Prevention (Legal, Financial, Data)](http://www.w3.org/TR/WCAG20/#minimize-error-reversible) (Level AA) | Supports | |
+| [4.1.3 Status Messages](http://www.w3.org/TR/WCAG20/#ensure-compat-rsv) (Level AA) | Supports | |
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index fdbe0195b..cf1645c72 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8559,9 +8559,9 @@ nan@^2.12.1:
integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==
nanoid@^3.3.7:
- version "3.3.7"
- resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
- integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
+ version "3.3.8"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
+ integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
nanomatch@^1.2.9:
version "1.2.13"