,
35 | context?: any,
36 | ): React.ReactNode;
37 | propTypes?: any;
38 | contextTypes?: any;
39 | defaultProps?: Partial;
40 | displayName?: string;
41 | }
42 |
43 | export interface DynamicFunctionComponent<
44 | TInitial extends string | React.ComponentType,
45 | P = { children?: React.ReactNode },
46 | > {
47 | = TInitial>(
48 | props: AssignPropsWithoutRef,
49 | context?: any,
50 | ): React.ReactNode;
51 | propTypes?: any;
52 | contextTypes?: any;
53 | defaultProps?: Partial;
54 | displayName?: string;
55 | }
56 |
57 | export class DynamicComponent<
58 | As extends string | React.ComponentType,
59 | P = unknown,
60 | > extends React.Component> {}
61 |
62 | // Need to use this instead of typeof Component to get proper type checking.
63 | export type DynamicComponentClass<
64 | As extends string | React.ComponentType,
65 | P = unknown,
66 | > = React.ComponentClass>;
67 |
68 | export type SelectCallback = (
69 | eventKey: string | null,
70 | e: React.SyntheticEvent,
71 | ) => void;
72 |
73 | export interface TransitionCallbacks {
74 | /**
75 | * Callback fired before the component transitions in
76 | */
77 | onEnter?(node: HTMLElement, isAppearing: boolean): any;
78 | /**
79 | * Callback fired as the component begins to transition in
80 | */
81 | onEntering?(node: HTMLElement, isAppearing: boolean): any;
82 | /**
83 | * Callback fired after the component finishes transitioning in
84 | */
85 | onEntered?(node: HTMLElement, isAppearing: boolean): any;
86 | /**
87 | * Callback fired right before the component transitions out
88 | */
89 | onExit?(node: HTMLElement): any;
90 | /**
91 | * Callback fired as the component begins to transition out
92 | */
93 | onExiting?(node: HTMLElement): any;
94 | /**
95 | * Callback fired after the component finishes transitioning out
96 | */
97 | onExited?(node: HTMLElement): any;
98 | }
99 |
100 | export interface TransitionProps extends TransitionCallbacks {
101 | in?: boolean;
102 | appear?: boolean;
103 | children: React.ReactElement;
104 | mountOnEnter?: boolean;
105 | unmountOnExit?: boolean;
106 | }
107 |
108 | export type TransitionComponent = React.ComponentType;
109 |
--------------------------------------------------------------------------------
/test/AnchorSpec.tsx:
--------------------------------------------------------------------------------
1 | import { render, fireEvent } from '@testing-library/react';
2 |
3 | import { expect, describe, it, vi } from 'vitest';
4 |
5 | import Anchor from '../src/Anchor';
6 |
7 | describe('Anchor', () => {
8 | it('renders an anchor tag', () => {
9 | const { container } = render();
10 |
11 | expect(container.firstElementChild!.tagName).toEqual('A');
12 | });
13 |
14 | it('forwards provided href', () => {
15 | const { container } = render();
16 |
17 | expect(container.firstElementChild!.getAttribute('href')!).to.equal(
18 | 'http://google.com',
19 | );
20 | });
21 |
22 | it('ensures that a href is a hash if none provided', () => {
23 | const { container } = render();
24 |
25 | expect(container.firstElementChild!.getAttribute('href')!).to.equal('#');
26 | });
27 |
28 | it('forwards onClick handler', () => {
29 | const handleClick = vi.fn();
30 |
31 | const { container } = render();
32 |
33 | fireEvent.click(container.firstChild!);
34 |
35 | expect(handleClick).toHaveBeenCalledOnce();
36 | });
37 |
38 | it('provides onClick handler as onKeyDown handler for "space"', () => {
39 | const handleClick = vi.fn();
40 |
41 | const { container } = render();
42 |
43 | fireEvent.keyDown(container.firstChild!, { key: ' ' });
44 |
45 | expect(handleClick).toHaveBeenCalledOnce();
46 | });
47 |
48 | it('should call onKeyDown handler when href is non-trivial', () => {
49 | const onKeyDownSpy = vi.fn();
50 |
51 | const { container } = render(
52 | ,
53 | );
54 |
55 | fireEvent.keyDown(container.firstChild!, { key: ' ' });
56 |
57 | expect(onKeyDownSpy).toHaveBeenCalledOnce();
58 | });
59 |
60 | it('prevents default when no href is provided', () => {
61 | const handleClick = vi.fn();
62 |
63 | const { container, rerender } = render();
64 |
65 | fireEvent.click(container.firstChild!);
66 |
67 | rerender();
68 |
69 | fireEvent.click(container.firstChild!);
70 |
71 | expect(handleClick).toHaveBeenCalledTimes(2);
72 | expect(handleClick.mock.calls[0][0].isDefaultPrevented()).toEqual(true);
73 | expect(handleClick.mock.calls[1][0].isDefaultPrevented()).toEqual(true);
74 | });
75 |
76 | it('does not prevent default when href is provided', () => {
77 | const handleClick = vi.fn();
78 |
79 | fireEvent.click(
80 | render().container
81 | .firstChild!,
82 | );
83 |
84 | expect(handleClick).toHaveBeenCalledOnce();
85 | expect(handleClick.mock.calls[0][0].isDefaultPrevented()).toEqual(false);
86 | });
87 |
88 | it('forwards provided role', () => {
89 | render().getByRole('dialog');
90 | });
91 |
92 | it('forwards provided role with href', () => {
93 | render().getByRole(
94 | 'dialog',
95 | );
96 | });
97 |
98 | it('set role=button with no provided href', () => {
99 | const wrapper = render();
100 |
101 | wrapper.getByRole('button');
102 |
103 | wrapper.rerender();
104 |
105 | wrapper.getByRole('button');
106 | });
107 |
108 | it('sets no role with provided href', () => {
109 | expect(
110 | render(
111 | ,
112 | ).container.firstElementChild!.hasAttribute('role'),
113 | ).toEqual(false);
114 | });
115 | });
116 |
--------------------------------------------------------------------------------
/src/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useId, useMemo } from 'react';
3 | import { useUncontrolledProp } from 'uncontrollable';
4 |
5 | import TabContext, { type TabContextType } from './TabContext.js';
6 | import SelectableContext from './SelectableContext.js';
7 | import { EventKey, SelectCallback, TransitionComponent } from './types.js';
8 | import TabPanel, { TabPanelProps } from './TabPanel.js';
9 |
10 | export type { TabPanelProps };
11 | export interface TabsProps extends React.PropsWithChildren {
12 | id?: string;
13 |
14 | /**
15 | * Sets a default animation strategy for all children ``s.
16 | * Use a react-transition-group `` component.
17 | */
18 | transition?: TransitionComponent;
19 |
20 | /**
21 | * Wait until the first "enter" transition to mount tabs (add them to the DOM)
22 | */
23 | mountOnEnter?: boolean;
24 |
25 | /**
26 | * Unmount tabs (remove it from the DOM) when they are no longer visible
27 | */
28 | unmountOnExit?: boolean;
29 |
30 | /**
31 | * A function that takes an `eventKey` and `type` and returns a unique id for
32 | * child tab ``s and ``s. The function _must_ be a pure
33 | * function, meaning it should always return the _same_ id for the same set
34 | * of inputs. The default value requires that an `id` to be set for the
35 | * ``.
36 | *
37 | * The `type` argument will either be `"tab"` or `"pane"`.
38 | *
39 | * @defaultValue (eventKey, type) => `${props.id}-${type}-${eventKey}`
40 | */
41 | generateChildId?: (eventKey: EventKey, type: 'tab' | 'pane') => string;
42 |
43 | /**
44 | * A callback fired when a tab is selected.
45 | *
46 | * @controllable activeKey
47 | */
48 | onSelect?: SelectCallback;
49 |
50 | /**
51 | * The `eventKey` of the currently active tab.
52 | *
53 | * @controllable onSelect
54 | */
55 | activeKey?: EventKey;
56 |
57 | /**
58 | * Default value for `eventKey`.
59 | */
60 | defaultActiveKey?: EventKey;
61 | }
62 |
63 | const Tabs = (props: TabsProps) => {
64 | const {
65 | id: userId,
66 | generateChildId: generateCustomChildId,
67 | onSelect: propsOnSelect,
68 | activeKey: propsActiveKey,
69 | defaultActiveKey,
70 | transition,
71 | mountOnEnter,
72 | unmountOnExit,
73 | children,
74 | } = props;
75 |
76 | const [activeKey, onSelect] = useUncontrolledProp(
77 | propsActiveKey,
78 | defaultActiveKey,
79 | propsOnSelect,
80 | );
81 |
82 | const generatedId = useId();
83 | const id = userId ?? generatedId;
84 |
85 | const generateChildId = useMemo(
86 | () =>
87 | generateCustomChildId ||
88 | ((key: EventKey, type: string) => (id ? `${id}-${type}-${key}` : null)),
89 | [id, generateCustomChildId],
90 | );
91 |
92 | const tabContext: TabContextType = useMemo(
93 | () => ({
94 | onSelect,
95 | activeKey,
96 | transition,
97 | mountOnEnter: mountOnEnter || false,
98 | unmountOnExit: unmountOnExit || false,
99 | getControlledId: (key: EventKey) => generateChildId(key, 'pane'),
100 | getControllerId: (key: EventKey) => generateChildId(key, 'tab'),
101 | }),
102 | [
103 | onSelect,
104 | activeKey,
105 | transition,
106 | mountOnEnter,
107 | unmountOnExit,
108 | generateChildId,
109 | ],
110 | );
111 |
112 | return (
113 |
114 |
115 | {children}
116 |
117 |
118 | );
119 | };
120 |
121 | Tabs.Panel = TabPanel;
122 | export default Tabs;
123 |
--------------------------------------------------------------------------------
/test/usePopperSpec.tsx:
--------------------------------------------------------------------------------
1 | import { renderHook, waitFor } from '@testing-library/react';
2 | import { expect, describe, it, beforeEach, afterEach } from 'vitest';
3 | import usePopper from '../src/usePopper';
4 |
5 | describe('usePopper', () => {
6 | const elements: Record = {};
7 |
8 | beforeEach(() => {
9 | elements.mount = document.createElement('div');
10 | elements.reference = document.createElement('div');
11 | elements.popper = document.createElement('div');
12 |
13 | elements.mount.appendChild(elements.reference);
14 | elements.mount.appendChild(elements.popper);
15 | document.body.appendChild(elements.mount);
16 | });
17 |
18 | afterEach(() => {
19 | elements.mount.parentNode!.removeChild(elements.mount);
20 | });
21 |
22 | it('should return state', async () => {
23 | const { result } = renderHook(() =>
24 | usePopper(elements.reference, elements.popper),
25 | );
26 |
27 | await waitFor(() => expect(result.current).toHaveProperty('update'));
28 |
29 | expect(result.current.update).toBeInstanceOf(Function);
30 | expect(result.current.forceUpdate).toBeInstanceOf(Function);
31 | expect(result.current.styles).toHaveProperty('popper');
32 | });
33 |
34 | it('should add aria-describedBy for tooltips', async () => {
35 | elements.popper.setAttribute('role', 'tooltip');
36 | elements.popper.setAttribute('id', 'example123');
37 |
38 | const { unmount } = renderHook(() =>
39 | usePopper(elements.reference, elements.popper),
40 | );
41 |
42 | await waitFor(() =>
43 | expect(document.querySelector('[aria-describedby="example123"]')).toEqual(
44 | elements.reference,
45 | ),
46 | );
47 |
48 | unmount();
49 |
50 | expect(document.querySelector('[aria-describedby="example123"]')).toEqual(
51 | null,
52 | );
53 | });
54 |
55 | it('should add to existing describedBy', async () => {
56 | elements.popper.setAttribute('role', 'tooltip');
57 | elements.popper.setAttribute('id', 'example123');
58 | elements.reference.setAttribute('aria-describedby', 'foo, bar , baz ');
59 |
60 | const { unmount } = renderHook(() =>
61 | usePopper(elements.reference, elements.popper),
62 | );
63 |
64 | await waitFor(() =>
65 | expect(
66 | document.querySelector(
67 | '[aria-describedby="foo, bar , baz ,example123"]',
68 | ),
69 | ).toEqual(elements.reference),
70 | );
71 |
72 | unmount();
73 |
74 | expect(
75 | document.querySelector('[aria-describedby="foo, bar , baz "]'),
76 | ).toEqual(elements.reference);
77 | });
78 |
79 | it('should not aria-describedBy any other role', async () => {
80 | renderHook(() => usePopper(elements.reference, elements.popper));
81 |
82 | await waitFor(() => {
83 | expect(document.querySelector('[aria-describedby="example123"]')).toEqual(
84 | null,
85 | );
86 | });
87 | });
88 |
89 | it('should not add add duplicates to aria-describedby', async () => {
90 | elements.popper.setAttribute('role', 'tooltip');
91 | elements.popper.setAttribute('id', 'example123');
92 | elements.reference.setAttribute('aria-describedby', 'foo');
93 |
94 | const result = renderHook(() =>
95 | usePopper(elements.reference, elements.popper),
96 | );
97 |
98 | window.dispatchEvent(new Event('resize'));
99 |
100 | await waitFor(() =>
101 | expect(
102 | document.querySelector('[aria-describedby="foo,example123"]'),
103 | ).toEqual(elements.reference),
104 | );
105 |
106 | result.unmount();
107 |
108 | expect(document.querySelector('[aria-describedby="foo"]')).toEqual(
109 | elements.reference,
110 | );
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/src/ImperativeTransition.tsx:
--------------------------------------------------------------------------------
1 | import useMergedRefs from '@restart/hooks/useMergedRefs';
2 | import useEventCallback from '@restart/hooks/useEventCallback';
3 | import useIsomorphicEffect from '@restart/hooks/useIsomorphicEffect';
4 | import { useRef, cloneElement, useState } from 'react';
5 | import type { TransitionComponent, TransitionProps } from './types.js';
6 | import NoopTransition from './NoopTransition.js';
7 | import RTGTransition from './RTGTransition.js';
8 | import { getChildRef } from './utils.js';
9 |
10 | export interface TransitionFunctionOptions {
11 | in: boolean;
12 | element: HTMLElement;
13 | initial: boolean;
14 | isStale: () => boolean;
15 | }
16 |
17 | export type TransitionHandler = (
18 | options: TransitionFunctionOptions,
19 | ) => void | Promise;
20 |
21 | export interface UseTransitionOptions {
22 | in: boolean;
23 | onTransition: TransitionHandler;
24 | initial?: boolean;
25 | }
26 |
27 | export function useTransition({
28 | in: inProp,
29 | onTransition,
30 | }: UseTransitionOptions) {
31 | const ref = useRef(null);
32 | const isInitialRef = useRef(true);
33 | const handleTransition = useEventCallback(onTransition);
34 |
35 | useIsomorphicEffect(() => {
36 | if (!ref.current) {
37 | return undefined;
38 | }
39 |
40 | let stale = false;
41 |
42 | handleTransition({
43 | in: inProp,
44 | element: ref.current!,
45 | initial: isInitialRef.current,
46 | isStale: () => stale,
47 | });
48 | return () => {
49 | stale = true;
50 | };
51 | }, [inProp, handleTransition]);
52 |
53 | useIsomorphicEffect(() => {
54 | isInitialRef.current = false;
55 | // this is for strict mode
56 | return () => {
57 | isInitialRef.current = true;
58 | };
59 | }, []);
60 |
61 | return ref;
62 | }
63 |
64 | export interface ImperativeTransitionProps
65 | extends Omit {
66 | transition: TransitionHandler;
67 | }
68 |
69 | /**
70 | * Adapts an imperative transition function to a subset of the RTG `` component API.
71 | *
72 | * ImperativeTransition does not support mounting options or `appear` at the moment, meaning
73 | * that it always acts like: `mountOnEnter={true} unmountOnExit={true} appear={true}`
74 | */
75 | export default function ImperativeTransition({
76 | children,
77 | in: inProp,
78 | onExited,
79 | onEntered,
80 | transition,
81 | }: ImperativeTransitionProps) {
82 | const [exited, setExited] = useState(!inProp);
83 |
84 | // TODO: I think this needs to be in an effect
85 | if (inProp && exited) {
86 | setExited(false);
87 | }
88 |
89 | const ref = useTransition({
90 | in: !!inProp,
91 | onTransition: (options) => {
92 | const onFinish = () => {
93 | if (options.isStale()) return;
94 |
95 | if (options.in) {
96 | onEntered?.(options.element, options.initial);
97 | } else {
98 | setExited(true);
99 | onExited?.(options.element);
100 | }
101 | };
102 |
103 | Promise.resolve(transition(options)).then(onFinish, (error) => {
104 | if (!options.in) setExited(true);
105 | throw error;
106 | });
107 | },
108 | });
109 |
110 | const combinedRef = useMergedRefs(ref, getChildRef(children));
111 |
112 | return exited && !inProp
113 | ? null
114 | : cloneElement(children, { ref: combinedRef });
115 | }
116 |
117 | export function renderTransition(
118 | component: TransitionComponent | undefined,
119 | runTransition: TransitionHandler | undefined,
120 | props: TransitionProps & Omit,
121 | ) {
122 | if (component) {
123 | return ;
124 | }
125 | if (runTransition) {
126 | return ;
127 | }
128 |
129 | return ;
130 | }
131 |
--------------------------------------------------------------------------------
/src/NavItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useContext } from 'react';
3 | import useEventCallback from '@restart/hooks/useEventCallback';
4 |
5 | import NavContext from './NavContext.js';
6 | import SelectableContext, { makeEventKey } from './SelectableContext.js';
7 | import type { EventKey, DynamicRefForwardingComponent } from './types.js';
8 | import Button from './Button.js';
9 | import { dataAttr } from './DataKey.js';
10 | import TabContext from './TabContext.js';
11 |
12 | export interface NavItemProps extends React.HTMLAttributes {
13 | /**
14 | * Highlight the NavItem as active.
15 | */
16 | active?: boolean;
17 |
18 | /**
19 | * Element used to render the component.
20 | */
21 | as?: React.ElementType;
22 |
23 | /**
24 | * Disable the NavItem, making it unselectable.
25 | */
26 | disabled?: boolean;
27 |
28 | /**
29 | * Value passed to the `onSelect` handler, useful for identifying the selected NavItem.
30 | */
31 | eventKey?: EventKey;
32 |
33 | /**
34 | * HTML `href` attribute corresponding to `a.href`.
35 | */
36 | href?: string;
37 | }
38 |
39 | export interface UseNavItemOptions {
40 | key?: string | null;
41 | onClick?: React.MouseEventHandler;
42 | active?: boolean;
43 | disabled?: boolean;
44 | id?: string;
45 | role?: string;
46 | }
47 |
48 | export function useNavItem({
49 | key,
50 | onClick,
51 | active,
52 | id,
53 | role,
54 | disabled,
55 | }: UseNavItemOptions) {
56 | const parentOnSelect = useContext(SelectableContext);
57 | const navContext = useContext(NavContext);
58 | const tabContext = useContext(TabContext);
59 |
60 | let isActive = active;
61 | const props = { role } as any;
62 |
63 | if (navContext) {
64 | if (!role && navContext.role === 'tablist') props.role = 'tab';
65 |
66 | const contextControllerId = navContext.getControllerId(key ?? null);
67 | const contextControlledId = navContext.getControlledId(key ?? null);
68 |
69 | props[dataAttr('event-key')] = key;
70 |
71 | props.id = contextControllerId || id;
72 |
73 | isActive =
74 | active == null && key != null ? navContext.activeKey === key : active;
75 |
76 | /**
77 | * Simplified scenario for `mountOnEnter`.
78 | *
79 | * While it would make sense to keep 'aria-controls' for tabs that have been mounted at least
80 | * once, it would also complicate the code quite a bit, for very little gain.
81 | * The following implementation is probably good enough.
82 | *
83 | * @see https://github.com/react-restart/ui/pull/40#issuecomment-1009971561
84 | */
85 | if (isActive || (!tabContext?.unmountOnExit && !tabContext?.mountOnEnter))
86 | props['aria-controls'] = contextControlledId;
87 | }
88 |
89 | if (props.role === 'tab') {
90 | props['aria-selected'] = isActive;
91 |
92 | if (!isActive) {
93 | props.tabIndex = -1;
94 | }
95 |
96 | if (disabled) {
97 | props.tabIndex = -1;
98 | props['aria-disabled'] = true;
99 | }
100 | }
101 |
102 | props.onClick = useEventCallback((e: React.MouseEvent) => {
103 | if (disabled) return;
104 |
105 | onClick?.(e);
106 |
107 | if (key == null) {
108 | return;
109 | }
110 |
111 | if (parentOnSelect && !e.isPropagationStopped()) {
112 | parentOnSelect(key, e);
113 | }
114 | });
115 |
116 | return [props, { isActive }] as const;
117 | }
118 |
119 | const NavItem: DynamicRefForwardingComponent =
120 | React.forwardRef(
121 | ({ as: Component = Button, active, eventKey, ...options }, ref) => {
122 | const [props, meta] = useNavItem({
123 | key: makeEventKey(eventKey, options.href),
124 | active,
125 | ...options,
126 | });
127 |
128 | props[dataAttr('active')] = meta.isActive;
129 |
130 | return ;
131 | },
132 | );
133 |
134 | NavItem.displayName = 'NavItem';
135 |
136 | export default NavItem;
137 |
--------------------------------------------------------------------------------
/www/src/LiveCodeblock.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import BrowserOnly from '@docusaurus/BrowserOnly';
3 |
4 | import clsx from 'clsx';
5 | import {
6 | Editor,
7 | Error,
8 | ImportResolver,
9 | InfoMessage,
10 | Preview,
11 | Provider,
12 | } from 'jarle';
13 | import * as React from 'react';
14 | import * as ReactDOM from 'react-dom';
15 | import * as RestartUi from '@restart/ui';
16 | import Button from './Button';
17 | import Dropdown from './Dropdown';
18 | import Tooltip from './Tooltip';
19 | import Transition from 'react-transition-group/Transition';
20 | import scrollParent from 'dom-helpers/scrollParent';
21 | import '../src/css/transitions.css';
22 | import styled from '@emotion/styled';
23 |
24 | // @ts-ignore
25 | import styles from './LiveCodeBlock.module.css';
26 |
27 | const Info = (props: any) => (
28 |
32 | );
33 |
34 | const LocalImports = {
35 | react: React,
36 | 'react-dom': ReactDOM,
37 | '@restart/ui': RestartUi,
38 | 'react-transition-group/Transition': Transition,
39 | 'dom-helpers/scrollParent': scrollParent,
40 | clsx,
41 | '../src/Button': Button,
42 | '../src/Dropdown': Dropdown,
43 | '../src/Tooltip': Tooltip,
44 | '../src/css/transitions.css': '',
45 | '@emotion/styled': styled,
46 | };
47 |
48 | export interface Props
49 | extends Omit, 'children' | 'code'> {
50 | inline?: boolean;
51 | children: string;
52 | className?: string;
53 | editorClassName?: string;
54 | previewClassName?: string;
55 | errorClassName?: string;
56 | codeFirst?: boolean;
57 | editor?: 'show' | 'hide' | 'collapse';
58 | ref?: React.Ref;
59 | }
60 |
61 | const resolveImports: ImportResolver = (requests: string[]) => {
62 | return Promise.all(
63 | requests.map((request) => {
64 | if (request in LocalImports) {
65 | return LocalImports[request];
66 | }
67 |
68 | return import(/* webpackIgnore: true */ request);
69 | }),
70 | );
71 | };
72 |
73 | const scope = { React, ...React };
74 |
75 | export function LiveCodeblock({
76 | children,
77 | inline = false,
78 | className,
79 | editorClassName,
80 | previewClassName,
81 | errorClassName,
82 | codeFirst = false,
83 | ref,
84 | editor: editorConfig = 'show',
85 | ...props
86 | }: Props) {
87 | const [showEditor, setShowEditor] = React.useState(editorConfig === 'show');
88 |
89 | const showButton = editorConfig === 'collapse' && (
90 |
91 |
97 |
98 | );
99 |
100 | const editor = showEditor && (
101 |
106 | );
107 | const preview = (
108 | Loading…}>
109 | {() => (
110 |
114 | )}
115 |
116 | );
117 |
118 | return (
119 | <>
120 |
124 |
130 | {!codeFirst ? (
131 | <>
132 | {preview}
133 | {editor}
134 | >
135 | ) : (
136 | <>
137 | {editor}
138 | {preview}
139 | >
140 | )}
141 |
142 |
143 | {showButton}
144 | >
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/www/docs/Modal.mdx:
--------------------------------------------------------------------------------
1 | Love them or hate them, `` provides a solid foundation for creating dialogs,
2 | lightboxes, or whatever else. The `Modal` component renders its `children` node in front
3 | of a backdrop component.
4 |
5 | The `Modal` offers a few helpful features over using just a `` component and
6 | some styles:
7 |
8 | - Manages dialog stacking when one-at-a-time just isn't enough.
9 | - Creates a backdrop for disabling interaction below the modal.
10 | - Properly manages focus; moving to the modal content, and keeping it there until
11 | the modal is closed.
12 | - Disables scrolling of the page content while open.
13 | - Ensuring modal content is accessible with the appropriate ARIA.
14 | - Allows easily-pluggable animations via a `` component.
15 |
16 | ## Example
17 |
18 | ```jsx live
19 | import { Modal } from "@restart/ui";
20 | import Button from "../src/Button";
21 |
22 | function Example() {
23 | const [show, setShow] = useState(false);
24 |
25 | return (
26 |
27 |
30 |
31 |
setShow(false)}
35 | renderBackdrop={(props) => (
36 |
40 | )}
41 | className="fixed z-[301] top-1/2 left-1/2 transform -translate-y-1/2 -translate-x-1/2 bg-white shadow-lg p-5"
42 | >
43 |
44 |
Alert!
45 |
Some important content!
46 |
47 |
48 |
51 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | ;
65 | ```
66 |
67 | ## Modal stacking
68 |
69 | Modal supports stacking (if you really need it) out of the box.
70 |
71 | ```jsx live
72 | import styled from "@emotion/styled";
73 | import { Modal } from "@restart/ui";
74 | import Button from "../src/Button";
75 |
76 | let rand = () => Math.floor(Math.random() * 20) - 10;
77 |
78 | const Backdrop = styled("div")`
79 | position: fixed;
80 | z-index: 1040;
81 | top: 0;
82 | bottom: 0;
83 | left: 0;
84 | right: 0;
85 | background-color: #000;
86 | opacity: 0.5;
87 | `;
88 |
89 | // we use some pseudo random coords so nested modals
90 | // don't sit right on top of each other.
91 | const RandomlyPositionedModal = styled(Modal)`
92 | position: fixed;
93 | width: 400px;
94 | z-index: 1040;
95 | top: ${() => 50 + rand()}%;
96 | left: ${() => 50 + rand()}%;
97 | border: 1px solid #e5e5e5;
98 | background-color: white;
99 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
100 | padding: 20px;
101 | `;
102 |
103 | function ModalExample() {
104 | const [show, setShow] = useState(false);
105 |
106 | const renderBackdrop = (props) => ;
107 |
108 | return (
109 |
110 |
116 |
Click to get the full Modal experience!
117 |
118 |
setShow(false)}
121 | renderBackdrop={renderBackdrop}
122 | aria-labelledby="modal-label"
123 | >
124 |
125 |
Text in a modal
126 |
127 | Duis mollis, est non commodo luctus, nisi erat
128 | porttitor ligula.
129 |
130 |
131 |
132 |
133 |
134 | );
135 | }
136 |
137 | ;
138 | ```
139 |
--------------------------------------------------------------------------------
/src/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export type ButtonType = 'button' | 'reset' | 'submit';
4 |
5 | export interface AnchorOptions {
6 | href?: string;
7 | rel?: string;
8 | target?: string;
9 | }
10 |
11 | export interface UseButtonPropsOptions extends AnchorOptions {
12 | type?: ButtonType;
13 | disabled?: boolean;
14 | onClick?: React.EventHandler;
15 | tabIndex?: number;
16 | tagName?: keyof React.JSX.IntrinsicElements;
17 | role?: React.AriaRole | undefined;
18 | }
19 |
20 | export function isTrivialHref(href?: string) {
21 | return !href || href.trim() === '#';
22 | }
23 |
24 | export interface AriaButtonProps {
25 | type?: ButtonType | undefined;
26 | disabled: boolean | undefined;
27 | role?: React.AriaRole;
28 | tabIndex?: number | undefined;
29 | href?: string | undefined;
30 | target?: string | undefined;
31 | rel?: string | undefined;
32 | 'aria-disabled'?: true | undefined;
33 | onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void;
34 | onKeyDown?: (event: React.KeyboardEvent) => void;
35 | }
36 |
37 | export interface UseButtonPropsMetadata {
38 | tagName: React.ElementType;
39 | }
40 |
41 | export function useButtonProps({
42 | tagName,
43 | disabled,
44 | href,
45 | target,
46 | rel,
47 | role,
48 | onClick,
49 | tabIndex = 0,
50 | type,
51 | }: UseButtonPropsOptions): [AriaButtonProps, UseButtonPropsMetadata] {
52 | if (!tagName) {
53 | if (href != null || target != null || rel != null) {
54 | tagName = 'a';
55 | } else {
56 | tagName = 'button';
57 | }
58 | }
59 |
60 | const meta: UseButtonPropsMetadata = { tagName };
61 | if (tagName === 'button') {
62 | return [{ type: (type as any) || 'button', disabled }, meta];
63 | }
64 |
65 | const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => {
66 | if (disabled || (tagName === 'a' && isTrivialHref(href))) {
67 | event.preventDefault();
68 | }
69 |
70 | if (disabled) {
71 | event.stopPropagation();
72 | return;
73 | }
74 |
75 | onClick?.(event);
76 | };
77 |
78 | const handleKeyDown = (event: React.KeyboardEvent) => {
79 | if (event.key === ' ') {
80 | event.preventDefault();
81 | handleClick(event);
82 | }
83 | };
84 |
85 | if (tagName === 'a') {
86 | // Ensure there's a href so Enter can trigger anchor button.
87 | href ||= '#';
88 | if (disabled) {
89 | href = undefined;
90 | }
91 | }
92 |
93 | return [
94 | {
95 | role: role ?? 'button',
96 | // explicitly undefined so that it overrides the props disabled in a spread
97 | // e.g.
98 | disabled: undefined,
99 | tabIndex: disabled ? undefined : tabIndex,
100 | href,
101 | target: tagName === 'a' ? target : undefined,
102 | 'aria-disabled': !disabled ? undefined : disabled,
103 | rel: tagName === 'a' ? rel : undefined,
104 | onClick: handleClick,
105 | onKeyDown: handleKeyDown,
106 | },
107 | meta,
108 | ];
109 | }
110 |
111 | export interface BaseButtonProps {
112 | /**
113 | * Control the underlying rendered element directly by passing in a valid
114 | * component type
115 | */
116 | as?: keyof React.JSX.IntrinsicElements | undefined;
117 |
118 | /** The disabled state of the button */
119 | disabled?: boolean | undefined;
120 |
121 | /** Optionally specify an href to render a `` tag styled as a button */
122 | href?: string | undefined;
123 |
124 | /** Anchor target, when rendering an anchor as a button */
125 | target?: string | undefined;
126 |
127 | rel?: string | undefined;
128 | }
129 |
130 | export interface ButtonProps
131 | extends BaseButtonProps,
132 | React.ComponentPropsWithoutRef<'button'> {}
133 |
134 | const Button = React.forwardRef(
135 | ({ as: asProp, disabled, ...props }, ref) => {
136 | const [buttonProps, { tagName: Component }] = useButtonProps({
137 | tagName: asProp,
138 | disabled,
139 | ...props,
140 | });
141 |
142 | return ;
143 | },
144 | );
145 |
146 | Button.displayName = 'Button';
147 |
148 | export default Button;
149 |
--------------------------------------------------------------------------------
/src/ModalManager.ts:
--------------------------------------------------------------------------------
1 | import css from 'dom-helpers/css';
2 | import { dataAttr } from './DataKey.js';
3 | import getBodyScrollbarWidth from './getScrollbarWidth.js';
4 |
5 | export interface ModalInstance {
6 | dialog: Element;
7 | backdrop: Element;
8 | }
9 |
10 | export interface ModalManagerOptions {
11 | ownerDocument?: Document;
12 | handleContainerOverflow?: boolean;
13 | isRTL?: boolean;
14 | }
15 |
16 | export type ContainerState = {
17 | scrollBarWidth: number;
18 | style: Record;
19 | [key: string]: any;
20 | };
21 |
22 | export const OPEN_DATA_ATTRIBUTE = dataAttr('modal-open');
23 |
24 | /**
25 | * Manages a stack of Modals as well as ensuring
26 | * body scrolling is is disabled and padding accounted for
27 | */
28 | class ModalManager {
29 | readonly handleContainerOverflow: boolean;
30 |
31 | readonly isRTL: boolean;
32 |
33 | readonly modals: ModalInstance[];
34 |
35 | protected state!: ContainerState;
36 |
37 | protected ownerDocument: Document | undefined;
38 |
39 | constructor({
40 | ownerDocument,
41 | handleContainerOverflow = true,
42 | isRTL = false,
43 | }: ModalManagerOptions = {}) {
44 | this.handleContainerOverflow = handleContainerOverflow;
45 | this.isRTL = isRTL;
46 | this.modals = [];
47 | this.ownerDocument = ownerDocument;
48 | }
49 |
50 | getScrollbarWidth() {
51 | return getBodyScrollbarWidth(this.ownerDocument);
52 | }
53 |
54 | getElement() {
55 | return (this.ownerDocument || document).body;
56 | }
57 |
58 | setModalAttributes(_modal: ModalInstance) {
59 | // For overriding
60 | }
61 |
62 | removeModalAttributes(_modal: ModalInstance) {
63 | // For overriding
64 | }
65 |
66 | setContainerStyle(containerState: ContainerState) {
67 | const style: Partial = { overflow: 'hidden' };
68 |
69 | // we are only interested in the actual `style` here
70 | // because we will override it
71 | const paddingProp = this.isRTL ? 'paddingLeft' : 'paddingRight';
72 | const container = this.getElement();
73 |
74 | containerState.style = {
75 | overflow: container.style.overflow,
76 | [paddingProp]: container.style[paddingProp],
77 | };
78 |
79 | if (containerState.scrollBarWidth) {
80 | // use computed style, here to get the real padding
81 | // to add our scrollbar width
82 | style[paddingProp] = `${
83 | parseInt(css(container, paddingProp) || '0', 10) +
84 | containerState.scrollBarWidth
85 | }px`;
86 | }
87 | container.setAttribute(OPEN_DATA_ATTRIBUTE, '');
88 |
89 | css(container, style as any);
90 | }
91 |
92 | reset() {
93 | [...this.modals].forEach((m) => this.remove(m));
94 | }
95 |
96 | removeContainerStyle(containerState: ContainerState) {
97 | const container = this.getElement();
98 | container.removeAttribute(OPEN_DATA_ATTRIBUTE);
99 | Object.assign(container.style, containerState.style);
100 | }
101 |
102 | add(modal: ModalInstance) {
103 | let modalIdx = this.modals.indexOf(modal);
104 |
105 | if (modalIdx !== -1) {
106 | return modalIdx;
107 | }
108 |
109 | modalIdx = this.modals.length;
110 | this.modals.push(modal);
111 | this.setModalAttributes(modal);
112 | if (modalIdx !== 0) {
113 | return modalIdx;
114 | }
115 |
116 | this.state = {
117 | scrollBarWidth: this.getScrollbarWidth(),
118 | style: {},
119 | };
120 |
121 | if (this.handleContainerOverflow) {
122 | this.setContainerStyle(this.state);
123 | }
124 |
125 | return modalIdx;
126 | }
127 |
128 | remove(modal: ModalInstance) {
129 | const modalIdx = this.modals.indexOf(modal);
130 |
131 | if (modalIdx === -1) {
132 | return;
133 | }
134 |
135 | this.modals.splice(modalIdx, 1);
136 |
137 | // if that was the last modal in a container,
138 | // clean up the container
139 | if (!this.modals.length && this.handleContainerOverflow) {
140 | this.removeContainerStyle(this.state);
141 | }
142 |
143 | this.removeModalAttributes(modal);
144 | }
145 |
146 | isTopModal(modal: ModalInstance) {
147 | return (
148 | !!this.modals.length && this.modals[this.modals.length - 1] === modal
149 | );
150 | }
151 | }
152 |
153 | export default ModalManager;
154 |
--------------------------------------------------------------------------------
/test/NavItemSpec.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render, fireEvent } from '@testing-library/react';
3 | import { expect, describe, it, vi } from 'vitest';
4 |
5 | import NavContext from '../src/NavContext';
6 | import NavItem from '../src/NavItem';
7 | import SelectableContext from '../src/SelectableContext';
8 |
9 | describe('', () => {
10 | it('should output a nav item as button', () => {
11 | const { getByText } = render(test);
12 |
13 | expect(getByText('test').tagName).toEqual('BUTTON');
14 | });
15 |
16 | it('should output custom role', () => {
17 | const { getByRole } = render(test);
18 | expect(getByRole('abc')).toBeTruthy();
19 | });
20 |
21 | it('should set role to tab if inside nav context', () => {
22 | const { getByRole } = render(
23 |
31 | test
32 | ,
33 | );
34 |
35 | expect(getByRole('tab')).toBeTruthy();
36 | });
37 |
38 | it('should not override custom role if inside nav context', () => {
39 | const { getByRole } = render(
40 |
48 | test
49 | ,
50 | );
51 |
52 | expect(getByRole('abc')).toBeTruthy();
53 | });
54 |
55 | it('should use active from nav context', () => {
56 | const { getByText } = render(
57 |
65 | test
66 | ,
67 | );
68 |
69 | expect(getByText('test').getAttribute('data-rr-ui-active')).to.equal(
70 | 'true',
71 | );
72 | });
73 |
74 | it('should set disabled attributes when nav item is disabled and role is tab', () => {
75 | const { getByText } = render(
76 |
77 | test
78 | ,
79 | );
80 | const node = getByText('test');
81 | expect(node.getAttribute('aria-disabled')).to.equal('true');
82 | expect(node.tabIndex).toEqual(-1);
83 | });
84 |
85 | it('should trigger onClick', () => {
86 | const onClickSpy = vi.fn();
87 | const { getByText } = render(test);
88 | fireEvent.click(getByText('test'));
89 | expect(onClickSpy).toHaveBeenCalled();
90 | });
91 |
92 | it('should not trigger onClick if disabled', () => {
93 | const onClickSpy = vi.fn();
94 | const { getByText } = render(
95 | // Render as div because onClick won't get triggered with Button when disabled.
96 |
97 | test
98 | ,
99 | );
100 | fireEvent.click(getByText('test'));
101 | expect(onClickSpy).not.toHaveBeenCalled();
102 | });
103 |
104 | it('should call onSelect if a key is defined', () => {
105 | const onSelect = vi.fn();
106 | const { getByText } = render(
107 |
108 | test
109 | ,
110 | );
111 |
112 | fireEvent.click(getByText('test'));
113 | expect(onSelect.mock.calls[0][0]).toEqual('abc');
114 | });
115 |
116 | it('should not call onSelect onClick stopPropagation called', () => {
117 | const onSelect = vi.fn();
118 | const handleClick = (e: React.MouseEvent) => {
119 | e.stopPropagation();
120 | };
121 | const { getByText } = render(
122 |
123 |
124 | test
125 |
126 | ,
127 | );
128 |
129 | fireEvent.click(getByText('test'));
130 | expect(onSelect).not.toHaveBeenCalled();
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/www/docs/Overlay.mdx:
--------------------------------------------------------------------------------
1 | A flexible base component for building tooltips, popups, and any other floating UI components.
2 | `Overlay` combines [Popper](https://popper.js.org/), click-to-dismiss, and optional transitions for
3 | rendering floating UI relative to another element.
4 |
5 | ## Creating an Overlay
6 |
7 | Overlays consist of at least two elements, the "overlay", the element to be positioned,
8 | as well as a "target", the element the overlay is positioned in relation to. Popper
9 | also provides functionality for optional tooltip "arrows" like in the example below.
10 | Be sure to check out the Popper documentation for more details about the underlying
11 | positioning engine.
12 |
13 | ```jsx live editor=collapse
14 | import clsx from "clsx";
15 | import { Overlay } from "@restart/ui";
16 | import Button from "../src/Button";
17 | import Tooltip from "../src/Tooltip";
18 |
19 | const PLACEMENTS = ["left", "top", "right", "bottom"];
20 |
21 | const initialSstate = {
22 | show: false,
23 | placement: null,
24 | };
25 |
26 | function reducer(state, [type, payload]) {
27 | switch (type) {
28 | case "placement":
29 | return { show: !!payload, placement: payload };
30 | default:
31 | return state;
32 | }
33 | }
34 |
35 | function OverlayExample() {
36 | const [{ show, placement }, dispatch] = useReducer(
37 | reducer,
38 | initialSstate
39 | );
40 | const triggerRef = useRef(null);
41 | const containerRef = useRef(null);
42 |
43 | const handleClick = () => {
44 | const nextPlacement =
45 | PLACEMENTS[PLACEMENTS.indexOf(placement) + 1];
46 |
47 | dispatch(["placement", nextPlacement]);
48 | };
49 |
50 | return (
51 |
55 |
63 |
Keep clicking to see the placement change.
64 |
65 |
73 | {(props, { arrowProps, popper, show }) => (
74 |
75 | I’m placed to the{" "}
76 | {popper.placement}
77 |
78 | )}
79 |
80 |
81 | );
82 | }
83 |
84 | ;
85 | ```
86 |
87 | ## Animations
88 |
89 | Overlays support `react-transition-group` compliant Transition components (though you are welcome to use something else).
90 |
91 | ```jsx live
92 | import Transition from "react-transition-group/Transition";
93 | import clsx from "clsx";
94 | import { Overlay } from "@restart/ui";
95 | import Tooltip from "../src/Tooltip";
96 |
97 | function Fade({ children, ...props }) {
98 | return (
99 | {
103 | // trigger a reflow
104 | node.offsetWidth;
105 | }}
106 | >
107 | {(status, innerProps) =>
108 | React.cloneElement(children, {
109 | ...innerProps,
110 | className: clsx(
111 | "transition-opacity duration-300",
112 | status === "entering" || status === "entered"
113 | ? "opacity-1"
114 | : "opacity-0"
115 | ),
116 | })
117 | }
118 |
119 | );
120 | }
121 |
122 | function Example() {
123 | const ref = useRef(null);
124 | const [show, setShow] = useState(false);
125 | return (
126 | <>
127 | setShow(true)}
131 | onMouseLeave={() => setShow(false)}
132 | >
133 | A link with a tooltip
134 |
135 |
142 | {(props, { arrowProps, popper }) => (
143 |
148 | I am a tooltip
149 |
150 | )}
151 |
152 | >
153 | );
154 | }
155 | ```
156 |
--------------------------------------------------------------------------------
/src/TabPanel.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useContext } from 'react';
3 |
4 | import TabContext from './TabContext.js';
5 | import SelectableContext, { makeEventKey } from './SelectableContext.js';
6 | import type {
7 | EventKey,
8 | DynamicRefForwardingComponent,
9 | TransitionCallbacks,
10 | TransitionComponent,
11 | } from './types.js';
12 | import NoopTransition from './NoopTransition.js';
13 |
14 | export interface TabPanelProps
15 | extends TransitionCallbacks,
16 | React.HTMLAttributes {
17 | /**
18 | * Element used to render the component.
19 | */
20 | as?: React.ElementType;
21 |
22 | /**
23 | * A key that associates the `TabPanel` with it's controlling `NavLink`.
24 | */
25 | eventKey?: EventKey;
26 |
27 | /**
28 | * Toggles the active state of the TabPanel, this is generally controlled by `Tabs`.
29 | */
30 | active?: boolean;
31 |
32 | /**
33 | * Use animation when showing or hiding ``s. Use a react-transition-group
34 | * `` component.
35 | */
36 | transition?: TransitionComponent;
37 |
38 | /**
39 | * Wait until the first "enter" transition to mount the tab (add it to the DOM)
40 | */
41 | mountOnEnter?: boolean;
42 |
43 | /**
44 | * Unmount the tab (remove it from the DOM) when it is no longer visible
45 | */
46 | unmountOnExit?: boolean;
47 | }
48 |
49 | export interface TabPanelMetadata extends TransitionCallbacks {
50 | eventKey?: EventKey;
51 | isActive?: boolean;
52 | transition?: TransitionComponent;
53 | mountOnEnter?: boolean;
54 | unmountOnExit?: boolean;
55 | }
56 |
57 | export function useTabPanel({
58 | active,
59 | eventKey,
60 | mountOnEnter,
61 | transition,
62 | unmountOnExit,
63 | role = 'tabpanel',
64 | onEnter,
65 | onEntering,
66 | onEntered,
67 | onExit,
68 | onExiting,
69 | onExited,
70 | ...props
71 | }: TabPanelProps): [any, TabPanelMetadata] {
72 | const context = useContext(TabContext);
73 |
74 | if (!context)
75 | return [
76 | {
77 | ...props,
78 | role,
79 | },
80 | {
81 | eventKey,
82 | isActive: active,
83 | mountOnEnter,
84 | transition,
85 | unmountOnExit,
86 | onEnter,
87 | onEntering,
88 | onEntered,
89 | onExit,
90 | onExiting,
91 | onExited,
92 | },
93 | ];
94 |
95 | const { activeKey, getControlledId, getControllerId, ...rest } = context;
96 | const key = makeEventKey(eventKey);
97 |
98 | return [
99 | {
100 | ...props,
101 | role,
102 | id: getControlledId(eventKey!),
103 | 'aria-labelledby': getControllerId(eventKey!),
104 | },
105 | {
106 | eventKey,
107 | isActive:
108 | active == null && key != null
109 | ? makeEventKey(activeKey) === key
110 | : active,
111 | transition: transition || rest.transition,
112 | mountOnEnter: mountOnEnter != null ? mountOnEnter : rest.mountOnEnter,
113 | unmountOnExit: unmountOnExit != null ? unmountOnExit : rest.unmountOnExit,
114 | onEnter,
115 | onEntering,
116 | onEntered,
117 | onExit,
118 | onExiting,
119 | onExited,
120 | },
121 | ];
122 | }
123 |
124 | const TabPanel: DynamicRefForwardingComponent<'div', TabPanelProps> =
125 | React.forwardRef(
126 | // Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595
127 | ({ as: Component = 'div', ...props }, ref) => {
128 | const [
129 | tabPanelProps,
130 | {
131 | isActive,
132 | onEnter,
133 | onEntering,
134 | onEntered,
135 | onExit,
136 | onExiting,
137 | onExited,
138 | mountOnEnter,
139 | unmountOnExit,
140 | transition: Transition = NoopTransition,
141 | },
142 | ] = useTabPanel(props);
143 | // We provide an empty the TabContext so `