Skip to content

Commit a929e04

Browse files
committed
feat(utils): added Dir component to help determine current writing direction
Adding this functionality since it'll be needed for creating sliders with the drag behavior. It'll also be helpful for other customization and components like creating your own language toggle.
1 parent d3f855d commit a929e04

File tree

8 files changed

+379
-65
lines changed

8 files changed

+379
-65
lines changed

packages/documentation/src/components/Layout/ToggleRTL.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import {
55
FormatAlignRightSVGIcon,
66
} from "@react-md/material-icons";
77
import { Tooltipped } from "@react-md/tooltip";
8-
import useRTLToggle from "./useRTLToggle";
8+
import { useDir } from "@react-md/utils";
99

1010
const ToggleRTL: FC = () => {
11-
const { isRTL, toggleRTL } = useRTLToggle();
11+
const { dir, toggleDir } = useDir();
12+
const isRTL = dir === "rtl";
1213

1314
return (
1415
<Tooltipped id="toggle-rtl" tooltip="Toggle right to left">
1516
<AppBarAction
1617
last
17-
onClick={toggleRTL}
18+
onClick={toggleDir}
1819
aria-label="Right to left layout"
1920
aria-pressed={isRTL}
2021
>

packages/documentation/src/components/Layout/ToggleRTLMenuItem.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ import {
44
FormatAlignRightSVGIcon,
55
} from "@react-md/material-icons";
66
import { MenuItem } from "@react-md/menu";
7-
import useRTLToggle from "./useRTLToggle";
7+
import { useDir } from "@react-md/utils";
88

99
const ToggleRTLMenuItem: FC = () => {
10-
const { isRTL, toggleRTL } = useRTLToggle();
10+
const { dir, toggleDir } = useDir();
11+
const isRTL = dir === "rtl";
1112

1213
return (
1314
<MenuItem
1415
id="toggle-rtl"
15-
onClick={toggleRTL}
16+
onClick={toggleDir}
1617
leftAddon={
1718
isRTL ? <FormatAlignRightSVGIcon /> : <FormatAlignLeftSVGIcon />
1819
}

packages/documentation/src/components/Layout/useRTLToggle.tsx

-30
This file was deleted.

packages/layout/src/Configuration.tsx

+48-29
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import {
2222
DEFAULT_PHONE_MAX_WIDTH,
2323
DEFAULT_TABLET_MAX_WIDTH,
2424
DEFAULT_TABLET_MIN_WIDTH,
25+
DEFAULT_DIR,
26+
Dir,
2527
InteractionModeListener,
28+
WritingDirection,
2629
} from "@react-md/utils";
2730

2831
export interface ConfigurationProps extends AppSizeOptions, StatesConfigProps {
@@ -65,6 +68,15 @@ export interface ConfigurationProps extends AppSizeOptions, StatesConfigProps {
6568
* An object of any overrides for the `FormThemeProvider`.
6669
*/
6770
formTheme?: FormThemeOptions;
71+
72+
/**
73+
* The current writing direction for your app. This defaults to `"ltr"` but
74+
* should be changed to `"rtl"` if using a language that is read from right to
75+
* left.
76+
*
77+
* @since 2.3.0
78+
*/
79+
defaultDir?: WritingDirection;
6880
}
6981

7082
/**
@@ -76,6 +88,7 @@ const Configuration: FC<ConfigurationProps> = ({
7688
children,
7789
icons,
7890
formTheme,
91+
defaultDir = DEFAULT_DIR,
7992
phoneMaxWidth = DEFAULT_PHONE_MAX_WIDTH,
8093
tabletMinWidth = DEFAULT_TABLET_MIN_WIDTH,
8194
tabletMaxWidth = DEFAULT_TABLET_MAX_WIDTH,
@@ -90,36 +103,38 @@ const Configuration: FC<ConfigurationProps> = ({
90103
tooltipDefaultDelay = DEFAULT_TOOLTIP_DELAY,
91104
tooltipDelayTimeout = DEFAULT_TOOLTIP_DELAY,
92105
}) => (
93-
<AppSizeListener
94-
defaultSize={defaultSize}
95-
onChange={onAppResize}
96-
phoneMaxWidth={phoneMaxWidth}
97-
tabletMinWidth={tabletMinWidth}
98-
tabletMaxWidth={tabletMaxWidth}
99-
desktopMinWidth={desktopMinWidth}
100-
desktopLargeMinWidth={desktopLargeMinWidth}
101-
>
102-
<NestedDialogContextProvider>
103-
<InteractionModeListener>
104-
<StatesConfig
105-
disableRipple={disableRipple}
106-
disableProgrammaticRipple={disableProgrammaticRipple}
107-
rippleTimeout={rippleTimeout}
108-
rippleClassNames={rippleClassNames}
109-
>
110-
<TooltipHoverModeConfig
111-
enabled={!disableTooltipHoverMode}
112-
defaultDelay={tooltipDefaultDelay}
113-
delayTimeout={tooltipDelayTimeout}
106+
<Dir defaultDir={defaultDir}>
107+
<AppSizeListener
108+
defaultSize={defaultSize}
109+
onChange={onAppResize}
110+
phoneMaxWidth={phoneMaxWidth}
111+
tabletMinWidth={tabletMinWidth}
112+
tabletMaxWidth={tabletMaxWidth}
113+
desktopMinWidth={desktopMinWidth}
114+
desktopLargeMinWidth={desktopLargeMinWidth}
115+
>
116+
<NestedDialogContextProvider>
117+
<InteractionModeListener>
118+
<StatesConfig
119+
disableRipple={disableRipple}
120+
disableProgrammaticRipple={disableProgrammaticRipple}
121+
rippleTimeout={rippleTimeout}
122+
rippleClassNames={rippleClassNames}
114123
>
115-
<IconProvider {...icons}>
116-
<FormThemeProvider {...formTheme}>{children}</FormThemeProvider>
117-
</IconProvider>
118-
</TooltipHoverModeConfig>
119-
</StatesConfig>
120-
</InteractionModeListener>
121-
</NestedDialogContextProvider>
122-
</AppSizeListener>
124+
<TooltipHoverModeConfig
125+
enabled={!disableTooltipHoverMode}
126+
defaultDelay={tooltipDefaultDelay}
127+
delayTimeout={tooltipDelayTimeout}
128+
>
129+
<IconProvider {...icons}>
130+
<FormThemeProvider {...formTheme}>{children}</FormThemeProvider>
131+
</IconProvider>
132+
</TooltipHoverModeConfig>
133+
</StatesConfig>
134+
</InteractionModeListener>
135+
</NestedDialogContextProvider>
136+
</AppSizeListener>
137+
</Dir>
123138
);
124139

125140
if (process.env.NODE_ENV !== "production") {
@@ -181,6 +196,10 @@ if (process.env.NODE_ENV !== "production") {
181196
theme: PropTypes.oneOf(["none", "underline", "outline", "filled"]),
182197
underlineDirection: PropTypes.oneOf(["left", "center", "right"]),
183198
}),
199+
defaultDir: PropTypes.oneOfType([
200+
PropTypes.func,
201+
PropTypes.oneOf(["ltr", "rtl"]),
202+
]),
184203
};
185204
} catch (e) {}
186205
}

packages/utils/src/Dir.tsx

+180
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import React, {
2+
Children,
3+
cloneElement,
4+
createContext,
5+
ReactElement,
6+
useCallback,
7+
useContext,
8+
useEffect,
9+
useMemo,
10+
useState,
11+
} from "react";
12+
13+
/**
14+
* Note: unlike the `dir` DOM attribute, the `"auto"` value is not supported
15+
*
16+
* @since 2.3.0
17+
*/
18+
export type WritingDirection = "ltr" | "rtl";
19+
20+
/**
21+
* @since 2.3.0
22+
*/
23+
export interface WritingDirectionContext {
24+
/**
25+
* The current writing direction that is being inherited.
26+
*/
27+
dir: WritingDirection;
28+
29+
/**
30+
* Toggles the current writing direction for the first parent `Dir` component.
31+
*/
32+
toggleDir(): void;
33+
}
34+
35+
/**
36+
* @internal
37+
*/
38+
interface InheritableContext extends WritingDirectionContext {
39+
root: boolean;
40+
}
41+
42+
const context = createContext<InheritableContext>({
43+
root: true,
44+
dir: "ltr",
45+
toggleDir: () => {
46+
throw new Error(
47+
"Tried to toggle the current writing direction without initializing the `Dir` component."
48+
);
49+
},
50+
});
51+
const { Provider } = context;
52+
53+
/**
54+
* Gets the writing direction context which provides access to the current `dir`
55+
* and a `toggleDir` function.
56+
*
57+
* @since 2.3.0
58+
*/
59+
export function useDir(): WritingDirectionContext {
60+
const { root: _root, ...current } = useContext(context);
61+
return current;
62+
}
63+
64+
/**
65+
* @since 2.3.0
66+
*/
67+
export interface DirProps {
68+
/**
69+
* A single ReactElement child. If the `Dir` has a parent `Dir`, the child
70+
* will have the `dir` prop cloned into this element.
71+
*/
72+
children: ReactElement;
73+
74+
/**
75+
* The default writing direction for your app or a subtree. To change the
76+
* current writing direction, use the `useDir` hook to get access to the
77+
* current `dir` and the `toggleDir` function.
78+
*/
79+
defaultDir?: WritingDirection | (() => WritingDirection);
80+
}
81+
82+
/**
83+
* @since 2.3.0
84+
*/
85+
export const DEFAULT_DIR = (): WritingDirection => {
86+
let dir: WritingDirection = "ltr";
87+
if (typeof document !== "undefined") {
88+
const rootDir = document.documentElement.getAttribute("dir");
89+
dir = rootDir === "rtl" ? "rtl" : "ltr";
90+
}
91+
92+
return dir;
93+
};
94+
95+
/**
96+
* The `Dir` component is used to handle the current writing direction within
97+
* your app as well as conditionally updating the writing direction for small
98+
* sections in your app. When this component is used for the first time near the
99+
* root of your React component tree, the current direction will be applied to
100+
* the root `<html>` element. Otherwise the current dir will be cloned into the
101+
* child element so it can be passed as a prop.
102+
*
103+
* ```tsx
104+
* // html element will be updated to have `dir="ltr"`
105+
* ReactDOM.render(<Dir><App /></Dir>, root)
106+
* ```
107+
*
108+
* ```tsx
109+
* // html element will be updated to have `dir="rtl"` while the `<span>` will
110+
* // now be `<span dir="ltr">`
111+
* ReactDOM.render(
112+
* <Dir defaultDir="rtl">
113+
* <Some>
114+
* <Other>
115+
* <Components>
116+
* <Dir defaultDir="ltr">
117+
* <span>Content</span>
118+
* </Dir>
119+
* </Components>
120+
* </Other>
121+
* </Some>
122+
* </Dir>,
123+
* root
124+
* );
125+
*
126+
* Note: Since the `dir` is cloned into the child element, you need to make sure
127+
* that the child is either a DOM element or the `dir` prop is passed from your
128+
* custom component.
129+
*
130+
* @since 2.3.0
131+
*/
132+
export function Dir({
133+
children,
134+
defaultDir = DEFAULT_DIR,
135+
}: DirProps): ReactElement {
136+
const { root } = useContext(context);
137+
const [dir, setDir] = useState(defaultDir);
138+
useEffect(() => {
139+
if (!root || typeof document === "undefined") {
140+
return;
141+
}
142+
143+
document.documentElement.setAttribute("dir", dir);
144+
145+
return () => {
146+
document.documentElement.removeAttribute("dir");
147+
};
148+
}, [dir, root]);
149+
150+
const toggleDir = useCallback(() => {
151+
setDir((prevDir) => (prevDir === "ltr" ? "rtl" : "ltr"));
152+
}, []);
153+
154+
const value = useMemo<InheritableContext>(
155+
() => ({ root: false, dir, toggleDir }),
156+
[dir, toggleDir]
157+
);
158+
let child = Children.only(children);
159+
if (!root) {
160+
child = cloneElement(child, { dir });
161+
}
162+
163+
return <Provider value={value}>{child}</Provider>;
164+
}
165+
166+
if (process.env.NODE_ENV !== "production") {
167+
context.displayName = "WritingDirection";
168+
169+
try {
170+
const PropTypes = require("prop-types");
171+
172+
Dir.propTypes = {
173+
children: PropTypes.element.isRequired,
174+
defaultDir: PropTypes.oneOfType([
175+
PropTypes.func,
176+
PropTypes.oneOf(["ltr", "rtl"]),
177+
]),
178+
};
179+
} catch (e) {}
180+
}
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import React from "react";
6+
import { renderToString } from "react-dom/server";
7+
8+
import { Dir, useDir, WritingDirection } from "../Dir";
9+
10+
describe("Dir", () => {
11+
it('should default to "ltr" for environments that do not have a document and not crash', () => {
12+
expect(typeof document).toBe("undefined");
13+
let dir: WritingDirection | undefined;
14+
const Child = () => {
15+
({ dir } = useDir());
16+
return null;
17+
};
18+
19+
renderToString(
20+
<Dir>
21+
<Child />
22+
</Dir>
23+
);
24+
expect(dir).toBe("ltr");
25+
});
26+
});

0 commit comments

Comments
 (0)