diff --git a/component.manifest.json b/component.manifest.json
index 97a58348b..af170c08c 100644
--- a/component.manifest.json
+++ b/component.manifest.json
@@ -1016,6 +1016,15 @@
"role"
]
},
+ {
+ "id": "overlays/sheet",
+ "name": null,
+ "category": "overlays",
+ "sourcePath": "src/tedi/components/overlays/sheet",
+ "status": [],
+ "description": null,
+ "keyProps": []
+ },
{
"id": "misc/separator/separator",
"name": "Separator",
diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md
index 0031c6f4b..3efb5efd6 100644
--- a/skills/tedi-react/references/components.md
+++ b/skills/tedi-react/references/components.md
@@ -126,6 +126,12 @@ files. This is the part of this document worth maintaining by hand.
- **`OptionContent` is a template, not an item.** It has no role, click or focus handling by design.
It must go inside an interactive parent (`DropdownItem`, a `Select` option) that owns the role,
selection and keyboard handling.
+- **`Sheet` is the bottom sheet; `Modal` is the centred dialog.** Reach for `Sheet` on mobile-first
+ surfaces — it anchors to the bottom and adds a drag handle, snap points and collapse-to-header that
+ `Modal` has no concept of. It is bottom-only by design (no side/drawer variant). `SheetModal` is a
+ *separate*, Modal-backed variant used internally by `CardStepper` and
+ `TableOfContents.Collapsible`; it is **not** a sub-component of `Sheet`, so don't reach for it when
+ composing one.
- **`TableCard` is `Table`'s readable mobile form.** Below a breakpoint, render a list of
`TableCard` (each row a stacked `
` of label / value pairs with its own title / status /
actions) instead of collapsing columns; swap via `useBreakpoint` + `isBreakpointBelow(bp, 'md')`.
@@ -147,6 +153,24 @@ files. This is the part of this document worth maintaining by hand.
prop. Children win when both are given.
- **`TableOfContents.Item` children must be direct children.** Don't wrap them in another
component. Pass `underline={false}` on the `Link` inside an item to match the design.
+- **`Sheet` is a compound with an auto-wired title.** Compose `Sheet.Trigger` / `.Content` /
+ `.Header` / `.Body` / `.Footer` / `.Closer`. `Sheet.Header`'s `title` is wired to the dialog's
+ `aria-labelledby` for you; set `Sheet.Content`'s `aria-label` only when there is no visible title.
+- **`Sheet`'s `keepMounted` preserves state, invisibly.** By default the panel is removed from the
+ DOM when closed; `keepMounted` keeps it mounted-but-`hidden`, so form values and scroll position
+ survive a close→reopen. Nothing in the type signals that state-preservation difference — and the
+ kept panel is inert while closed, not just off-screen.
+- **`Sheet` `snapPoints` resize the panel; they don't translate it.** With `snapPoints={[0.4, 0.9]}`
+ the bottom sheet's height *is* the active snap, so the body scrolls and the footer stays visible at
+ every rest position, and dragging below the lowest snap dismisses. `collapsible` is the lighter
+ relative: it peeks the sheet down to just its header (toggled by the `CollapseButton`). From that
+ peek the drag handle is direction-sensitive — drag up to expand, swipe down to dismiss.
+- **`Sheet.Content`'s `radius` drives both the panel and header corners.** `radius="card" | "none" |
+ "default"` overrides the top-corner radius via a single `--tedi-sheet-radius` variable the panel
+ and header both read, so it's a sheet-level concern, not a `Sheet.Header` prop — and the panel's
+ `overflow: hidden` clips to its own radius, so a header-only override would be invisible anyway.
+ It's breakpoint-aware (`radius="none" md={{ radius: 'card' }}`); for any value outside the three
+ keywords, set `--tedi-sheet-radius` through `style`.
- **`Checkbox` / `Radio` pair with `Checkbox.Group` / `Radio.Group`.** The `.Group` owns selection
(`value` / `onChange`) and the shared props (`variant`, `size`, `name`); items inside inherit
`name` and auto-generate their `id`, so don't hand-wire those. `variant="card"` + `layout`
diff --git a/src/tedi/components/navigation/card-stepper/card-stepper.tsx b/src/tedi/components/navigation/card-stepper/card-stepper.tsx
index 6301ca628..c20a64545 100644
--- a/src/tedi/components/navigation/card-stepper/card-stepper.tsx
+++ b/src/tedi/components/navigation/card-stepper/card-stepper.tsx
@@ -5,7 +5,7 @@ import { useLabels } from '../../../providers/label-provider';
import { Icon } from '../../base/icon/icon';
import { Text } from '../../base/typography/text/text';
import { Button } from '../../buttons/button/button';
-import { Sheet } from '../../overlays/sheet/sheet';
+import { SheetModal } from '../../overlays/sheet/components/sheet-modal/sheet-modal';
import { VerticalStepper } from '../vertical-stepper/vertical-stepper';
import styles from './card-stepper.module.scss';
import { CardStepperStep, CardStepperStepProps } from './card-stepper-step';
@@ -342,7 +342,7 @@ const CardStepperInner = forwardRef((props, re
{current.bottomSlot &&
{current.bottomSlot}
}
{showStepList && (
-
+
{steps.map((step, index) => {
const navigable = isStepNavigable(index);
@@ -386,7 +386,7 @@ const CardStepperInner = forwardRef((props, re
);
})}
-
+
)}
);
diff --git a/src/tedi/components/navigation/table-of-contents/components/table-of-contents-collapsible/table-of-contents-collapsible.tsx b/src/tedi/components/navigation/table-of-contents/components/table-of-contents-collapsible/table-of-contents-collapsible.tsx
index 92a95f4c8..81d6c516e 100644
--- a/src/tedi/components/navigation/table-of-contents/components/table-of-contents-collapsible/table-of-contents-collapsible.tsx
+++ b/src/tedi/components/navigation/table-of-contents/components/table-of-contents-collapsible/table-of-contents-collapsible.tsx
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useLabels } from '../../../../../providers/label-provider';
import { Text } from '../../../../base/typography/text/text';
import { CollapseButton } from '../../../../buttons/collapse-button/collapse-button';
-import { Sheet } from '../../../../overlays/sheet/sheet';
+import { SheetModal } from '../../../../overlays/sheet/components/sheet-modal/sheet-modal';
import {
buildActiveTrail,
childrenToNodes,
@@ -125,7 +125,7 @@ export const TableOfContentsCollapsible = (props: TableOfContentsCollapsibleProp
-
-
+
);
};
diff --git a/src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx b/src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx
new file mode 100644
index 000000000..1d48e2146
--- /dev/null
+++ b/src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx
@@ -0,0 +1,47 @@
+import cn from 'classnames';
+import { ReactNode } from 'react';
+
+import styles from '../../sheet.module.scss';
+import { useSheetContext } from '../../sheet-context';
+
+export type SheetBodyPadding = 'default' | 'none';
+
+export interface SheetBodyProps {
+ /**
+ * Body content. Scrolls when it overflows the available height.
+ */
+ children: ReactNode;
+ /**
+ * Body padding.
+ * - `default` — the standard sheet padding.
+ * - `none` — edge-to-edge content. Use when the content brings its own padding
+ * (e.g. `Tabs`, a full-width action list) and should sit flush with the sheet edges.
+ * @default default
+ */
+ padding?: SheetBodyPadding;
+ /**
+ * Additional class name.
+ */
+ className?: string;
+}
+
+export const SheetBody = ({ children, padding = 'default', className }: SheetBodyProps): JSX.Element | null => {
+ const { collapsed } = useSheetContext();
+
+ if (collapsed) return null;
+
+ return (
+
+ {children}
+
+ );
+};
+
+SheetBody.displayName = 'Sheet.Body';
+export default SheetBody;
diff --git a/src/tedi/components/overlays/sheet/components/sheet-closer/sheet-closer.tsx b/src/tedi/components/overlays/sheet/components/sheet-closer/sheet-closer.tsx
new file mode 100644
index 000000000..e4f89a0ad
--- /dev/null
+++ b/src/tedi/components/overlays/sheet/components/sheet-closer/sheet-closer.tsx
@@ -0,0 +1,24 @@
+import { cloneElement, MouseEvent, ReactElement } from 'react';
+
+import { useSheetContext } from '../../sheet-context';
+
+export interface SheetCloserProps {
+ /**
+ * Element that closes the sheet when clicked. The element is cloned and receives
+ * a wrapped `onClick` that calls the original handler first, then closes the sheet.
+ */
+ children: ReactElement<{ onClick?: (event: MouseEvent) => void }>;
+}
+
+export const SheetCloser = ({ children }: SheetCloserProps): JSX.Element => {
+ const { onOpenChange } = useSheetContext();
+ return cloneElement(children, {
+ onClick: (event: MouseEvent) => {
+ children.props.onClick?.(event);
+ onOpenChange(false);
+ },
+ });
+};
+
+SheetCloser.displayName = 'Sheet.Closer';
+export default SheetCloser;
diff --git a/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx
new file mode 100644
index 000000000..1627c0ea8
--- /dev/null
+++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx
@@ -0,0 +1,314 @@
+import { FloatingFocusManager, FloatingOverlay, FloatingPortal, useTransitionStatus } from '@floating-ui/react';
+import cn from 'classnames';
+import {
+ ComponentProps,
+ CSSProperties,
+ PointerEvent as ReactPointerEvent,
+ ReactNode,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { BreakpointSupport, useBreakpointProps } from '../../../../../helpers';
+import { useLabels } from '../../../../../providers/label-provider';
+import styles from '../../sheet.module.scss';
+import { useSheetContext } from '../../sheet-context';
+
+const DRAG_DISMISS_THRESHOLD = 120;
+const DRAG_EXPAND_THRESHOLD = 40;
+
+type SheetContentBreakpointProps = {
+ /**
+ * Top-corner radius of the sheet panel (the header follows it). Overrides the default sheet radius,
+ * and can be set per breakpoint via the `BreakpointSupport` API (e.g.
+ * ``).
+ * - `default` — the standard sheet radius.
+ * - `card` — matches a `Card`'s rounded corners (`--card-radius-rounded`).
+ * - `none` — square corners.
+ *
+ * For any other value, set the `--tedi-sheet-radius` custom property via `style`.
+ * @default default
+ */
+ radius?: 'default' | 'card' | 'none';
+};
+
+export interface SheetContentProps extends BreakpointSupport {
+ /**
+ * ``, ``, `` and any other content.
+ */
+ children: ReactNode;
+ /**
+ * Show the drag handle at the top.
+ * @default true
+ */
+ showHandle?: boolean;
+ /**
+ * Trap focus inside the sheet while open.
+ * @default true
+ */
+ trapFocus?: boolean;
+ /**
+ * Restore focus to the trigger after closing.
+ * @default true
+ */
+ returnFocus?: boolean;
+ /**
+ * Render the dimmed backdrop. `false` keeps portal + focus management but no overlay.
+ * @default true
+ */
+ showOverlay?: boolean;
+ /**
+ * Lock background scroll while open.
+ * @default true
+ */
+ lockScroll?: boolean;
+ /**
+ * Keep the panel mounted while closed instead of removing it from the DOM. Preserves its content
+ * and internal state (form values, scroll position) across close / reopen. The kept panel is
+ * inert (`hidden`) while closed.
+ * @default false
+ */
+ keepMounted?: boolean;
+ /**
+ * Rest positions for the sheet, as fractions of the viewport height (`0`–`1`), e.g. `[0.4, 0.9]`.
+ * Drag the handle to move between them; releasing snaps to the nearest, and dragging below the
+ * lowest point dismisses.
+ */
+ snapPoints?: number[];
+ /**
+ * Index into `snapPoints` for the initial rest position. Defaults to the tallest (last) snap.
+ */
+ defaultSnapPoint?: number;
+ /**
+ * Called with the fraction the sheet snapped to after a drag settles.
+ */
+ onSnapPointChange?: (snapPoint: number) => void;
+ /**
+ * Render visually-hidden dismiss buttons at the start and end of the dialog
+ * so touch screen-readers can escape without an Escape key. Only applies while
+ * `trapFocus` is `true` (a modal sheet); set `false` to opt out.
+ * @default true
+ */
+ visuallyHiddenDismiss?: boolean;
+ /**
+ * Override the dialog's labelling element. Only needed when the label lives
+ * outside `` - the header wires this automatically.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Plain-text accessible name. Use when there's no visible title. Ignored when
+ * `aria-labelledby` is set.
+ */
+ 'aria-label'?: string;
+ /**
+ * Element to focus on open. Tabbable index (`0` = first tabbable, `-1` = dialog
+ * container) or a ref.
+ */
+ initialFocus?: ComponentProps['initialFocus'];
+ /**
+ * Minimum height of the sheet panel (any CSS length, e.g. `'60dvh'`). Keeps the sheet from
+ * shrinking as its content shrinks - useful when a search or filter reduces the body but the
+ * sheet should stay put rather than collapsing around the results.
+ */
+ minHeight?: CSSProperties['minHeight'];
+ /**
+ * Maximum height of the sheet panel (any CSS length, e.g. `'80dvh'`). Defaults to `90dvh`; when
+ * the content is taller than this the body scrolls and the panel never exceeds the viewport.
+ * Ignored while `snapPoints` are set (the tallest snap caps the height instead).
+ */
+ maxHeight?: CSSProperties['maxHeight'];
+ /**
+ * Padding scale for the whole sheet - header, body and footer tighten together.
+ * - `default` - the standard sheet padding (`1rem`).
+ * - `small` - a denser layout (`0.5rem`), for compact or data-heavy sheets.
+ *
+ * Note: this is the sheet-wide padding *size*. To drop body padding entirely (edge-to-edge
+ * content) use `Sheet.Body`'s own `padding="none"`.
+ * @default default
+ */
+ padding?: 'default' | 'small';
+ /**
+ * Additional class name on the sheet panel.
+ */
+ className?: string;
+ /**
+ * Inline style applied to the sheet panel - handy for a custom `maxWidth`.
+ */
+ style?: CSSProperties;
+}
+
+export const SheetContent = (props: SheetContentProps): JSX.Element | null => {
+ const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint);
+ const { radius = 'default' } = getCurrentBreakpointProps(props);
+
+ const {
+ children,
+ showHandle = true,
+ trapFocus = true,
+ returnFocus = true,
+ showOverlay = true,
+ lockScroll = true,
+ keepMounted = false,
+ visuallyHiddenDismiss = true,
+ initialFocus,
+ minHeight,
+ maxHeight,
+ padding = 'default',
+ className,
+ style,
+ } = props;
+
+ const { getLabel } = useLabels();
+ const { floating, getFloatingProps, context, labelId, onOpenChange, collapsed, onCollapsedChange } =
+ useSheetContext();
+ const { isMounted, status } = useTransitionStatus(context, { duration: { open: 350, close: 300 } });
+
+ const { snapPoints, defaultSnapPoint, onSnapPointChange } = props;
+ const snaps = useMemo(() => (snapPoints ? [...snapPoints].sort((a, b) => a - b) : []), [snapPoints]);
+ const hasSnaps = snaps.length > 0;
+ const maxSnap = snaps[snaps.length - 1] ?? 1;
+ const [snapIndex, setSnapIndex] = useState(() =>
+ defaultSnapPoint !== undefined ? defaultSnapPoint : Math.max(0, snaps.length - 1)
+ );
+ const currentSnap = snaps[Math.min(snapIndex, snaps.length - 1)] ?? maxSnap;
+
+ const viewportHeight = () => (typeof window !== 'undefined' ? window.innerHeight : 0);
+ const snapHeightPx = (snap: number) => snap * viewportHeight();
+
+ const dragStartRef = useRef(null);
+ const [dragOffset, setDragOffset] = useState(0);
+ const [dragging, setDragging] = useState(false);
+
+ const handleDragStart = (event: ReactPointerEvent) => {
+ dragStartRef.current = event.clientY;
+ setDragging(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+
+ const handleDragMove = (event: ReactPointerEvent) => {
+ if (dragStartRef.current === null) return;
+ const delta = event.clientY - dragStartRef.current;
+
+ setDragOffset(hasSnaps ? delta : Math.max(0, delta));
+ };
+
+ const handleDragEnd = (event: ReactPointerEvent) => {
+ if (dragStartRef.current === null) return;
+ const delta = event.clientY - dragStartRef.current;
+ dragStartRef.current = null;
+ setDragging(false);
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+
+ if (hasSnaps) {
+ const height = snapHeightPx(currentSnap) - delta;
+ if (height < snapHeightPx(snaps[0]) - DRAG_DISMISS_THRESHOLD) {
+ onOpenChange(false);
+ } else {
+ let nearest = 0;
+ let best = Infinity;
+ snaps.forEach((snap, index) => {
+ const distance = Math.abs(snapHeightPx(snap) - height);
+ if (distance < best) {
+ best = distance;
+ nearest = index;
+ }
+ });
+ setSnapIndex(nearest);
+ onSnapPointChange?.(snaps[nearest]);
+ }
+ } else if (collapsed && delta < -DRAG_EXPAND_THRESHOLD) {
+ onCollapsedChange(false);
+ } else if (delta > DRAG_DISMISS_THRESHOLD) {
+ onOpenChange(false);
+ }
+ setDragOffset(0);
+ };
+
+ if (!isMounted && !keepMounted) return null;
+
+ const panelClassName = cn(
+ styles['tedi-sheet__panel'],
+ { [styles['tedi-sheet__panel--padding-small']]: padding === 'small' },
+ className
+ );
+
+ const active = isMounted;
+
+ const ariaLabelledBy = props['aria-labelledby'] ?? (labelId || undefined);
+ const ariaLabel = !ariaLabelledBy ? props['aria-label'] : undefined;
+
+ const radiusOverride = radius === 'card' ? 'var(--card-radius-rounded)' : radius === 'none' ? '0' : undefined;
+ const panelStyle: CSSProperties = {
+ ...(radiusOverride ? ({ '--tedi-sheet-radius': radiusOverride } as CSSProperties) : undefined),
+ ...(minHeight !== undefined ? { minHeight } : undefined),
+ ...(maxHeight !== undefined && !hasSnaps ? { maxHeight } : undefined),
+ ...style,
+ };
+ if (hasSnaps) {
+ panelStyle.maxHeight = `${maxSnap * 100}dvh`;
+ if (dragging) {
+ const height = Math.min(maxSnap * viewportHeight(), Math.max(0, snapHeightPx(currentSnap) - dragOffset));
+ panelStyle.height = `${height}px`;
+ panelStyle.transition = 'none';
+ } else {
+ panelStyle.height = `${currentSnap * 100}dvh`;
+ }
+ } else if (dragOffset > 0) {
+ panelStyle.transform = `translateY(${dragOffset}px)`;
+ panelStyle.transition = 'none';
+ }
+
+ return (
+
+
+
+
+ {showHandle && (
+
+
+
+ )}
+ {children}
+
+
+
+
+ );
+};
+
+SheetContent.displayName = 'Sheet.Content';
+export default SheetContent;
diff --git a/src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx b/src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx
new file mode 100644
index 000000000..93ce02333
--- /dev/null
+++ b/src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx
@@ -0,0 +1,66 @@
+import cn from 'classnames';
+import { ReactNode } from 'react';
+
+import styles from '../../sheet.module.scss';
+import { useSheetContext } from '../../sheet-context';
+
+export type SheetFooterAlign = 'left' | 'center' | 'right';
+
+export interface SheetFooterProps {
+ /**
+ * Footer content - typically the primary actions. Positioned by `align`; when `right`
+ * is set they stay on the `align` edge and `right` is pushed to the opposite edge.
+ */
+ children?: ReactNode;
+ /**
+ * Horizontal placement of the main actions. Ignored when `right` is set (the split
+ * layout pins `children` to the start and `right` to the end).
+ * @default left
+ */
+ align?: SheetFooterAlign;
+ /**
+ * Stretch the actions to fill the footer width - each button grows to an equal share.
+ * @default false
+ */
+ fullWidth?: boolean;
+ /**
+ * Content pinned to the right edge - e.g. an overflow menu button.
+ */
+ right?: ReactNode;
+ /**
+ * Additional class name.
+ */
+ className?: string;
+}
+
+export const SheetFooter = ({
+ children,
+ align = 'left',
+ fullWidth = false,
+ right,
+ className,
+}: SheetFooterProps): JSX.Element | null => {
+ const { collapsed } = useSheetContext();
+
+ if (collapsed) return null;
+
+ const isSplit = Boolean(right);
+ return (
+
+
{children}
+ {isSplit && (
+
{right}
+ )}
+
+ );
+};
+
+SheetFooter.displayName = 'Sheet.Footer';
+export default SheetFooter;
diff --git a/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx b/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx
new file mode 100644
index 000000000..c01514fd5
--- /dev/null
+++ b/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx
@@ -0,0 +1,122 @@
+import cn from 'classnames';
+import { ReactNode, useEffect } from 'react';
+
+import { useLabels } from '../../../../../providers/label-provider';
+import { Heading } from '../../../../base/typography/heading/heading';
+import ClosingButton, { ClosingButtonProps } from '../../../../buttons/closing-button/closing-button';
+import { CollapseButton } from '../../../../buttons/collapse-button/collapse-button';
+import styles from '../../sheet.module.scss';
+import { useSheetContext } from '../../sheet-context';
+
+export type SheetHeaderVariant = 'brand' | 'default';
+
+export interface SheetHeaderProps {
+ /**
+ * Header title. Rendered as an `
` and wired to the dialog's `aria-labelledby`.
+ */
+ title?: ReactNode;
+ /**
+ * Colour scheme.
+ * - `brand` - brand-blue background with white content.
+ * - `default` - surface background with primary content.
+ * @default brand
+ */
+ variant?: SheetHeaderVariant;
+ /**
+ * Centre the title within the header instead of aligning it to the start. The action buttons stay
+ * pinned to the trailing edge.
+ * @default false
+ */
+ centerTitle?: boolean;
+ /**
+ * Show a collapse toggle that peeks the sheet down to just its header.
+ * @default false
+ */
+ collapsible?: boolean;
+ /**
+ * Trailing content shown before the collapse / close actions - e.g. a count `Tag` or a small action button.
+ */
+ slot?: ReactNode;
+ /**
+ * Show the close button.
+ * @default true
+ */
+ closeButton?: boolean;
+ /**
+ * Props forwarded to the close button (except `onClick`, which is managed).
+ */
+ closeButtonProps?: Omit;
+ /**
+ * Custom header content. Replaces the default title / action layout entirely.
+ */
+ children?: ReactNode;
+ /**
+ * Additional class name.
+ */
+ className?: string;
+}
+
+export const SheetHeader = (props: SheetHeaderProps): JSX.Element => {
+ const {
+ title,
+ variant = 'brand',
+ centerTitle = false,
+ collapsible = false,
+ slot,
+ closeButton = true,
+ closeButtonProps,
+ children,
+ className,
+ } = props;
+ const { getLabel } = useLabels();
+ const { onOpenChange, labelId, setHasTitle, collapsed, onCollapsedChange } = useSheetContext();
+
+ useEffect(() => {
+ setHasTitle(Boolean(title) && (children === undefined || children === null));
+ return () => setHasTitle(false);
+ }, [title, children, setHasTitle]);
+
+ return (
+
+
+);
+
+// Realistic gallery body - a small settings group rather than a lone sentence of filler text.
+const headerBody = (
+
+
+
+
+
+
+
+);
+
+// Leading (left) content. There's no first-class left slot - `slot` sits on the trailing side next
+// to the actions. For a leading element, pass `children` (which replaces the default title / actions
+// layout) and re-add the close button via `Sheet.Closer`; the dialog is named through
+// `aria-labelledby` pointing at the custom heading.
+const leadingTagHeader = (
+
+
+ 2
+
+ Teated
+
+
+
+
+
+ {headerBody}
+
+);
+
+const leadingBackHeader = (
+
+
+
+
+ Teated
+
+
+ {isExpanded && layer.description && (
+ // Top padding so the expanded detail isn't cramped against the row that toggles it.
+
+
+ {layer.description}
+
+
+ )}
+
+ );
+ })}
+
+
+ Rohkem valikuid
+
+ }
+ >
+
+
+
+
+
+
+
+ );
+}
+
+const MAP_LAYERS = ['Hübriidkaart', 'Reljeef', 'Katastriüksused', 'Kitsendused', 'Aadressandmed'];
+
+/** Controlled open state - the parent owns visibility via `open` + `onToggle`. */
+export const Controlled: Story = {
+ render: function ControlledSheet() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+
+
+
+ Paani avatust juhib vanemkomponent `open` / `onToggle` kaudu.
+
+
+
+
+
+
+
+
+
+ >
+ );
+ },
+};
+
+/**
+ * **Snap points.** `snapPoints={[0.4, 0.9]}` lets the bottom sheet rest at 40% or 90% of the
+ * viewport. Drag the handle to move between them - releasing snaps to the nearest, and dragging
+ * below the lowest point dismisses.
+ */
+export const SnapPoints: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+ {MAP_LAYERS.concat(MAP_LAYERS).map((layer, i) => (
+
+ ))}
+
+
+
+
+ ),
+};
+
+/**
+ * **`keepMounted`.** The panel stays in the DOM (hidden) while closed, so its content and state
+ * persist. Type into the field, close the sheet, then reopen - the value is retained.
+ */
+export const KeepMounted: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+/**
+ * **Docked peek.** With `showOverlay`, `trapFocus` and `lockScroll` off (plus
+ * `closeOnBackdropClick={false}`), a `collapsible` sheet stays docked as a header peek while the page
+ * behind stays fully usable and clicks don't dismiss it (a non-modal sheet).
+ */
+export const DockedPeek: Story = {
+ name: 'Docked peek',
+ render: function DockedPeek() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+};
+
+const FOREST_TYPES: ISelectOption[] = [
+ { value: 'commercial', label: 'Majandusmets' },
+ { value: 'protected', label: 'Kaitsemets' },
+];
+const SPECIES: ISelectOption[] = [
+ { value: 'pine', label: 'Mänd' },
+ { value: 'spruce', label: 'Kuusk' },
+ { value: 'birch', label: 'Kask' },
+];
+const USES: ISelectOption[] = [
+ { value: 'thinning', label: 'Harvendusraie' },
+ { value: 'clear', label: 'Lageraie' },
+];
+const PLACES: ISelectOption[] = [
+ { value: 'tartu', label: 'Tartumaa' },
+ { value: 'harju', label: 'Harjumaa' },
+];
+
+/**
+ * A larger desktop layout: a table-style input view inside the sheet. `maxHeight` caps the panel, so
+ * the body scrolls once enough **Lisa rida** rows are added instead of the sheet growing without
+ * bound. The header carries an extra fullscreen action alongside collapse / close, and the footer
+ * groups the **Kopeeri** and **Lisa rida** links together with the **Tühista** / **Salvesta** actions.
+ */
+export const DesktopTable: Story = {
+ name: 'Desktop table input',
+ render: function DesktopTable() {
+ const [rows, setRows] = useState([0, 1, 2, 3]);
+ const columnStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, minmax(6rem, 1fr))', gap: '0.5rem' };
+
+ return (
+
+
+
+
+ {/* maxHeight caps the panel so the table body scrolls once more rows are added. */}
+
+ }
+ />
+
+
+ {rows.map((row) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+};
diff --git a/src/tedi/components/overlays/sheet/sheet.tsx b/src/tedi/components/overlays/sheet/sheet.tsx
index 1e8c1dbc8..ae1932dec 100644
--- a/src/tedi/components/overlays/sheet/sheet.tsx
+++ b/src/tedi/components/overlays/sheet/sheet.tsx
@@ -1,120 +1,137 @@
-import cn from 'classnames';
-import { ReactNode, useId } from 'react';
+import { useClick, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react';
+import { ReactNode, useCallback, useEffect, useId, useMemo, useState } from 'react';
-import { Text } from '../../base/typography/text/text';
-import { ClosingButton } from '../../buttons/closing-button/closing-button';
-import { Modal } from '../modal/modal';
-import styles from './sheet.module.scss';
+import { SheetBody } from './components/sheet-body/sheet-body';
+import { SheetCloser } from './components/sheet-closer/sheet-closer';
+import { SheetContent } from './components/sheet-content/sheet-content';
+import { SheetFooter } from './components/sheet-footer/sheet-footer';
+import { SheetHeader } from './components/sheet-header/sheet-header';
+import { SheetTrigger } from './components/sheet-trigger/sheet-trigger';
+import { SheetContext, SheetContextValue } from './sheet-context';
-interface SheetBaseProps {
- /** Controlled open state. Pair with `onToggle`; omit for uncontrolled use with `defaultOpen`. */
- open?: boolean;
- /** Called when the sheet opens or closes. */
- onToggle?: (open: boolean) => void;
+export type SheetRole = 'dialog' | 'alertdialog';
+
+export interface SheetProps {
/**
- * Initial open state in uncontrolled mode. Ignored when `open` is provided.
+ * `Sheet.Trigger`, `Sheet.Content` and any other content.
+ */
+ children: ReactNode;
+ /**
+ * Initial open state for uncontrolled usage.
* @default false
*/
defaultOpen?: boolean;
/**
- * Whether the close button is shown next to the `title`. Only applies to the
- * `title` layout — a custom `header` owns its own controls.
+ * Controlled open state. Provide together with `onToggle`.
+ */
+ open?: boolean;
+ /**
+ * Called whenever the sheet requests to open or close (trigger click, backdrop,
+ * Escape, close button). Required to react to changes in controlled mode.
+ */
+ onToggle?: (open: boolean) => void;
+ /**
+ * Close the sheet when the backdrop is clicked.
* @default true
*/
- closeButton?: boolean;
- /** Extra class name applied to the sheet container. */
- className?: string;
- /** Sheet body content. */
- children: ReactNode;
+ closeOnBackdropClick?: boolean;
+ /**
+ * Close the sheet when Escape is pressed.
+ * @default true
+ */
+ closeOnEscape?: boolean;
+ /**
+ * ARIA role of the sheet dialog. Use `alertdialog` for interruptive confirmations.
+ * @default dialog
+ */
+ role?: SheetRole;
}
-/**
- * The dialog must always have an accessible name (WCAG 4.1.2). A plain `title` names it via
- * `aria-labelledby`; when no title is rendered (a custom `header`, or no title at all) an explicit
- * `ariaLabel` is required — the type enforces one of these.
- */
-export type SheetProps = SheetBaseProps &
- (
- | {
- /**
- * Plain header title, rendered as the sheet's bold heading (with a close button per
- * `closeButton`) and wired to the dialog's `aria-labelledby`.
- */
- title: ReactNode;
- header?: never;
- /** Optional — the rendered `title` already names the dialog. */
- ariaLabel?: string;
- }
- | {
- /**
- * Accessible name for the dialog. Required here because no plain `title` is rendered
- * (a custom `header`, or no title).
- */
- ariaLabel: string;
- /** Plain header title. Ignored when `header` is set. */
- title?: ReactNode;
- /**
- * Full header override (custom controls, layout, …), rendered as `Modal.Header` children —
- * replaces the default title/close-button layout, so the consumer owns any close control.
- * Takes precedence over `title`.
- */
- header?: ReactNode;
- }
+export const Sheet = (props: SheetProps): JSX.Element => {
+ const {
+ children,
+ defaultOpen = false,
+ open: controlledOpen,
+ onToggle,
+ closeOnBackdropClick = true,
+ closeOnEscape = true,
+ role = 'dialog',
+ } = props;
+
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
+ const isControlled = typeof controlledOpen !== 'undefined';
+ const isOpen = isControlled ? controlledOpen : internalOpen;
+
+ const [collapsed, setCollapsed] = useState(false);
+
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ if (!isControlled) setInternalOpen(next);
+ onToggle?.(next);
+ },
+ [isControlled, onToggle]
);
-/**
- * ⚠️ Internal, temporary component — **not exported publicly**.
- *
- * A bottom-sheet overlay built on `Modal` (`position="bottom"` +
- * `fullscreen="edge"`), so the sheet sits flush to the screen edges with no
- * outer margin. It centralises that configuration for the mobile variants that
- * need a sheet (e.g. `TableOfContents.Collapsible`, `CardStepper`) until a real
- * `Sheet` component exists — at which point these usages migrate to it.
- */
-export const Sheet = ({
- open,
- onToggle,
- defaultOpen,
- title,
- header,
- closeButton = true,
- ariaLabel,
- className,
- children,
-}: SheetProps): JSX.Element => {
- const titleId = useId();
+ useEffect(() => {
+ if (isOpen) setCollapsed(false);
+ }, [isOpen]);
- return (
-
-
- {header ? (
- {header}
- ) : (
-
-