Skip to content
Permalink
Browse files
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.
  • Loading branch information
mlaursen committed Aug 29, 2020
1 parent d3f855d commit a929e04b20bf41c3bff109714d9cf850bac99eb3
Show file tree
Hide file tree
Showing 8 changed files with 379 additions and 65 deletions.
@@ -5,16 +5,17 @@ import {
FormatAlignRightSVGIcon,
} from "@react-md/material-icons";
import { Tooltipped } from "@react-md/tooltip";
import useRTLToggle from "./useRTLToggle";
import { useDir } from "@react-md/utils";

const ToggleRTL: FC = () => {
const { isRTL, toggleRTL } = useRTLToggle();
const { dir, toggleDir } = useDir();
const isRTL = dir === "rtl";

return (
<Tooltipped id="toggle-rtl" tooltip="Toggle right to left">
<AppBarAction
last
onClick={toggleRTL}
onClick={toggleDir}
aria-label="Right to left layout"
aria-pressed={isRTL}
>
@@ -4,15 +4,16 @@ import {
FormatAlignRightSVGIcon,
} from "@react-md/material-icons";
import { MenuItem } from "@react-md/menu";
import useRTLToggle from "./useRTLToggle";
import { useDir } from "@react-md/utils";

const ToggleRTLMenuItem: FC = () => {
const { isRTL, toggleRTL } = useRTLToggle();
const { dir, toggleDir } = useDir();
const isRTL = dir === "rtl";

return (
<MenuItem
id="toggle-rtl"
onClick={toggleRTL}
onClick={toggleDir}
leftAddon={
isRTL ? <FormatAlignRightSVGIcon /> : <FormatAlignLeftSVGIcon />
}

This file was deleted.

@@ -22,7 +22,10 @@ import {
DEFAULT_PHONE_MAX_WIDTH,
DEFAULT_TABLET_MAX_WIDTH,
DEFAULT_TABLET_MIN_WIDTH,
DEFAULT_DIR,
Dir,
InteractionModeListener,
WritingDirection,
} from "@react-md/utils";

export interface ConfigurationProps extends AppSizeOptions, StatesConfigProps {
@@ -65,6 +68,15 @@ export interface ConfigurationProps extends AppSizeOptions, StatesConfigProps {
* An object of any overrides for the `FormThemeProvider`.
*/
formTheme?: FormThemeOptions;

/**
* The current writing direction for your app. This defaults to `"ltr"` but
* should be changed to `"rtl"` if using a language that is read from right to
* left.
*
* @since 2.3.0
*/
defaultDir?: WritingDirection;
}

/**
@@ -76,6 +88,7 @@ const Configuration: FC<ConfigurationProps> = ({
children,
icons,
formTheme,
defaultDir = DEFAULT_DIR,
phoneMaxWidth = DEFAULT_PHONE_MAX_WIDTH,
tabletMinWidth = DEFAULT_TABLET_MIN_WIDTH,
tabletMaxWidth = DEFAULT_TABLET_MAX_WIDTH,
@@ -90,36 +103,38 @@ const Configuration: FC<ConfigurationProps> = ({
tooltipDefaultDelay = DEFAULT_TOOLTIP_DELAY,
tooltipDelayTimeout = DEFAULT_TOOLTIP_DELAY,
}) => (
<AppSizeListener
defaultSize={defaultSize}
onChange={onAppResize}
phoneMaxWidth={phoneMaxWidth}
tabletMinWidth={tabletMinWidth}
tabletMaxWidth={tabletMaxWidth}
desktopMinWidth={desktopMinWidth}
desktopLargeMinWidth={desktopLargeMinWidth}
>
<NestedDialogContextProvider>
<InteractionModeListener>
<StatesConfig
disableRipple={disableRipple}
disableProgrammaticRipple={disableProgrammaticRipple}
rippleTimeout={rippleTimeout}
rippleClassNames={rippleClassNames}
>
<TooltipHoverModeConfig
enabled={!disableTooltipHoverMode}
defaultDelay={tooltipDefaultDelay}
delayTimeout={tooltipDelayTimeout}
<Dir defaultDir={defaultDir}>
<AppSizeListener
defaultSize={defaultSize}
onChange={onAppResize}
phoneMaxWidth={phoneMaxWidth}
tabletMinWidth={tabletMinWidth}
tabletMaxWidth={tabletMaxWidth}
desktopMinWidth={desktopMinWidth}
desktopLargeMinWidth={desktopLargeMinWidth}
>
<NestedDialogContextProvider>
<InteractionModeListener>
<StatesConfig
disableRipple={disableRipple}
disableProgrammaticRipple={disableProgrammaticRipple}
rippleTimeout={rippleTimeout}
rippleClassNames={rippleClassNames}
>
<IconProvider {...icons}>
<FormThemeProvider {...formTheme}>{children}</FormThemeProvider>
</IconProvider>
</TooltipHoverModeConfig>
</StatesConfig>
</InteractionModeListener>
</NestedDialogContextProvider>
</AppSizeListener>
<TooltipHoverModeConfig
enabled={!disableTooltipHoverMode}
defaultDelay={tooltipDefaultDelay}
delayTimeout={tooltipDelayTimeout}
>
<IconProvider {...icons}>
<FormThemeProvider {...formTheme}>{children}</FormThemeProvider>
</IconProvider>
</TooltipHoverModeConfig>
</StatesConfig>
</InteractionModeListener>
</NestedDialogContextProvider>
</AppSizeListener>
</Dir>
);

if (process.env.NODE_ENV !== "production") {
@@ -181,6 +196,10 @@ if (process.env.NODE_ENV !== "production") {
theme: PropTypes.oneOf(["none", "underline", "outline", "filled"]),
underlineDirection: PropTypes.oneOf(["left", "center", "right"]),
}),
defaultDir: PropTypes.oneOfType([
PropTypes.func,
PropTypes.oneOf(["ltr", "rtl"]),
]),
};
} catch (e) {}
}
@@ -0,0 +1,180 @@
import React, {
Children,
cloneElement,
createContext,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";

/**
* Note: unlike the `dir` DOM attribute, the `"auto"` value is not supported
*
* @since 2.3.0
*/
export type WritingDirection = "ltr" | "rtl";

/**
* @since 2.3.0
*/
export interface WritingDirectionContext {
/**
* The current writing direction that is being inherited.
*/
dir: WritingDirection;

/**
* Toggles the current writing direction for the first parent `Dir` component.
*/
toggleDir(): void;
}

/**
* @internal
*/
interface InheritableContext extends WritingDirectionContext {
root: boolean;
}

const context = createContext<InheritableContext>({
root: true,
dir: "ltr",
toggleDir: () => {
throw new Error(
"Tried to toggle the current writing direction without initializing the `Dir` component."
);
},
});
const { Provider } = context;

/**
* Gets the writing direction context which provides access to the current `dir`
* and a `toggleDir` function.
*
* @since 2.3.0
*/
export function useDir(): WritingDirectionContext {
const { root: _root, ...current } = useContext(context);
return current;
}

/**
* @since 2.3.0
*/
export interface DirProps {
/**
* A single ReactElement child. If the `Dir` has a parent `Dir`, the child
* will have the `dir` prop cloned into this element.
*/
children: ReactElement;

/**
* The default writing direction for your app or a subtree. To change the
* current writing direction, use the `useDir` hook to get access to the
* current `dir` and the `toggleDir` function.
*/
defaultDir?: WritingDirection | (() => WritingDirection);
}

/**
* @since 2.3.0
*/
export const DEFAULT_DIR = (): WritingDirection => {
let dir: WritingDirection = "ltr";
if (typeof document !== "undefined") {
const rootDir = document.documentElement.getAttribute("dir");
dir = rootDir === "rtl" ? "rtl" : "ltr";
}

return dir;
};

/**
* The `Dir` component is used to handle the current writing direction within
* your app as well as conditionally updating the writing direction for small
* sections in your app. When this component is used for the first time near the
* root of your React component tree, the current direction will be applied to
* the root `<html>` element. Otherwise the current dir will be cloned into the
* child element so it can be passed as a prop.
*
* ```tsx
* // html element will be updated to have `dir="ltr"`
* ReactDOM.render(<Dir><App /></Dir>, root)
* ```
*
* ```tsx
* // html element will be updated to have `dir="rtl"` while the `<span>` will
* // now be `<span dir="ltr">`
* ReactDOM.render(
* <Dir defaultDir="rtl">
* <Some>
* <Other>
* <Components>
* <Dir defaultDir="ltr">
* <span>Content</span>
* </Dir>
* </Components>
* </Other>
* </Some>
* </Dir>,
* root
* );
*
* Note: Since the `dir` is cloned into the child element, you need to make sure
* that the child is either a DOM element or the `dir` prop is passed from your
* custom component.
*
* @since 2.3.0
*/
export function Dir({
children,
defaultDir = DEFAULT_DIR,
}: DirProps): ReactElement {
const { root } = useContext(context);
const [dir, setDir] = useState(defaultDir);
useEffect(() => {
if (!root || typeof document === "undefined") {
return;
}

document.documentElement.setAttribute("dir", dir);

return () => {
document.documentElement.removeAttribute("dir");
};
}, [dir, root]);

const toggleDir = useCallback(() => {
setDir((prevDir) => (prevDir === "ltr" ? "rtl" : "ltr"));
}, []);

const value = useMemo<InheritableContext>(
() => ({ root: false, dir, toggleDir }),
[dir, toggleDir]
);
let child = Children.only(children);
if (!root) {
child = cloneElement(child, { dir });
}

return <Provider value={value}>{child}</Provider>;
}

if (process.env.NODE_ENV !== "production") {
context.displayName = "WritingDirection";

try {
const PropTypes = require("prop-types");

Dir.propTypes = {
children: PropTypes.element.isRequired,
defaultDir: PropTypes.oneOfType([
PropTypes.func,
PropTypes.oneOf(["ltr", "rtl"]),
]),
};
} catch (e) {}
}
@@ -0,0 +1,26 @@
/**
* @jest-environment node
*/

import React from "react";
import { renderToString } from "react-dom/server";

import { Dir, useDir, WritingDirection } from "../Dir";

describe("Dir", () => {
it('should default to "ltr" for environments that do not have a document and not crash', () => {
expect(typeof document).toBe("undefined");
let dir: WritingDirection | undefined;
const Child = () => {
({ dir } = useDir());
return null;
};

renderToString(
<Dir>
<Child />
</Dir>
);
expect(dir).toBe("ltr");
});
});

0 comments on commit a929e04

Please sign in to comment.