feat: RTL layout (#12107)

* First pass

* Remove prop drilling, fix comment layout

* Revert dev:watch to use dev:backend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-18 15:12:57 -04:00
committed by GitHub
parent e6cfc45fb4
commit 49d5052a51
47 changed files with 244 additions and 153 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
height: 24px;
line-height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
max-width: 460px;
&:hover {
+3 -3
View File
@@ -125,7 +125,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-left: 4px;"};
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
`;
export const Inner = styled.span<{
@@ -135,13 +135,13 @@ export const Inner = styled.span<{
}>`
display: flex;
padding: 0 8px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
@@ -43,7 +43,8 @@ const Container = styled.div`
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding: 16px 0 4px 20px;
padding-block: 16px 4px;
padding-inline: 20px 0;
height: 36px;
cursor: default;
`;
+2 -2
View File
@@ -117,7 +117,7 @@ const Breadcrumbs = styled("div")`
flex-grow: 1;
flex-basis: 0;
align-items: center;
padding-right: 8px;
padding-inline: 0 8px;
display: flex;
`;
@@ -125,7 +125,7 @@ const Actions = styled(Flex)`
flex-grow: 1;
flex-basis: 0;
min-width: auto;
padding-left: 8px;
padding-inline: 8px 0;
gap: 12px;
${breakpoint("tablet")`
+6 -3
View File
@@ -95,7 +95,7 @@ export const Wrapper = styled.div<{
const IconWrapper = styled.span`
position: relative;
left: 4px;
inset-inline-start: 4px;
width: 24px;
height: 24px;
`;
@@ -132,11 +132,14 @@ export const Outline = styled(Flex)<{
const CharacterCount = styled.span<{ $warning?: boolean }>`
position: absolute;
top: 0;
right: 0;
inset-inline-end: 0;
font-size: 11px;
line-height: 1;
padding: 2px 4px;
border-radius: 0 0 0 2px;
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-end-radius: 0;
border-end-start-radius: 2px;
background: ${(props) =>
props.$warning ? props.theme.warning : props.theme.inputBorder};
color: ${(props) =>
+1 -1
View File
@@ -36,7 +36,7 @@ const PositionedSwatchButton = styled(SwatchButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
position: absolute;
bottom: 21px;
right: 6px;
inset-inline-end: 6px;
`;
export default InputColor;
+1 -1
View File
@@ -121,7 +121,7 @@ const Shortcut = styled.span<{ $visible: boolean }>`
flex-shrink: 0;
font-size: 13px;
color: ${s("textTertiary")};
padding-right: 10px;
padding-inline: 0 10px;
pointer-events: none;
opacity: ${(props) => (props.$visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
+2 -2
View File
@@ -365,8 +365,8 @@ const IconWrapper = styled.span`
align-items: center;
width: 24px;
height: 24px;
margin-left: -4px;
margin-right: 4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
overflow: hidden;
flex-shrink: 0;
`;
+4 -4
View File
@@ -56,7 +56,7 @@ const Layout = React.forwardRef(function Layout_(
sidebarCollapsed
? undefined
: {
marginLeft: `${ui.sidebarWidth}px`,
marginInlineStart: `${ui.sidebarWidth}px`,
}
}
>
@@ -86,21 +86,21 @@ type ContentProps = {
const Content = styled(Flex)<ContentProps>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
props.$isResizing ? "none" : `margin-inline-start 100ms ease-out`};
@media print {
margin: 0 !important;
}
${breakpoint("mobile", "tablet")`
margin-left: 0 !important;
margin-inline-start: 0 !important;
`}
${breakpoint("tablet")`
${(props: ContentProps) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
`margin-inline-start: ${props.theme.sidebarCollapsedWidth}px;`}
`};
`;
+1 -1
View File
@@ -203,7 +203,7 @@ const Wrapper = styled.a<{
`;
const Image = styled(Flex)`
padding: 0 8px 0 0;
padding-inline-end: 8px;
max-height: 32px;
align-items: center;
user-select: none;
@@ -117,8 +117,8 @@ const StyledAvatar = styled(Avatar).attrs({
const Container = styled(Flex)<{ $unread: boolean }>`
position: relative;
padding: 8px 12px;
padding-right: 40px;
padding-block: 8px;
padding-inline: 12px 40px;
border-radius: 4px;
${StyledLink}[data-state=open] &,
+1 -1
View File
@@ -69,7 +69,7 @@ function AppSidebar() {
model={team}
size={24}
alt={t("Logo")}
style={{ marginLeft: 4 }}
style={{ insetInlineStart: 4 }}
/>
}
>
@@ -10,6 +10,7 @@ import ResizeBorder from "~/components/Sidebar/components/ResizeBorder";
import useStores from "~/hooks/useStores";
import useWindowScrollbarWidth from "~/hooks/useWindowScrollbarWidth";
import { sidebarAppearDuration } from "~/styles/animations";
import { useDirection } from "@radix-ui/react-direction";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
@@ -18,25 +19,25 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
skipInitialAnimation?: boolean;
}
function Right({ children, border, className, skipInitialAnimation }: Props) {
function Aside({ children, border, className, skipInitialAnimation }: Props) {
const theme = useTheme();
const { ui } = useStores();
const [isResizing, setResizing] = React.useState(false);
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const windowScrollbarWidth = useWindowScrollbarWidth();
const direction = useDirection();
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
const width = Math.max(
Math.min(window.innerWidth - event.pageX, maxWidth),
minWidth
);
const distance =
direction === "rtl" ? event.pageX : window.innerWidth - event.pageX;
const width = Math.max(Math.min(distance, maxWidth), minWidth);
ui.set({ sidebarRightWidth: width });
},
[minWidth, maxWidth, ui]
[minWidth, maxWidth, direction, ui]
);
const handleReset = React.useCallback(() => {
@@ -108,7 +109,7 @@ function Right({ children, border, className, skipInitialAnimation }: Props) {
$border={border}
className={className}
role="complementary"
aria-label="Right sidebar"
aria-label="Aside"
>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
@@ -136,15 +137,15 @@ const Sidebar = styled(m.div)<{
flex-shrink: 0;
background: ${s("background")};
max-width: 80%;
border-left: 1px solid ${s("divider")};
transition: border-left 100ms ease-in-out;
border-inline-start: 1px solid ${s("divider")};
transition: border-inline-start 100ms ease-in-out;
z-index: ${depths.sidebar};
${breakpoint("mobile", "tablet")`
display: flex;
position: absolute;
top: 0;
right: 0;
inset-inline-end: 0;
bottom: 0;
z-index: ${depths.mobileSidebar};
`}
@@ -154,4 +155,4 @@ const Sidebar = styled(m.div)<{
`}
`;
export default observer(Right);
export default observer(Aside);
+5 -1
View File
@@ -101,7 +101,11 @@ function SettingsSidebar() {
}
const StyledBackIcon = styled(BackIcon)`
margin-left: 4px;
margin-inline-start: 4px;
[dir="rtl"] & {
transform: rotate(180deg);
}
`;
export default observer(SettingsSidebar);
+1 -1
View File
@@ -137,7 +137,7 @@ const SearchButton = styled.button`
const SearchLabel = styled.span`
flex-grow: 1;
text-align: left;
text-align: start;
`;
const Shortcut = styled.span`
+35 -13
View File
@@ -22,6 +22,7 @@ import ResizeBorder from "./components/ResizeBorder";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useTranslation } from "react-i18next";
import { useDirection } from "@radix-ui/react-direction";
const ANIMATION_MS = 250;
@@ -53,6 +54,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const { trigger } = useWebHaptics();
const direction = useDirection();
const [offset, setOffset] = React.useState(0);
const [isHovering, setHovering] = React.useState(false);
@@ -66,8 +68,9 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// this is simple because the sidebar is always against the left edge
const newWidth = Math.min(event.pageX - offset, maxWidth);
const rawWidth =
direction === "rtl" ? offset - event.pageX : event.pageX - offset;
const newWidth = Math.min(rawWidth, maxWidth);
const isSmallerThanCollapsePoint = newWidth < minWidth / 2;
if (canCollapse) {
@@ -80,7 +83,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
ui.set({ sidebarWidth: Math.max(newWidth, minWidth) });
}
},
[ui, theme, offset, minWidth, maxWidth]
[ui, theme, offset, minWidth, maxWidth, direction]
);
const handleStopDrag = React.useCallback(() => {
@@ -117,11 +120,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
return;
}
setOffset(event.pageX - width);
setOffset(
direction === "rtl" ? event.pageX + width : event.pageX - width
);
setResizing(true);
setAnimating(false);
},
[width]
[width, direction]
);
const handlePointerActivity = React.useCallback(() => {
@@ -145,16 +150,21 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
const withinSidebar =
direction === "rtl"
? ev.pageX > window.innerWidth - width
: ev.pageX < width;
setHovering(
document.hasFocus() &&
ev.pageX < width &&
withinSidebar &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
}
},
[width, hasPointerMoved]
[width, direction, hasPointerMoved]
);
React.useEffect(() => {
@@ -255,7 +265,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
alt={t("Avatar of {{ name }}", { name: user.name })}
model={user}
size={24}
style={{ marginLeft: 4 }}
style={{ marginInlineStart: 4 }}
/>
}
>
@@ -302,7 +312,7 @@ type ContainerProps = {
};
const hoverStyles = (props: ContainerProps) => `
transform: none;
transform: none !important;
box-shadow: ${
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
@@ -320,22 +330,29 @@ const Container = styled(Flex)<ContainerProps>`
position: fixed;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: 100%;
background: ${s("sidebarBackground")};
transition:
box-shadow 150ms ease-in-out,
transform 150ms ease-out,
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
transform 150ms
ease-out${(props: ContainerProps) =>
props.$isAnimating ? `, width ${ANIMATION_MS}ms ease-out` : ""};
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
padding-left: var(--sal);
padding-inline-start: var(--sal);
${fadeOnDesktopBackgrounded()}
[dir="rtl"] & {
transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "100%")}
);
}
@media print {
display: none;
transform: none;
@@ -367,6 +384,11 @@ const Container = styled(Flex)<ContainerProps>`
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
: 0});
[dir="rtl"] & {
transform: translateX(${(props: ContainerProps) =>
props.$collapsed ? `calc(100% - 8px)` : 0});
}
${(props: ContainerProps) => props.$isHovering && css(hoverStyles)}
&:hover {
@@ -118,7 +118,7 @@ const DynamicDropCursor = observer(
);
const Loading = styled(PlaceholderCollections)`
margin-left: 44px;
margin-inline-start: 44px;
min-height: 90px;
`;
@@ -28,7 +28,7 @@ function Disclosure({ onClick, expanded, ...rest }: Props) {
const Button = styled(NudeButton)`
position: absolute;
left: -24px;
inset-inline-start: -24px;
flex-shrink: 0;
color: ${s("textSecondary")};
margin: 2px;
@@ -47,7 +47,14 @@ const StyledCollapsedIcon = styled(CollapsedIcon)<{
opacity 100ms ease,
transform 100ms ease,
fill 50ms !important;
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
[aria-expanded="false"] & {
transform: rotate(-90deg);
}
[dir="rtl"] [aria-expanded="false"] & {
transform: rotate(90deg);
}
`;
// Enables identifying this component within styled components
+6 -1
View File
@@ -75,7 +75,8 @@ const Button = styled.button`
position: relative;
letter-spacing: 0.03em;
margin: 0;
padding: 4px 2px 4px 12px;
padding-block: 4px;
padding-inline: 12px 2px;
border: 0;
background: none;
border-radius: 4px;
@@ -98,6 +99,10 @@ const Disclosure = styled(CollapsedIcon)<{ $expanded?: boolean }>`
fill 50ms !important;
${(props) => !props.$expanded && "transform: rotate(-90deg);"};
opacity: 0;
[dir="rtl"] & {
${(props) => !props.$expanded && "transform: rotate(90deg);"};
}
`;
const H3 = styled.h3`
@@ -91,7 +91,7 @@ function HistoryNavigation(props: React.ComponentProps<typeof Flex>) {
const Navigation = styled(Flex)`
position: absolute;
right: 12px;
inset-inline-end: 12px;
top: 14px;
button {
@@ -108,11 +108,19 @@ const Forward = styled(ArrowIcon)<{ $enabled: boolean }>`
&:hover {
opacity: ${(props) => (props.$enabled ? 1 : 0.15)};
}
[dir="rtl"] & {
transform: rotate(180deg);
}
`;
const Back = styled(Forward)`
transform: rotate(180deg);
flex-shrink: 0;
[dir="rtl"] & {
transform: rotate(0deg);
}
`;
const StyledClockIcon = styled(ClockIcon)`
@@ -6,8 +6,8 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
position: absolute;
top: 0;
bottom: 0;
right: ${(props) => (props.dir !== "right" ? "-1px" : "auto")};
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
inset-inline-end: ${(props) => (props.dir !== "right" ? "-1px" : "auto")};
inset-inline-start: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
width: 2px;
cursor: col-resize;
${undraggableOnDesktop()}
@@ -23,7 +23,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
position: absolute;
top: 0;
bottom: 0;
right: -4px;
inset-inline-end: -4px;
width: 10px;
${undraggableOnDesktop()}
}
@@ -100,7 +100,7 @@ const Button = styled(Flex)<{
-webkit-appearance: none;
text-decoration: none;
text-align: left;
text-align: start;
user-select: none;
position: relative;
@@ -118,11 +118,11 @@ const Button = styled(Flex)<{
}
&:last-child {
margin-right: 8px;
margin-inline-end: 8px;
}
&:first-child {
margin-left: 8px;
margin-inline-start: 8px;
}
`;
@@ -102,15 +102,15 @@ function SidebarLink(
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
paddingRight: unreadBadge ? "32px" : undefined,
paddingInlineStart: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
paddingInlineEnd: unreadBadge ? "32px" : undefined,
}),
[depth, icon, unreadBadge]
);
const unreadStyle = React.useMemo(
() => ({
right: -20,
insetInlineEnd: -20,
}),
[]
);
@@ -217,7 +217,7 @@ function SidebarLink(
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
margin-inline-start: -4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
@@ -237,7 +237,7 @@ const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
visibility: ${(props) => (props.$showActions ? "visible" : "hidden")};
position: absolute;
top: 3px;
right: 4px;
inset-inline-end: 4px;
gap: 4px;
color: ${s("textTertiary")};
transition: opacity 50ms;
@@ -261,10 +261,10 @@ const Actions = styled(EventBoundary)<{ $showActions?: boolean }>`
const HiddenDisclosure = styled(Disclosure)`
position: inherit;
left: initial;
inset-inline-start: initial;
display: none;
margin-left: -2px;
margin-right: 6px;
margin-inline-start: -2px;
margin-inline-end: 6px;
`;
const Link = styled(NavLink)<{
@@ -317,10 +317,7 @@ const Link = styled(NavLink)<{
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
pointer-events: none;
border-radius: 4px;
border: 1.5px dashed ${props.theme.sidebarDraftBorder};
@@ -344,7 +341,8 @@ const Link = styled(NavLink)<{
}
${breakpoint("tablet")`
padding: 3px 8px 3px 12px;
padding-block: 3px;
padding-inline: 12px 8px;
font-size: 14px;
`}
@@ -379,9 +377,9 @@ const Label = styled.div<{ $ellipsis: boolean }>`
position: relative;
width: 100%;
line-height: 24px;
margin-left: 2px;
margin-inline-start: 2px;
min-width: 0;
text-align: left;
text-align: start;
${(props) => props.$ellipsis && ellipsis()}
@@ -10,6 +10,10 @@ const ToggleButton = styled(SidebarButton)`
&:active {
opacity: 1;
}
[dir="rtl"] & svg {
transform: scaleX(-1);
}
`;
export default ToggleButton;
@@ -54,5 +54,5 @@ export default function Version() {
}
const LilBadge = styled(Badge)`
margin-left: 0;
margin-inline-start: 0;
`;
+4
View File
@@ -172,6 +172,10 @@ const StyledSwitchThumb = styled(RadixSwitch.Thumb)<{
&[data-state="checked"] {
transform: translateX(${(props) => props.width - props.height}px);
}
[dir="rtl"] &[data-state="checked"] {
transform: translateX(${(props) => -(props.width - props.height)}px);
}
`;
export default React.forwardRef(Switch);
-1
View File
@@ -54,7 +54,6 @@ const tabStyles = `
font-size: 14px;
cursor: var(--pointer);
user-select: none;
margin-right: 24px;
padding: 6px 0;
`;
+4 -1
View File
@@ -14,6 +14,10 @@ const Nav = styled.nav<{ $shadowVisible?: boolean }>`
-ms-overflow-style: none;
scrollbar-width: none;
& > * + * {
margin-inline-start: 24px;
}
&::-webkit-scrollbar {
display: none;
}
@@ -52,7 +56,6 @@ export const Separator = styled.span`
border-left: 1px solid ${s("divider")};
position: relative;
top: 2px;
margin-right: 24px;
margin-top: 6px;
`;
+18 -11
View File
@@ -1,8 +1,11 @@
import { DirectionProvider } from "@radix-ui/react-direction";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { ThemeProvider } from "styled-components";
import GlobalStyles from "@shared/styles/globals";
import { TeamPreference, UserPreference } from "@shared/types";
import { isRTLLanguage } from "@shared/utils/rtl";
import useBuildTheme from "~/hooks/useBuildTheme";
import useStores from "~/hooks/useStores";
@@ -12,11 +15,13 @@ type Props = {
const Theme: React.FC = ({ children }: Props) => {
const { auth, ui } = useStores();
const { i18n } = useTranslation();
const theme = useBuildTheme(
auth.team?.getPreference(TeamPreference.CustomTheme) ||
auth.config?.customTheme ||
undefined
);
const direction = isRTLLanguage(i18n.language) ? "rtl" : "ltr";
React.useEffect(() => {
window.dispatchEvent(
@@ -27,17 +32,19 @@ const Theme: React.FC = ({ children }: Props) => {
}, [ui.resolvedTheme]);
return (
<ThemeProvider theme={theme}>
<>
<GlobalStyles
useCursorPointer={
// Default to showing the cursor pointer if no user is logged in (public share)
auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true
}
/>
{children}
</>
</ThemeProvider>
<DirectionProvider dir={direction}>
<ThemeProvider theme={theme}>
<>
<GlobalStyles
useCursorPointer={
// Default to showing the cursor pointer if no user is logged in (public share)
auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true
}
/>
{children}
</>
</ThemeProvider>
</DirectionProvider>
);
};
@@ -73,13 +73,13 @@ export const SelectButton = styled(Button)<{ $nude?: boolean }>`
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
padding-inline-start: 12px;
padding-inline-end: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
margin-inline-start: auto;
}
&[data-placeholder=""] {
@@ -132,7 +132,7 @@ const ItemContainer = styled(Flex)`
${breakpoint("tablet")`
font-size: 14px;
padding: 4px;
padding-left: 8px;
padding-inline-start: 8px;
`}
`;
+10 -6
View File
@@ -16,7 +16,7 @@ type BaseMenuItemProps = {
const BaseMenuItemCSS = css<BaseMenuItemProps>`
position: relative;
display: flex;
justify-content: left;
justify-content: flex-start;
align-items: center;
width: 100%;
min-height: 32px;
@@ -135,15 +135,19 @@ export const MenuHeader = styled.h3`
export const MenuDisclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
inset-inline-end: 8px;
color: ${s("textTertiary")};
[dir="rtl"] & {
transform: rotate(90deg);
}
`;
export const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
margin-inline-end: 6px;
margin-inline-start: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
display: flex;
@@ -154,7 +158,7 @@ export const MenuIconWrapper = styled.span`
export const SelectedIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: -6px;
margin-inline-end: -6px;
color: ${s("textSecondary")};
flex-shrink: 0;
display: flex;
@@ -169,7 +173,7 @@ export const MenuShortcut = styled.span`
font-size: 12px;
color: currentColor;
opacity: 0.5;
margin-left: 16px;
margin-inline-start: 16px;
flex-shrink: 0;
`;
+5 -2
View File
@@ -4,6 +4,7 @@ import first from "lodash/first";
import { Suspense, useCallback } from "react";
import styled from "styled-components";
import { CollectionValidation } from "@shared/validations";
import { isRTL } from "@shared/utils/rtl";
import Heading from "~/components/Heading";
import ContentEditable from "~/components/ContentEditable";
import CollectionIcon from "~/components/Icons/CollectionIcon";
@@ -48,9 +49,11 @@ export const Header = observer(function Header_({
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const dir = isRTL(collection.name) ? "rtl" : "ltr";
return (
<StyledHeading>
<IconTitleWrapper>
<StyledHeading dir={dir}>
<IconTitleWrapper dir={dir}>
{canEdit ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
@@ -51,8 +51,6 @@ type Props = {
animatePresence?: boolean;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Callback when the editor is focused */
onFocus?: () => void;
/** Callback when the editor is blurred */
@@ -75,7 +73,6 @@ function CommentForm({
placeholder,
animatePresence,
highlightedText,
dir,
...rest
}: Props) {
const { editor } = useDocumentContext();
@@ -306,7 +303,7 @@ function CommentForm({
tabIndex={-1}
/>
</VisuallyHidden.Root>
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
<Flex gap={8} align="flex-start">
<Avatar model={user} size={24} style={{ marginTop: 8 }} />
<Bubble
gap={10}
@@ -341,7 +338,7 @@ function CommentForm({
/>
</React.Suspense>
{(inputFocused || draft) && (
<Flex justify="space-between" reverse={dir === "rtl"} gap={8}>
<Flex justify="space-between" gap={8}>
<HStack>
<ButtonSmall type="submit" borderOnHover>
{thread && !thread.isNew ? t("Reply") : t("Post")}
@@ -219,7 +219,6 @@ function CommentThread({
ref={topRef}
$focused={focused}
$recessed={recessed}
$dir={document.dir}
onClick={handleClickThread}
>
{commentsInThread.map((comment, index) => {
@@ -252,7 +251,6 @@ function CommentThread({
firstOfAuthor={firstOfAuthor}
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
forceEdit={editingCommentIds.has(comment.id)}
onEditStart={() => handleCommentEditStart(comment.id)}
onEditEnd={() => handleCommentEditEnd(comment.id)}
@@ -270,7 +268,6 @@ function CommentThread({
documentId={document.id}
thread={thread}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
highlightedText={
commentsInThread.length === 0 ? highlightedText : undefined
@@ -298,23 +295,22 @@ const Reply = styled.button`
cursor: var(--pointer);
transition: opacity 100ms ease-out;
position: absolute;
text-align: left;
text-align: start;
width: 100%;
bottom: -30px;
left: 32px;
inset-inline-start: 32px;
${breakpoint("tablet")`
opacity: 0;
`}
`;
const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
const ShowMore = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1px;
margin-left: ${(props) => (props.$dir === "rtl" ? 0 : 32)}px;
margin-right: ${(props) => (props.$dir !== "rtl" ? 0 : 32)}px;
margin-inline-start: 32px;
padding: 8px 12px;
color: ${s("textTertiary")};
background: ${(props) => darken(0.015, props.theme.backgroundSecondary)};
@@ -334,11 +330,10 @@ const ShowMore = styled.div<{ $dir?: "rtl" | "ltr" }>`
const Thread = styled.div<{
$focused: boolean;
$recessed: boolean;
$dir?: "rtl" | "ltr";
}>`
margin: 12px 12px 32px;
margin-right: ${(props) => (props.$dir !== "rtl" ? "18px" : "12px")};
margin-left: ${(props) => (props.$dir === "rtl" ? "18px" : "12px")};
margin-inline-end: 18px;
margin-inline-start: 12px;
position: relative;
transition: opacity 100ms ease-out;
@@ -70,8 +70,6 @@ function useShowTime(
type Props = {
/** The comment to render */
comment: Comment;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Whether this is the first comment in the thread */
firstOfThread?: boolean;
/** Whether this is the last comment in the thread */
@@ -103,7 +101,6 @@ function CommentThreadItem({
firstOfAuthor,
firstOfThread,
lastOfThread,
dir,
previousCommentCreatedAt,
canReply,
onDelete,
@@ -200,7 +197,7 @@ function CommentThreadItem({
};
return (
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
<Flex gap={8} align="flex-start">
{firstOfAuthor && (
<AvatarSpacer>
<Avatar model={comment.createdBy} size={24} />
@@ -210,12 +207,11 @@ function CommentThreadItem({
$firstOfThread={firstOfThread}
$firstOfAuthor={firstOfAuthor}
$lastOfThread={lastOfThread}
$dir={dir}
$canReply={canReply}
column
>
{(showAuthor || showTime) && (
<Meta size="xsmall" type="secondary" dir={dir}>
<Meta size="xsmall" type="secondary">
{showAuthor && <em>{comment.createdBy.name}</em>}
{showAuthor && showTime && <> &middot; </>}
{showTime && (
@@ -277,7 +273,7 @@ function CommentThreadItem({
</Body>
<EventBoundary>
{!isEditing && (
<Actions gap={4} dir={dir}>
<Actions gap={4}>
{!comment.isResolved && (
<>
{firstOfThread && (
@@ -386,14 +382,13 @@ const Action = styled.span<{ $rounded?: boolean }>`
}
`;
const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
const Actions = styled(Flex)`
position: absolute;
left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")};
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
inset-inline-end: 4px;
top: 4px;
transition: opacity 100ms ease-in-out;
background: ${s("backgroundSecondary")};
padding-left: 4px;
padding-inline-start: 4px;
${breakpoint("tablet")`
opacity: 0;
@@ -423,7 +418,6 @@ export const Bubble = styled(Flex)<{
$lastOfThread?: boolean;
$canReply?: boolean;
$focused?: boolean;
$dir?: "rtl" | "ltr";
}>`
position: relative;
flex-grow: 1;
@@ -440,16 +434,13 @@ export const Bubble = styled(Flex)<{
${({ $lastOfThread, $canReply }) =>
$lastOfThread &&
!$canReply &&
"border-bottom-left-radius: 8px; border-bottom-right-radius: 8px"};
"border-end-start-radius: 8px; border-end-end-radius: 8px"};
${({ $firstOfThread }) =>
$firstOfThread &&
"border-top-left-radius: 8px; border-top-right-radius: 8px"};
"border-start-start-radius: 8px; border-start-end-radius: 8px"};
margin-left: ${(props) =>
props.$firstOfAuthor || props.$dir === "rtl" ? 0 : 32}px;
margin-right: ${(props) =>
props.$firstOfAuthor || props.$dir !== "rtl" ? 0 : 32}px;
margin-inline-start: ${(props) => (props.$firstOfAuthor ? 0 : 32)}px;
p:last-child {
margin-bottom: 0;
@@ -180,7 +180,6 @@ function Comments() {
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
@@ -245,10 +244,10 @@ const JumpToRecent = styled(ButtonSmall)`
}
`;
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
const NewCommentForm = styled(CommentForm)`
padding: 12px;
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
padding-left: ${(props) => (props.dir === "rtl" ? "18px" : "12px")};
padding-inline-end: 18px;
padding-inline-start: 12px;
`;
export default observer(Comments);
@@ -19,7 +19,7 @@ export const HighlightedText = styled(Text)`
content: "";
width: 2px;
position: absolute;
left: 0;
inset-inline-start: 0;
top: 2px;
bottom: 2px;
background: ${s("commentMarkBackground")};
@@ -9,7 +9,7 @@ import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal";
import { RightSidebarWrappedContext } from "~/components/RightSidebarContext";
import Scrollable from "~/components/Scrollable";
import RightSidebar from "~/components/Sidebar/Right";
import Aside from "~/components/Sidebar/Aside";
import Tooltip from "~/components/Tooltip";
import {
Drawer,
@@ -79,12 +79,16 @@ function SidebarLayout({ title, onClose, children, scrollable = true }: Props) {
return inner;
}
return <RightSidebar>{inner}</RightSidebar>;
return <Aside>{inner}</Aside>;
}
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
flex-shrink: 0;
[dir="rtl"] & {
transform: rotate(0deg);
}
`;
const Title = styled(Flex)`
@@ -5,7 +5,7 @@ import {
RightSidebarWrappedContext,
useSetRightSidebar,
} from "~/components/RightSidebarContext";
import RightSidebar from "~/components/Sidebar/Right";
import Aside from "~/components/Sidebar/Aside";
import PlaceholderText from "~/components/PlaceholderText";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
@@ -31,7 +31,7 @@ interface DocumentSidebarContentProps {
/**
* Stable component that reads `ui.rightSidebar` and renders the appropriate
* sidebar content. On desktop, wraps content in a single Right sidebar that
* sidebar content. On desktop, wraps content in a single Aside sidebar that
* stays mounted across panel switches to avoid re-triggering the open/close
* animation.
*/
@@ -61,11 +61,11 @@ const DocumentSidebarContent = observer(function DocumentSidebarContent({
}
return (
<RightSidebar skipInitialAnimation={skipInitialAnimation}>
<Aside skipInitialAnimation={skipInitialAnimation}>
<RightSidebarWrappedContext.Provider value={true}>
{inner}
</RightSidebarWrappedContext.Provider>
</RightSidebar>
</Aside>
);
});
+4 -4
View File
@@ -625,11 +625,11 @@ const List = styled.dl`
`;
const Keys = styled.dt`
float: right;
float: inline-end;
width: 45%;
margin: 0 0 10px;
clear: left;
text-align: right;
clear: inline-start;
text-align: end;
font-size: 12px;
color: ${s("textSecondary")};
display: flex;
@@ -638,7 +638,7 @@ const Keys = styled.dt`
`;
const Label = styled.dd`
float: left;
float: inline-start;
width: 55%;
margin: 0 0 10px;
display: flex;
+3 -2
View File
@@ -53,7 +53,8 @@ const Wrapper = styled(Flex)`
const StyledInput = styled.input`
width: 100%;
padding: 10px 10px 10px 60px;
padding-block: 10px 10px;
padding-inline: 60px 10px;
font-size: 30px;
font-weight: 400;
outline: none;
@@ -81,7 +82,7 @@ const StyledInput = styled.input`
const StyledIcon = styled(SearchIcon)`
position: absolute;
left: 8px;
inset-inline-start: 8px;
opacity: 0.7;
`;
+1 -1
View File
@@ -14,7 +14,7 @@ export const ActionRow = styled(HStack).attrs({
bottom: 0;
width: 100vw;
padding: 16px 12px;
margin-left: -12px;
margin-inline-start: -12px;
background: ${s("background")};
+7
View File
@@ -3,6 +3,7 @@ import backend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import { languages } from "@shared/i18n";
import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date";
import { isRTLLanguage } from "@shared/utils/rtl";
import { cdnPath } from "@shared/utils/urls";
import Logger from "./Logger";
@@ -17,6 +18,12 @@ import Logger from "./Logger";
export function initI18n(defaultLanguage = "en_US") {
const lng = unicodeCLDRtoBCP47(defaultLanguage);
if (typeof document !== "undefined") {
document.documentElement.dir = isRTLLanguage(defaultLanguage)
? "rtl"
: "ltr";
}
void i18n
.use(backend)
.use(initReactI18next)
+5
View File
@@ -1,6 +1,7 @@
import type { i18n } from "i18next";
import type { locales } from "@shared/utils/date";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import { isRTLLanguage } from "@shared/utils/rtl";
import Desktop from "./Desktop";
/**
@@ -49,6 +50,10 @@ export async function changeLanguage(
await instance.changeLanguage(localeBCP);
await Desktop.bridge?.setSpellCheckerLanguages(["en-US", localeBCP]);
}
if (typeof document !== "undefined") {
document.documentElement.dir = isRTLLanguage(locale) ? "rtl" : "ltr";
}
}
/**
+1
View File
@@ -88,6 +88,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-one-time-password-field": "^0.1.8",
"@radix-ui/react-popover": "^1.1.15",
+17
View File
@@ -4,6 +4,8 @@ const rtlChars = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
// oxlint-disable-next-line no-misleading-character-class
const rtlDirCheck = new RegExp("^[^" + ltrChars + "]*[" + rtlChars + "]");
const rtlLanguageCodes = new Set(["ar", "fa", "he", "ps", "ur", "yi"]);
/**
* Returns true if the text is likely written in an RTL language.
*
@@ -13,3 +15,18 @@ const rtlDirCheck = new RegExp("^[^" + ltrChars + "]*[" + rtlChars + "]");
export function isRTL(text: string) {
return rtlDirCheck.test(text);
}
/**
* Returns true if the given locale is an RTL language. Accepts both CLDR
* (`he_IL`) and BCP47 (`he-IL`, `he`) formats.
*
* @param locale The locale to check
* @returns True if the locale is RTL
*/
export function isRTLLanguage(locale: string | null | undefined) {
if (!locale) {
return false;
}
const code = locale.toLowerCase().split(/[-_]/)[0];
return rtlLanguageCodes.has(code);
}
+2 -1
View File
@@ -4965,7 +4965,7 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-direction@npm:1.1.1":
"@radix-ui/react-direction@npm:1.1.1, @radix-ui/react-direction@npm:^1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-direction@npm:1.1.1"
peerDependencies:
@@ -16940,6 +16940,7 @@ __metadata:
"@radix-ui/react-collapsible": "npm:^1.1.12"
"@radix-ui/react-context-menu": "npm:^2.2.16"
"@radix-ui/react-dialog": "npm:^1.1.15"
"@radix-ui/react-direction": "npm:^1.1.1"
"@radix-ui/react-dropdown-menu": "npm:^2.1.16"
"@radix-ui/react-one-time-password-field": "npm:^0.1.8"
"@radix-ui/react-popover": "npm:^1.1.15"