Skip to content
6 changes: 6 additions & 0 deletions .server-changes/sso-directory-sync-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Refreshed the SSO & Directory Sync settings page layout and copy
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function OrganizationSettingsSideMenu({
)}
{isSsoUsingPlugin && (
<SideMenuItem
name="Identity & Access"
name="SSO & Directory Sync"
icon={PadlockIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
Expand Down
20 changes: 16 additions & 4 deletions apps/webapp/app/components/primitives/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ export interface SelectProps<TValue extends string | string[], TItem> extends Om
allowItemShortcuts?: boolean;
clearSearchOnSelection?: boolean;
dropdownIcon?: boolean | React.ReactNode;
popoverClassName?: string;
placement?: Ariakit.SelectProviderProps<TValue>["placement"];
}

export function Select<TValue extends string | string[], TItem>({
Expand All @@ -135,6 +137,8 @@ export function Select<TValue extends string | string[], TItem>({
disabled,
clearSearchOnSelection = true,
dropdownIcon,
popoverClassName,
placement,
...props
}: SelectProps<TValue, TItem>) {
const [searchValue, setSearchValue] = useState("");
Expand Down Expand Up @@ -191,6 +195,7 @@ export function Select<TValue extends string | string[], TItem>({
open={open}
setOpen={setOpen}
virtualFocus={searchable}
placement={placement}
value={value}
setValue={(v) => {
if (clearSearchOnSelection) {
Expand All @@ -215,7 +220,7 @@ export function Select<TValue extends string | string[], TItem>({
dropdownIcon={dropdownIcon}
{...props}
/>
<SelectPopover>
<SelectPopover className={popoverClassName}>
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}

Expand Down Expand Up @@ -437,7 +442,7 @@ export function SelectList(props: SelectListProps) {
<Component
{...props}
className={cn(
"overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
"overflow-y-auto overflow-x-hidden overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
props.className
)}
/>
Expand All @@ -449,6 +454,9 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
checkIcon?: React.ReactNode;
checkPosition?: "left" | "right";
shortcut?: ShortcutDefinition;
// Allow the item to grow to multiple lines and wrap its content instead of
// being locked to a single truncated line. Use for options with a subtitle.
wrap?: boolean;
}

const selectItemClasses =
Expand All @@ -461,6 +469,7 @@ export function SelectItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
checkPosition = "right",
shortcut,
wrap = false,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
Expand Down Expand Up @@ -508,13 +517,16 @@ export function SelectItem({
>
<div
className={cn(
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
"flex w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
wrap ? "min-h-8" : "h-8",
checkPosition === "left" ? "gap-2" : "gap-1"
)}
>
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
{icon}
<div className="grow truncate">{props.children || props.value}</div>
<div className={cn("grow", wrap ? "min-w-0 break-words py-1.5" : "truncate")}>
{props.children || props.value}
</div>
{checkPosition === "right" && checkIcon}
{shortcut && (
<ShortcutKey
Expand Down
216 changes: 216 additions & 0 deletions apps/webapp/app/components/primitives/SettingsLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { type ReactNode } from "react";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { cn } from "~/utils/cn";
import { Header2, Header3 } from "./Headers";
import { Paragraph } from "./Paragraph";

// A composable layout system for settings pages: a centered container holds
// sections; each section has a header (title/description/action over a divide)
// followed by rows. A row lays out a title + description on the left and an
// action (button/switch/select/status) on the right, separated by divides and
// spacing rather than bordered boxes.
//
// Everything that renders text accepts `ReactNode`, and every piece takes a
// `className` so callers can restyle without forking. For layouts the built-in
// props don't cover, pass `children` to a row/block for full control.

const rowSize = {
sm: "py-3",
md: "py-4",
} as const;

type RowSize = keyof typeof rowSize;

/** Page-level wrapper that centers content and sets the settings column width. */
export function SettingsContainer({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<MainHorizontallyCenteredContainer
className={cn("max-w-[37.5rem] overflow-visible", className)}
>
{children}
</MainHorizontallyCenteredContainer>
);
}

/** A group of related rows. Adds vertical spacing between sibling sections. */
export function SettingsSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("w-full [&:not(:first-child)]:mt-12", className)}>{children}</section>
);
}

/**
* Section (or sub-section) heading with an optional description and a
* right-aligned action, sitting above a bottom divide. Use `as="h3"` for a
* heading nested inside a section.
*/
export function SettingsHeader({
title,
description,
action,
as = "h2",
className,
}: {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
as?: "h2" | "h3";
className?: string;
}) {
const Heading = as === "h3" ? Header3 : Header2;
return (
<div
className={cn(
"flex w-full items-end justify-between gap-8 border-b border-grid-dimmed",
// An h2 section header gets its top spacing from SettingsSection's margin,
// so it only needs bottom padding. An h3 sits mid-section among rows, so it
// takes the full row rhythm (py-4) to separate from the divide above it.
as === "h3" ? "py-4" : "pb-3",
className
)}
>
<div className="space-y-1">
<Heading>{title}</Heading>
{description ? <Paragraph variant="small">{description}</Paragraph> : null}
</div>
{action ? <div className="flex flex-none items-center">{action}</div> : null}
</div>
);
}

/** Title typography for a row. Renders a `<label>` when `htmlFor` is set. */
export function SettingsRowTitle({
children,
htmlFor,
className,
}: {
children: ReactNode;
htmlFor?: string;
className?: string;
}) {
const classes = cn("font-sans text-sm font-semibold leading-tight text-text-bright", className);
return htmlFor ? (
<label htmlFor={htmlFor} className={classes}>
{children}
</label>
) : (
<span className={classes}>{children}</span>
);
}

/** Description/subtitle typography for a row. */
export function SettingsRowDescription({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<Paragraph variant="small" className={className}>
{children}
</Paragraph>
);
}

/**
* A single settings row: title + description on the left, action on the right.
*
* Pass `title`/`description` for the common case, or `children` to supply
* custom left-hand content (the built-in title group is skipped when `children`
* is provided). `action` renders on the right in both cases.
*/
export function SettingsRow({
title,
description,
action,
htmlFor,
children,
className,
titleClassName,
size = "md",
align = "center",
bordered = true,
}: {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
htmlFor?: string;
children?: ReactNode;
className?: string;
titleClassName?: string;
size?: RowSize;
align?: "center" | "start";
bordered?: boolean;
}) {
return (
<div
className={cn(
"flex w-full justify-between gap-8",
align === "center" ? "items-center" : "items-start",
rowSize[size],
bordered && "border-b border-grid-dimmed",
className
)}
>
{children ?? (
<div className="flex-1 space-y-1">
{title ? (
<SettingsRowTitle htmlFor={htmlFor} className={titleClassName}>
{title}
</SettingsRowTitle>
) : null}
{description ? <SettingsRowDescription>{description}</SettingsRowDescription> : null}
</div>
)}
{action ? <div className="flex flex-none items-center">{action}</div> : null}
</div>
);
}

/**
* Full-width row for arbitrary content (callouts, empty states, custom blocks)
* that shouldn't be split into a title/action layout.
*/
export function SettingsBlock({
children,
className,
size = "md",
bordered = true,
}: {
children: ReactNode;
className?: string;
size?: RowSize;
bordered?: boolean;
}) {
return (
<div
className={cn("w-full", rowSize[size], bordered && "border-b border-grid-dimmed", className)}
>
{children}
</div>
);
}

/** Right-aligned action bar, typically for a section's Save button. */
export function SettingsActions({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("flex justify-end gap-2 py-4", className)}>{children}</div>;
}
Loading
Loading