From e05e3ccee3990e8735234d0c9029c0d8ef884967 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:57:03 +0300 Subject: [PATCH 01/10] feat(sheet): new TEDI-Ready component #736 --- component.manifest.json | 9 + skills/tedi-react/references/components.md | 18 + .../navigation/card-stepper/card-stepper.tsx | 6 +- .../table-of-contents-collapsible.tsx | 6 +- .../components/sheet-body/sheet-body.tsx | 27 ++ .../components/sheet-closer/sheet-closer.tsx | 24 ++ .../sheet-content/sheet-content.tsx | 255 +++++++++++ .../components/sheet-footer/sheet-footer.tsx | 46 ++ .../components/sheet-header/sheet-header.tsx | 122 ++++++ .../sheet-modal/sheet-modal.module.scss | 19 + .../components/sheet-modal/sheet-modal.tsx | 115 +++++ .../sheet-trigger/sheet-trigger.tsx | 39 ++ src/tedi/components/overlays/sheet/index.ts | 9 + .../overlays/sheet/sheet-context.tsx | 44 ++ .../overlays/sheet/sheet.module.scss | 170 +++++++- .../components/overlays/sheet/sheet.spec.tsx | 251 +++++++++++ .../overlays/sheet/sheet.stories.tsx | 402 ++++++++++++++++++ src/tedi/components/overlays/sheet/sheet.tsx | 219 +++++----- src/tedi/index.ts | 1 + .../providers/label-provider/labels-map.ts | 21 + 20 files changed, 1690 insertions(+), 113 deletions(-) create mode 100644 src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx create mode 100644 src/tedi/components/overlays/sheet/components/sheet-closer/sheet-closer.tsx create mode 100644 src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx create mode 100644 src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx create mode 100644 src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx create mode 100644 src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.module.scss create mode 100644 src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx create mode 100644 src/tedi/components/overlays/sheet/components/sheet-trigger/sheet-trigger.tsx create mode 100644 src/tedi/components/overlays/sheet/index.ts create mode 100644 src/tedi/components/overlays/sheet/sheet-context.tsx create mode 100644 src/tedi/components/overlays/sheet/sheet.spec.tsx create mode 100644 src/tedi/components/overlays/sheet/sheet.stories.tsx diff --git a/component.manifest.json b/component.manifest.json index 2eb9eb077..8882ada06 100644 --- a/component.manifest.json +++ b/component.manifest.json @@ -977,6 +977,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 0e26286bc..15ffc554f 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. ### Composition constraints @@ -144,6 +150,18 @@ 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`; the drag + handle still dismisses). ### Responsive behaviour that isn't a prop 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 db6b7c2de..2d72689dc 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, @@ -70,7 +70,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..c0983ac12 --- /dev/null +++ b/src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx @@ -0,0 +1,27 @@ +import cn from 'classnames'; +import { ReactNode } from 'react'; + +import styles from '../../sheet.module.scss'; +import { useSheetContext } from '../../sheet-context'; + +export interface SheetBodyProps { + /** + * Body content. Scrolls when it overflows the available height. + */ + children: ReactNode; + /** + * Additional class name. + */ + className?: string; +} + +export const SheetBody = ({ children, 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..502a3efd7 --- /dev/null +++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx @@ -0,0 +1,255 @@ +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 { useLabels } from '../../../../../providers/label-provider'; +import styles from '../../sheet.module.scss'; +import { useSheetContext } from '../../sheet-context'; + +const DRAG_DISMISS_THRESHOLD = 120; + +export interface SheetContentProps { + /** + * ``, ``, `` 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. + * @default false + */ + 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']; + /** + * Additional class name on the sheet panel. + */ + className?: string; + /** + * Inline style applied to the sheet panel - handy for a custom `maxHeight` / `maxWidth`. + */ + style?: CSSProperties; +} + +export const SheetContent = (props: SheetContentProps): JSX.Element | null => { + const { + children, + showHandle = true, + trapFocus = true, + returnFocus = true, + showOverlay = true, + lockScroll = true, + keepMounted = false, + visuallyHiddenDismiss = false, + initialFocus, + className, + style, + } = props; + + const { getLabel } = useLabels(); + const { floating, getFloatingProps, context, labelId, onOpenChange } = 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 (delta > DRAG_DISMISS_THRESHOLD) { + onOpenChange(false); + } + setDragOffset(0); + }; + + if (!isMounted && !keepMounted) return null; + + const panelClassName = cn(styles['tedi-sheet__panel'], className); + + const active = isMounted; + + const ariaLabelledBy = props['aria-labelledby'] ?? (labelId || undefined); + const ariaLabel = !ariaLabelledBy ? props['aria-label'] : undefined; + + const panelStyle: CSSProperties = { ...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..4c4587c54 --- /dev/null +++ b/src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx @@ -0,0 +1,46 @@ +import cn from 'classnames'; +import { ReactNode } from 'react'; + +import styles from '../../sheet.module.scss'; +import { useSheetContext } from '../../sheet-context'; + +export interface SheetFooterProps { + /** + * Footer content - typically the primary actions. Placed on the left; when `right` + * is set they stay left and `right` is pushed to the opposite edge. + */ + children?: ReactNode; + /** + * Content pinned to the right edge - e.g. an overflow menu button. + */ + right?: ReactNode; + /** + * Additional class name. + */ + className?: string; +} + +export const SheetFooter = ({ children, 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..b95b248de --- /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) || Boolean(children)); + return () => setHasTitle(false); + }, [title, children, setHasTitle]); + + return ( +
+ {children ?? ( + <> + {title && ( + + {title} + + )} +
+ {slot} + {collapsible && ( + onCollapsedChange(!next)} + inverted={variant === 'brand'} + aria-label={getLabel(collapsed ? 'sheet.expand' : 'sheet.collapse')} + /> + )} + {closeButton && ( + onOpenChange(false)} + /> + )} +
+ + )} +
+ ); +}; + +SheetHeader.displayName = 'Sheet.Header'; +export default SheetHeader; diff --git a/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.module.scss b/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.module.scss new file mode 100644 index 000000000..ebd2453db --- /dev/null +++ b/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.module.scss @@ -0,0 +1,19 @@ +.tedi-sheet.tedi-sheet { + height: auto; + max-height: 80dvh; + border-top: var(--tedi-borders-01) solid var(--card-border-primary); + border-top-left-radius: var(--modal-radius); + border-top-right-radius: var(--modal-radius); +} + +.tedi-sheet__header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; +} + +.tedi-sheet__title { + flex: 1 1 auto; + min-width: 0; +} diff --git a/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx b/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx new file mode 100644 index 000000000..251e99f82 --- /dev/null +++ b/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx @@ -0,0 +1,115 @@ +import cn from 'classnames'; +import { ReactNode, useId } 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-modal.module.scss'; + +interface SheetModalBaseProps { + /** 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; + /** + * Initial open state in uncontrolled mode. Ignored when `open` is provided. + * @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. + * @default true + */ + closeButton?: boolean; + /** Extra class name applied to the sheet container. */ + className?: string; + /** Sheet body content. */ + children: ReactNode; +} + +export type SheetModalProps = SheetModalBaseProps & + ( + | { + /** + * 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; + } + ); + +/** + * ⚠️ 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 they + * migrate to the public compound `Sheet`. + */ +export const SheetModal = ({ + open, + onToggle, + defaultOpen, + title, + header, + closeButton = true, + ariaLabel, + className, + children, +}: SheetModalProps): JSX.Element => { + const titleId = useId(); + + return ( + + + {header ? ( + {header} + ) : ( + +
+ + {title} + + {closeButton && ( + + + + )} +
+
+ )} + {children} +
+
+ ); +}; + +SheetModal.displayName = 'SheetModal'; + +export default SheetModal; diff --git a/src/tedi/components/overlays/sheet/components/sheet-trigger/sheet-trigger.tsx b/src/tedi/components/overlays/sheet/components/sheet-trigger/sheet-trigger.tsx new file mode 100644 index 000000000..113940177 --- /dev/null +++ b/src/tedi/components/overlays/sheet/components/sheet-trigger/sheet-trigger.tsx @@ -0,0 +1,39 @@ +import { cloneElement, JSX, MutableRefObject, Ref } from 'react'; + +import { getElementRef } from '../../../../../helpers/get-element-ref'; +import { useSheetContext } from '../../sheet-context'; + +type AnyRef = Ref | undefined | null; + +const mergeRefs = + (...refs: AnyRef[]) => + (node: T | null) => { + for (const ref of refs) { + if (!ref) continue; + if (typeof ref === 'function') { + ref(node); + } else { + (ref as MutableRefObject).current = node; + } + } + }; + +export interface SheetTriggerProps { + /** + * Element that opens the sheet when clicked. The element is cloned and receives + * the `onClick` / `aria-*` props produced by floating-ui. + */ + children: JSX.Element; +} + +export const SheetTrigger = ({ children }: SheetTriggerProps): JSX.Element => { + const { getReferenceProps, reference } = useSheetContext(); + + const existingRef = getElementRef(children); + const mergedRef = mergeRefs(reference, existingRef as AnyRef); + + return cloneElement(children, getReferenceProps({ ...children.props, ref: mergedRef })); +}; + +SheetTrigger.displayName = 'Sheet.Trigger'; +export default SheetTrigger; diff --git a/src/tedi/components/overlays/sheet/index.ts b/src/tedi/components/overlays/sheet/index.ts new file mode 100644 index 000000000..d1b90a612 --- /dev/null +++ b/src/tedi/components/overlays/sheet/index.ts @@ -0,0 +1,9 @@ +export * from './sheet'; +export * from './components/sheet-trigger/sheet-trigger'; +export * from './components/sheet-content/sheet-content'; +export * from './components/sheet-header/sheet-header'; +export * from './components/sheet-body/sheet-body'; +export * from './components/sheet-footer/sheet-footer'; +export * from './components/sheet-closer/sheet-closer'; +export * from './components/sheet-modal/sheet-modal'; +export * from './sheet-context'; diff --git a/src/tedi/components/overlays/sheet/sheet-context.tsx b/src/tedi/components/overlays/sheet/sheet-context.tsx new file mode 100644 index 000000000..bd96b1d64 --- /dev/null +++ b/src/tedi/components/overlays/sheet/sheet-context.tsx @@ -0,0 +1,44 @@ +import { FloatingContext, ReferenceType } from '@floating-ui/react'; +import { createContext, useContext } from 'react'; + +export interface SheetContextValue { + open: boolean; + onOpenChange: (open: boolean) => void; + reference: (node: ReferenceType | null) => void; + floating: (node: HTMLElement | null) => void; + getReferenceProps: (userProps?: React.HTMLProps) => Record; + getFloatingProps: (userProps?: React.HTMLProps) => Record; + context: FloatingContext; + labelId: string; + setHasTitle: (hasTitle: boolean) => void; + /** Whether the sheet is collapsed to just its header (bottom-sheet peek state). */ + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; +} + +export const SheetContext = createContext(null); + +export const useSheetContext = (): SheetContextValue => { + const ctx = useContext(SheetContext); + if (!ctx) { + throw new Error('Sheet subcomponents must be rendered inside .'); + } + return ctx; +}; + +export interface UseSheetReturn { + open: boolean; + onOpenChange: (open: boolean) => void; + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + labelId: string; +} + +/** + * Read and control the surrounding `Sheet`'s open / collapsed state from a + * descendant (e.g. to close it from a custom action). + */ +export const useSheet = (): UseSheetReturn => { + const { open, onOpenChange, collapsed, onCollapsedChange, labelId } = useSheetContext(); + return { open, onOpenChange, collapsed, onCollapsedChange, labelId }; +}; diff --git a/src/tedi/components/overlays/sheet/sheet.module.scss b/src/tedi/components/overlays/sheet/sheet.module.scss index ebd2453db..ae61b13e1 100644 --- a/src/tedi/components/overlays/sheet/sheet.module.scss +++ b/src/tedi/components/overlays/sheet/sheet.module.scss @@ -1,9 +1,94 @@ -.tedi-sheet.tedi-sheet { - height: auto; - max-height: 80dvh; - border-top: var(--tedi-borders-01) solid var(--card-border-primary); - border-top-left-radius: var(--modal-radius); - border-top-right-radius: var(--modal-radius); +$sheet-enter-duration: 0.35s; +$sheet-enter-easing: cubic-bezier(0.32, 0.72, 0, 1); +$sheet-fade-duration: 200ms; + +.tedi-sheet__overlay { + z-index: var(--z-index-modal); + display: flex; + align-items: flex-end; + justify-content: center; + + &::before { + position: absolute; + inset: 0; + content: ''; + background: var(--general-surface-overlay); + opacity: 0; + transition: opacity $sheet-fade-duration ease-out; + } + + &[data-status='open']::before { + opacity: 1; + } +} + +.tedi-sheet__overlay--no-overlay { + pointer-events: none; + + &::before { + content: none; + } + + & .tedi-sheet__panel { + pointer-events: auto; + } +} + +.tedi-sheet__panel { + --tedi-sheet-radius: var(--tedi-dimensions-13); + --tedi-sheet-padding-x: var(--tedi-dimensions-10); + --tedi-sheet-padding-y: var(--tedi-dimensions-05); + --tedi-sheet-header-min-height: 3.75rem; + + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-height: 90dvh; + overflow: hidden; + background: var(--sheet-body-default); + border-top-left-radius: var(--tedi-sheet-radius); + border-top-right-radius: var(--tedi-sheet-radius); + transform: translateY(100%); + transition: transform $sheet-enter-duration $sheet-enter-easing, height $sheet-enter-duration $sheet-enter-easing; + + &[data-status='open'] { + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .tedi-sheet__overlay, + .tedi-sheet__panel { + transition: none; + } +} + +.tedi-sheet__handle-zone { + position: absolute; + top: 0; + left: 50%; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: center; + width: 6rem; + height: var(--tedi-dimensions-13); + padding-top: var(--tedi-dimensions-04); + touch-action: none; + cursor: grab; + transform: translateX(-50%); + + &:active { + cursor: grabbing; + } +} + +.tedi-sheet__handle { + width: 2.5rem; + height: var(--tedi-borders-04); + background: var(--drag-indicator-light); + border-radius: var(--tedi-dimensions-13); } .tedi-sheet__header { @@ -11,9 +96,82 @@ gap: var(--layout-grid-gutters-08); align-items: center; justify-content: space-between; + min-height: var(--tedi-sheet-header-min-height); + padding: var(--tedi-sheet-padding-y) var(--tedi-sheet-padding-x); + border-top-left-radius: var(--tedi-sheet-radius); + border-top-right-radius: var(--tedi-sheet-radius); +} + +.tedi-sheet__header--brand { + color: var(--general-text-white); + background: var(--sheet-header-background-brand); +} + +.tedi-sheet__header--default { + color: var(--general-text-primary); + background: var(--sheet-header-background-default); + border-bottom: var(--tedi-borders-01) solid var(--sheet-border); } .tedi-sheet__title { flex: 1 1 auto; min-width: 0; + margin: 0; + color: inherit; +} + +.tedi-sheet__header--center { + position: relative; + + .tedi-sheet__title { + text-align: center; + } + + .tedi-sheet__header-actions { + position: absolute; + inset-block-start: 50%; + inset-inline-end: var(--tedi-sheet-padding-x); + transform: translateY(-50%); + } +} + +.tedi-sheet__header-actions { + display: flex; + flex-shrink: 0; + gap: var(--layout-grid-gutters-08); + align-items: center; +} + +.tedi-sheet__body { + flex: 1 1 auto; + padding: var(--tedi-sheet-padding-x); + overflow-y: auto; + background: var(--sheet-body-default); +} + +.tedi-sheet__footer { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + padding: var(--tedi-sheet-padding-x); + background: var(--sheet-footer-background); + border-top: var(--tedi-borders-01) solid var(--sheet-border); +} + +.tedi-sheet__footer--split { + justify-content: space-between; +} + +.tedi-sheet__footer--start { + justify-content: flex-start; +} + +.tedi-sheet__footer-side { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; +} + +.tedi-sheet__footer-side--right { + justify-content: flex-end; } diff --git a/src/tedi/components/overlays/sheet/sheet.spec.tsx b/src/tedi/components/overlays/sheet/sheet.spec.tsx new file mode 100644 index 000000000..1cb320026 --- /dev/null +++ b/src/tedi/components/overlays/sheet/sheet.spec.tsx @@ -0,0 +1,251 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; + +import { Sheet } from './sheet'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: jest.fn(() => ({ + getLabel: jest.fn((key: string) => (key === 'close' ? 'Close' : `Mocked label: ${key}`)), + })), +})); + +describe('Sheet', () => { + it('opens on trigger click and renders body content', () => { + render( + + + + + + + Body content + + + ); + + expect(screen.queryByText('Body content')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Body content')).toBeInTheDocument(); + }); + + it('wires the header title to the dialog via aria-labelledby', () => { + render( + + + + Body + + + ); + + const dialog = screen.getByRole('dialog'); + const labelledBy = dialog.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + expect(document.getElementById(labelledBy as string)).toHaveTextContent('Title text'); + }); + + it('falls back to aria-label when no header title is set', () => { + render( + + + Body + + + ); + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'Themes'); + }); + + it('closes when the header close button is clicked', async () => { + render( + + + + Body + + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('closes on Escape', async () => { + render( + + + + Body + + + ); + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('renders the drag handle by default and hides it when showHandle is false', () => { + const { rerender } = render( + + + + + + ); + expect(document.querySelector('[data-name="sheet-handle"]')).toBeInTheDocument(); + + rerender( + + + + + + ); + expect(document.querySelector('[data-name="sheet-handle"]')).not.toBeInTheDocument(); + }); + + it('collapses the body and footer via the header toggle', () => { + render( + + + + Body content + Footer content + + + ); + + expect(screen.getByText('Body content')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Mocked label: sheet.collapse' })); + expect(screen.queryByText('Body content')).not.toBeInTheDocument(); + expect(screen.queryByText('Footer content')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Mocked label: sheet.expand' })); + expect(screen.getByText('Body content')).toBeInTheDocument(); + }); + + it('closes via Sheet.Closer while calling the child onClick first', async () => { + const onClick = jest.fn(); + render( + + + Body + + + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('supports controlled open state', async () => { + const ControlledSheet = () => { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Body content + + + + ); + }; + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('renders header slot content before the actions', () => { + render( + + + Slot content} /> + Body + + + ); + + expect(screen.getByText('Slot content')).toBeInTheDocument(); + }); + + it('applies the centre-title modifier only when centerTitle is set', () => { + const { rerender } = render( + + + + Body + + + ); + expect(document.querySelector('[class*="tedi-sheet__header--center"]')).not.toBeInTheDocument(); + + rerender( + + + + Body + + + ); + expect(document.querySelector('[class*="tedi-sheet__header--center"]')).toBeInTheDocument(); + }); + + it('removes the panel from the DOM while closed by default', () => { + render( + + + Persisted body + + + ); + + expect(document.querySelector('[class*="tedi-sheet__panel"]')).not.toBeInTheDocument(); + }); + + it('keeps the panel mounted (hidden, inert) while closed when keepMounted is set', () => { + render( + + + Persisted body + + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + const panel = document.querySelector('[class*="tedi-sheet__panel"]'); + expect(panel).toBeInTheDocument(); + expect(panel).toHaveAttribute('hidden'); + expect(panel).toHaveTextContent('Persisted body'); + }); + + it('sizes a bottom sheet to the tallest snap point when snapPoints is set', () => { + render( + + + Body + + + ); + + const panel = document.querySelector('[class*="tedi-sheet__panel"]'); + expect(panel).toHaveStyle({ height: '90dvh' }); + }); +}); diff --git a/src/tedi/components/overlays/sheet/sheet.stories.tsx b/src/tedi/components/overlays/sheet/sheet.stories.tsx new file mode 100644 index 000000000..5de8ec136 --- /dev/null +++ b/src/tedi/components/overlays/sheet/sheet.stories.tsx @@ -0,0 +1,402 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; + +import { Text } from '../../base/typography/text/text'; +import { Button } from '../../buttons/button/button'; +import { Checkbox } from '../../form/checkbox/checkbox'; +import { ChoiceGroup } from '../../form/choice-group/choice-group'; +import { DateField } from '../../form/date-field/date-field'; +import { Search } from '../../form/search/search'; +import { TextField } from '../../form/textfield/textfield'; +import { TimeField } from '../../form/time-field/time-field'; +import { Toggle } from '../../form/toggle/toggle'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { Tabs } from '../../navigation/tabs/tabs'; +import { Tag } from '../../tags/tag/tag'; +import { Sheet } from './sheet'; + +/** + * Figma ↗ + */ +const meta: Meta = { + component: Sheet, + subcomponents: { + 'Sheet.Trigger': Sheet.Trigger, + 'Sheet.Content': Sheet.Content, + 'Sheet.Header': Sheet.Header, + 'Sheet.Body': Sheet.Body, + 'Sheet.Footer': Sheet.Footer, + 'Sheet.Closer': Sheet.Closer, + }, + title: 'TEDI-Ready/Components/Overlay/Sheet', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.75.90?node-id=58104-185971&m=dev', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const demoBody = ( + + Hinnatsoon + EUREF-EST97 + UTM 34N võrk + +); + +export const Default: Story = { + render: (args) => ( + + + + + + + + +
+ Saatja +
+ 2 +
+
+ + +
+
+ }> + + + + + +
+
+ ), +}; + +export const Collapsible: Story = { + render: () => ( + + + + + + + + + + + + + + }> + + + + + + + + ), +}; + +export const WithoutActionButtons: Story = { + render: () => ( + + + + + + + + + + + + + + + ), +}; + +const headerExample = (trigger: string, header: JSX.Element) => ( + + + + + + {header} + {demoBody} + + +); + +export const Header: Story = { + render: () => ( + + + Variant +
+ {headerExample('Primary', )} + {headerExample('Brand', )} +
+
+ + + Type +
+ {headerExample('Default', )} + {headerExample('Collapsible', )} + {headerExample('Title center', )} + {headerExample( + 'Without closing button', + + )} + {headerExample( + 'Different closing button', + +
+
+
+ ), +}; + +/** 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: () => ( + + + + + + + + + {Array.from({ length: 20 }, (_, i) => ( + Kaardikiht {i + 1} + ))} + + + + + ), +}; + +/** + * **`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: () => ( + + + + + + + + + + + + ), +}; + +const MAP_LAYERS = ['Hübriidkaart', 'Reljeef', 'Katastriüksused', 'Kitsendused', 'Aadressandmed']; + +const SearchableLayersSheet = () => { + const [query, setQuery] = useState(''); + const [checked, setChecked] = useState(['Hübriidkaart']); + + const visibleLayers = MAP_LAYERS.filter((layer) => layer.toLowerCase().includes(query.trim().toLowerCase())); + + const toggleLayer = (layer: string, isChecked: boolean) => + setChecked((prev) => (isChecked ? [...prev, layer] : prev.filter((item) => item !== layer))); + + return ( + + + + + + 0 ? {checked.length} : undefined} + /> + + + + {visibleLayers.length > 0 ? ( + + {visibleLayers.map((layer) => { + const index = MAP_LAYERS.indexOf(layer); + return ( + toggleLayer(layer, isChecked)} + /> + ); + })} + + ) : ( + Otsingule vastavaid kihte ei leitud. + )} + + + + + ); +}; + +export const Examples: Story = { + render: () => ( +
+ + + + + + + + + + Maastik + Muu näitab + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +}; diff --git a/src/tedi/components/overlays/sheet/sheet.tsx b/src/tedi/components/overlays/sheet/sheet.tsx index 1e8c1dbc8..bcec6b968 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 && collapsed) setCollapsed(false); + }, [isOpen, collapsed]); - return ( - - - {header ? ( - {header} - ) : ( - -
- - {title} - - {closeButton && ( - - - - )} -
-
- )} - {children} -
-
+ const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: handleOpenChange, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + useClick(context), + useRole(context, { role }), + useDismiss(context, { + enabled: closeOnBackdropClick || closeOnEscape, + escapeKey: closeOnEscape, + outsidePress: closeOnBackdropClick, + }), + ]); + + const baseId = useId(); + const labelId = `${baseId}-label`; + const [hasTitle, setHasTitle] = useState(false); + + const value = useMemo( + () => ({ + open: isOpen, + onOpenChange: handleOpenChange, + reference: refs.setReference, + floating: refs.setFloating, + getReferenceProps, + getFloatingProps, + context, + labelId: hasTitle ? labelId : '', + setHasTitle, + collapsed, + onCollapsedChange: setCollapsed, + }), + [ + isOpen, + handleOpenChange, + refs.setReference, + refs.setFloating, + getReferenceProps, + getFloatingProps, + context, + hasTitle, + labelId, + collapsed, + ] ); + + return {children}; }; -Sheet.displayName = 'Sheet'; +Sheet.Trigger = SheetTrigger; +Sheet.Content = SheetContent; +Sheet.Header = SheetHeader; +Sheet.Body = SheetBody; +Sheet.Footer = SheetFooter; +Sheet.Closer = SheetCloser; +Sheet.displayName = 'Sheet'; export default Sheet; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index afd781592..4bebc4bdc 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -66,6 +66,7 @@ export * from './components/overlays/tooltip'; export * from './components/overlays/popover'; export * from './components/overlays/dropdown'; export * from './components/overlays/modal'; +export * from './components/overlays/sheet'; export * from './components/misc/separator/separator'; export * from './components/misc/print/print'; export * from './components/misc/stretch-content/stretch-content'; diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 95f6fc846..a597cb483 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -555,6 +555,27 @@ export const labelsMap = validateDefaultLabels({ en: 'Close modal', ru: 'Закрыть модальное окно', }, + 'sheet.close': { + description: 'Label for the Sheet close button', + components: ['Sheet'], + et: 'Sulge', + en: 'Close', + ru: 'Закрыть', + }, + 'sheet.collapse': { + description: 'Label for the Sheet collapse toggle when the sheet is expanded', + components: ['Sheet'], + et: 'Ahenda', + en: 'Collapse', + ru: 'Свернуть', + }, + 'sheet.expand': { + description: 'Label for the Sheet collapse toggle when the sheet is collapsed', + components: ['Sheet'], + et: 'Laienda', + en: 'Expand', + ru: 'Развернуть', + }, 'dateField.disabledDateError': { description: 'Inline error shown when the user types a date that fails the disable matchers (`disablePast`, `disableFuture`, `minDate`, `maxDate`, `disabledMatchers`).', From 7ce20271f6d8309e9a5d8e4a4447dc004d148b21 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:13:24 +0300 Subject: [PATCH 02/10] feat(sheet): update core version to access sheet tokens #736 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f9a82e03..f604eec2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "^6.8.1", + "@tedi-design-system/core": "^6.9.0", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", @@ -10560,9 +10560,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.8.1.tgz", - "integrity": "sha512-CbiGY4SNcbn005coA7O40D9kDKtcFJSxLoang25IWqFQda+My0qynlenvsJDsnGpwkqx7u5ddkFTlU3bJUtgXA==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.9.0.tgz", + "integrity": "sha512-+RPnP8d9xcgabfFODJRd472JPzRIT8OR6XMd67RtbKyLOMNHKl8RwT22DCKcMnH4FEMv/v7Uay1jwgWL9Wp/Rg==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index 7f8565f8e..5c7fe9199 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "^6.8.1", + "@tedi-design-system/core": "^6.9.0", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", From 2b1adef6ac6e63aea535e11129bcfb94d42ff20d Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:51:29 +0300 Subject: [PATCH 03/10] feat(sheet): drag handle fix, radius and breakpoint prop features #736 --- skills/tedi-react/references/components.md | 10 +- .../sheet-content/sheet-content.tsx | 33 ++++++- .../components/overlays/sheet/sheet.spec.tsx | 96 +++++++++++++++++++ .../overlays/sheet/sheet.stories.tsx | 39 +++++++- 4 files changed, 170 insertions(+), 8 deletions(-) diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index 15ffc554f..a6b57bf83 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -160,8 +160,14 @@ files. This is the part of this document worth maintaining by hand. - **`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`; the drag - handle still dismisses). + 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`. ### Responsive behaviour that isn't a prop 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 index 502a3efd7..004b6b4a1 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx @@ -10,13 +10,30 @@ import { 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; -export interface SheetContentProps { +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. */ @@ -99,6 +116,9 @@ export interface SheetContentProps { } export const SheetContent = (props: SheetContentProps): JSX.Element | null => { + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { radius = 'default' } = getCurrentBreakpointProps(props); + const { children, showHandle = true, @@ -114,7 +134,8 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { } = props; const { getLabel } = useLabels(); - const { floating, getFloatingProps, context, labelId, onOpenChange } = useSheetContext(); + const { floating, getFloatingProps, context, labelId, onOpenChange, collapsed, onCollapsedChange } = + useSheetContext(); const { isMounted, status } = useTransitionStatus(context, { duration: { open: 350, close: 300 } }); const { snapPoints, defaultSnapPoint, onSnapPointChange } = props; @@ -170,6 +191,8 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { setSnapIndex(nearest); onSnapPointChange?.(snaps[nearest]); } + } else if (collapsed && delta < -DRAG_EXPAND_THRESHOLD) { + onCollapsedChange(false); } else if (delta > DRAG_DISMISS_THRESHOLD) { onOpenChange(false); } @@ -185,7 +208,11 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { const ariaLabelledBy = props['aria-labelledby'] ?? (labelId || undefined); const ariaLabel = !ariaLabelledBy ? props['aria-label'] : undefined; - const panelStyle: CSSProperties = { ...style }; + const radiusOverride = radius === 'card' ? 'var(--card-radius-rounded)' : radius === 'none' ? '0' : undefined; + const panelStyle: CSSProperties = { + ...(radiusOverride ? ({ '--tedi-sheet-radius': radiusOverride } as CSSProperties) : undefined), + ...style, + }; if (hasSnaps) { panelStyle.maxHeight = `${maxSnap * 100}dvh`; if (dragging) { diff --git a/src/tedi/components/overlays/sheet/sheet.spec.tsx b/src/tedi/components/overlays/sheet/sheet.spec.tsx index 1cb320026..b31456e11 100644 --- a/src/tedi/components/overlays/sheet/sheet.spec.tsx +++ b/src/tedi/components/overlays/sheet/sheet.spec.tsx @@ -10,6 +10,19 @@ jest.mock('../../../providers/label-provider', () => ({ })); describe('Sheet', () => { + beforeAll(() => { + class PointerEventPolyfill extends MouseEvent { + pointerId: number; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + } + } + window.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent; + HTMLElement.prototype.setPointerCapture = jest.fn(); + HTMLElement.prototype.releasePointerCapture = jest.fn(); + }); + it('opens on trigger click and renders body content', () => { render( @@ -125,6 +138,47 @@ describe('Sheet', () => { expect(screen.getByText('Body content')).toBeInTheDocument(); }); + it('expands a collapsed sheet when the drag handle is pulled up', () => { + render( + + + + Body content + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Mocked label: sheet.collapse' })); + expect(screen.queryByText('Body content')).not.toBeInTheDocument(); + + const handle = document.querySelector('[data-name="sheet-handle"]') as HTMLElement; + fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 }); + + // Dragging the peek upward past the threshold expands it back to full height. + expect(screen.getByText('Body content')).toBeInTheDocument(); + }); + + it('closes a collapsed sheet when the drag handle is swiped down', async () => { + render( + + + + Body content + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Mocked label: sheet.collapse' })); + const handle = document.querySelector('[data-name="sheet-handle"]') as HTMLElement; + fireEvent.pointerDown(handle, { clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 260, pointerId: 1 }); + fireEvent.pointerUp(handle, { clientY: 260, pointerId: 1 }); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + it('closes via Sheet.Closer while calling the child onClick first', async () => { const onClick = jest.fn(); render( @@ -248,4 +302,46 @@ describe('Sheet', () => { const panel = document.querySelector('[class*="tedi-sheet__panel"]'); expect(panel).toHaveStyle({ height: '90dvh' }); }); + + it('overrides the corner radius via the radius prop', () => { + const { rerender } = render( + + + Body + + + ); + // Default leaves the sheet radius to the stylesheet. + expect(screen.getByRole('dialog').style.getPropertyValue('--tedi-sheet-radius')).toBe(''); + + rerender( + + + Body + + + ); + expect(screen.getByRole('dialog')).toHaveStyle({ '--tedi-sheet-radius': '0' }); + + rerender( + + + Body + + + ); + expect(screen.getByRole('dialog')).toHaveStyle({ '--tedi-sheet-radius': 'var(--card-radius-rounded)' }); + }); + + it('accepts a per-breakpoint radius and resolves the base breakpoint', () => { + render( + + + Body + + + ); + // jsdom reports the base (xs) breakpoint, so the flat `radius="none"` applies. + expect(screen.getByRole('dialog')).toHaveStyle({ '--tedi-sheet-radius': '0' }); + }); }); diff --git a/src/tedi/components/overlays/sheet/sheet.stories.tsx b/src/tedi/components/overlays/sheet/sheet.stories.tsx index 5de8ec136..d05a1678e 100644 --- a/src/tedi/components/overlays/sheet/sheet.stories.tsx +++ b/src/tedi/components/overlays/sheet/sheet.stories.tsx @@ -211,7 +211,7 @@ export const Header: Story = { ), }; -/** Controlled open state — the parent owns visibility via `open` + `onToggle`. */ +/** Controlled open state - the parent owns visibility via `open` + `onToggle`. */ export const Controlled: Story = { render: function ControlledSheet() { const [open, setOpen] = useState(false); @@ -239,7 +239,7 @@ export const Controlled: Story = { /** * **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 + * viewport. Drag the handle to move between them - releasing snaps to the nearest, and dragging * below the lowest point dismisses. */ export const SnapPoints: Story = { @@ -264,7 +264,7 @@ export const SnapPoints: Story = { /** * **`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. + * persist. Type into the field, close the sheet, then reopen - the value is retained. */ export const KeepMounted: Story = { render: () => ( @@ -282,6 +282,39 @@ export const KeepMounted: Story = { ), }; +/** + * **`radius`.** Override the sheet's top-corner radius - `default`, `card` (matches a `Card`), or + * `none`. It drives both the panel and header corners, and is breakpoint-aware, e.g. + * ``. For any other value, set the + * `--tedi-sheet-radius` custom property via `style`. + */ +export const Radius: Story = { + render: () => ( +
+ {(['default', 'card', 'none'] as const).map((radius) => ( + + + + + + + {demoBody} + + + ))} + + + + + + + {demoBody} + + +
+ ), +}; + const MAP_LAYERS = ['Hübriidkaart', 'Reljeef', 'Katastriüksused', 'Kitsendused', 'Aadressandmed']; const SearchableLayersSheet = () => { From 9b1b314491ec6e08b1e7ae45a1de5a298b77d430 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:54:47 +0300 Subject: [PATCH 04/10] feat(sheet): add persistent example #736 --- .../overlays/sheet/sheet.stories.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/tedi/components/overlays/sheet/sheet.stories.tsx b/src/tedi/components/overlays/sheet/sheet.stories.tsx index d05a1678e..ce4bf5b8c 100644 --- a/src/tedi/components/overlays/sheet/sheet.stories.tsx +++ b/src/tedi/components/overlays/sheet/sheet.stories.tsx @@ -315,6 +315,35 @@ export const Radius: Story = { ), }; +/** + * **Non-modal peek.** `showOverlay`, `trapFocus` and `lockScroll` off (plus + * `closeOnBackdropClick={false}`) keep a `collapsible` sheet docked as a header peek while the page + * behind stays usable and clicks don't dismiss it. + */ +export const NonModalPeek: Story = { + render: function NonModalPeek() { + return ( + + + + + + + + + + + + + + + + + + ); + }, +}; + const MAP_LAYERS = ['Hübriidkaart', 'Reljeef', 'Katastriüksused', 'Kitsendused', 'Aadressandmed']; const SearchableLayersSheet = () => { From 2b147d397e23cff0eb1de1c868913655d9e57235 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:51:19 +0300 Subject: [PATCH 05/10] fix(sheet): fix build error #736 --- .../overlays/sheet/components/sheet-content/sheet-content.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 004b6b4a1..ba9c14ee0 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx @@ -252,10 +252,10 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { 'aria-labelledby': ariaLabelledBy, 'aria-label': ariaLabel, 'aria-modal': trapFocus, - 'data-status': status, style: panelStyle, className: panelClassName, })} + data-status={status} > {showHandle && (
Date: Mon, 21 Sep 2026 15:57:19 +0300 Subject: [PATCH 06/10] feat(sheet): cr fixes #736 --- .../sheet-content/sheet-content.tsx | 9 +- .../components/sheet-header/sheet-header.tsx | 4 +- .../sheet-modal/sheet-modal.spec.tsx | 34 ++++ .../components/sheet-modal/sheet-modal.tsx | 16 +- src/tedi/components/overlays/sheet/index.ts | 1 - .../overlays/sheet/sheet.module.scss | 2 +- .../components/overlays/sheet/sheet.spec.tsx | 170 +++++++++++++++++- 7 files changed, 222 insertions(+), 14 deletions(-) create mode 100644 src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.spec.tsx 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 index ba9c14ee0..3a61de896 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx @@ -86,8 +86,9 @@ export interface SheetContentProps extends BreakpointSupport void; /** * Render visually-hidden dismiss buttons at the start and end of the dialog - * so touch screen-readers can escape without an Escape key. - * @default false + * 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; /** @@ -127,7 +128,7 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { showOverlay = true, lockScroll = true, keepMounted = false, - visuallyHiddenDismiss = false, + visuallyHiddenDismiss = true, initialFocus, className, style, @@ -243,7 +244,7 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { modal={trapFocus} returnFocus={returnFocus} initialFocus={initialFocus} - visuallyHiddenDismiss={visuallyHiddenDismiss && active ? getLabel('sheet.close') : undefined} + visuallyHiddenDismiss={trapFocus && visuallyHiddenDismiss && active ? getLabel('sheet.close') : undefined} >
{ const { onOpenChange, labelId, setHasTitle, collapsed, onCollapsedChange } = useSheetContext(); useEffect(() => { - setHasTitle(Boolean(title) || Boolean(children)); + setHasTitle(Boolean(title)); return () => setHasTitle(false); - }, [title, children, setHasTitle]); + }, [title, setHasTitle]); return (
({ + useLabels: jest.fn(() => ({ + getLabel: jest.fn((key: string) => (key === 'close' ? 'Close' : `Mocked label: ${key}`)), + })), +})); + +describe('SheetModal', () => { + it('names the dialog from the rendered title', () => { + render( + + Body + + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAccessibleName('Steps'); + const labelledBy = dialog.getAttribute('aria-labelledby'); + expect(document.getElementById(labelledBy as string)).toHaveTextContent('Steps'); + }); + + it('keeps an accessible name from aria-label when the title renders empty', () => { + render( + + Body + + ); + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Steps'); + }); +}); diff --git a/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx b/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx index 251e99f82..559d96712 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-modal/sheet-modal.tsx @@ -37,7 +37,10 @@ export type SheetModalProps = SheetModalBaseProps & */ title: ReactNode; header?: never; - /** Optional — the rendered `title` already names the dialog. */ + /** + * Optional — the rendered `title` already names the dialog. Pass it anyway when `title` may + * render empty (e.g. `title={condition && 'Steps'}`), so the dialog keeps an accessible name. + */ ariaLabel?: string; } | { @@ -78,6 +81,8 @@ export const SheetModal = ({ children, }: SheetModalProps): JSX.Element => { const titleId = useId(); + const hasRenderedTitle = !header && Boolean(title); + const labelledBy = hasRenderedTitle ? titleId : ''; return ( @@ -85,7 +90,7 @@ export const SheetModal = ({ position="bottom" fullscreen="edge" aria-label={ariaLabel} - aria-labelledby={!header && title !== undefined && title !== null ? titleId : undefined} + aria-labelledby={labelledBy} className={cn(styles['tedi-sheet'], className)} > {header ? ( @@ -93,7 +98,12 @@ export const SheetModal = ({ ) : (
- + {title} {closeButton && ( diff --git a/src/tedi/components/overlays/sheet/index.ts b/src/tedi/components/overlays/sheet/index.ts index d1b90a612..6b33edd2b 100644 --- a/src/tedi/components/overlays/sheet/index.ts +++ b/src/tedi/components/overlays/sheet/index.ts @@ -5,5 +5,4 @@ export * from './components/sheet-header/sheet-header'; export * from './components/sheet-body/sheet-body'; export * from './components/sheet-footer/sheet-footer'; export * from './components/sheet-closer/sheet-closer'; -export * from './components/sheet-modal/sheet-modal'; export * from './sheet-context'; diff --git a/src/tedi/components/overlays/sheet/sheet.module.scss b/src/tedi/components/overlays/sheet/sheet.module.scss index ae61b13e1..29e1c0511 100644 --- a/src/tedi/components/overlays/sheet/sheet.module.scss +++ b/src/tedi/components/overlays/sheet/sheet.module.scss @@ -58,7 +58,7 @@ $sheet-fade-duration: 200ms; } @media (prefers-reduced-motion: reduce) { - .tedi-sheet__overlay, + .tedi-sheet__overlay::before, .tedi-sheet__panel { transition: none; } diff --git a/src/tedi/components/overlays/sheet/sheet.spec.tsx b/src/tedi/components/overlays/sheet/sheet.spec.tsx index b31456e11..8d35585c0 100644 --- a/src/tedi/components/overlays/sheet/sheet.spec.tsx +++ b/src/tedi/components/overlays/sheet/sheet.spec.tsx @@ -1,7 +1,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import { useBreakpointProps } from '../../../helpers'; import { Sheet } from './sheet'; +import { useSheet } from './sheet-context'; jest.mock('../../../providers/label-provider', () => ({ useLabels: jest.fn(() => ({ @@ -9,7 +11,18 @@ jest.mock('../../../providers/label-provider', () => ({ })), })); +jest.mock('../../../helpers', () => ({ + ...jest.requireActual('../../../helpers'), + useBreakpointProps: jest.fn(), +})); + describe('Sheet', () => { + beforeEach(() => { + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: (props: Record) => props, + }); + }); + beforeAll(() => { class PointerEventPolyfill extends MouseEvent { pointerId: number; @@ -58,6 +71,24 @@ describe('Sheet', () => { expect(document.getElementById(labelledBy as string)).toHaveTextContent('Title text'); }); + it('keeps a Content aria-label when the header renders custom children (no built-in title)', () => { + render( + + + + Fully custom header + + Body + + + ); + + const dialog = screen.getByRole('dialog'); + + expect(dialog).toHaveAttribute('aria-label', 'Custom labelled sheet'); + expect(dialog).not.toHaveAttribute('aria-labelledby'); + }); + it('falls back to aria-label when no header title is set', () => { render( @@ -333,7 +364,7 @@ describe('Sheet', () => { expect(screen.getByRole('dialog')).toHaveStyle({ '--tedi-sheet-radius': 'var(--card-radius-rounded)' }); }); - it('accepts a per-breakpoint radius and resolves the base breakpoint', () => { + it('applies the base radius when the breakpoint resolver reports the base breakpoint', () => { render( @@ -341,7 +372,140 @@ describe('Sheet', () => { ); - // jsdom reports the base (xs) breakpoint, so the flat `radius="none"` applies. expect(screen.getByRole('dialog')).toHaveStyle({ '--tedi-sheet-radius': '0' }); }); + + it('applies the md radius when the breakpoint resolver reports md', () => { + (useBreakpointProps as jest.Mock).mockReturnValue({ + getCurrentBreakpointProps: () => ({ radius: 'card' }), + }); + + render( + + + Body + + + ); + expect(screen.getByRole('dialog')).toHaveStyle({ '--tedi-sheet-radius': 'var(--card-radius-rounded)' }); + }); + + it('throws when a subcomponent is rendered outside ', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow('Sheet subcomponents must be rendered inside .'); + spy.mockRestore(); + }); + + it('exposes the sheet state to descendants via useSheet', async () => { + const CloseFromBody = () => { + const { open, onOpenChange } = useSheet(); + return ( + + ); + }; + + render( + + + + + + + + ); + + const closeButton = screen.getByRole('button', { name: 'Close from body' }); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('merges an object ref supplied on the trigger child', () => { + const TriggerWithRef = () => { + const ref = useRef(null); + const [tag, setTag] = useState(''); + return ( + + + + + + Body + + + {tag} + + ); + }; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Probe' })); + expect(screen.getByTestId('probe')).toHaveTextContent('BUTTON'); + }); + + it('renders a split footer with right-aligned content', () => { + render( + + + Body + Confirm}> + + + + + ); + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); + expect(document.querySelector('[class*="tedi-sheet__footer--split"]')).toBeInTheDocument(); + expect(document.querySelector('[class*="tedi-sheet__footer-side--right"]')).toBeInTheDocument(); + }); + + it('snaps to the nearest snap point on drag end', () => { + const onSnapPointChange = jest.fn(); + render( + + + Body + + + ); + + const handle = document.querySelector('[data-name="sheet-handle"]') as HTMLElement; + fireEvent.pointerDown(handle, { clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 450, pointerId: 1 }); + fireEvent.pointerUp(handle, { clientY: 450, pointerId: 1 }); + + expect(onSnapPointChange).toHaveBeenCalledWith(0.4); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('dismisses a snap-point sheet when dragged below the lowest snap', async () => { + render( + + + Body + + + ); + + const handle = document.querySelector('[data-name="sheet-handle"]') as HTMLElement; + fireEvent.pointerDown(handle, { clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 700, pointerId: 1 }); + fireEvent.pointerUp(handle, { clientY: 700, pointerId: 1 }); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); }); From 4eda1ec01b37a0130680c4dbc4727ff0e8a64196 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:32:09 +0300 Subject: [PATCH 07/10] feat(sheet): design review fixes #736 --- .../components/sheet-body/sheet-body.tsx | 24 +- .../sheet-content/sheet-content.tsx | 18 +- .../components/sheet-footer/sheet-footer.tsx | 28 +- .../components/sheet-header/sheet-header.tsx | 4 +- .../overlays/sheet/sheet.module.scss | 24 +- .../components/overlays/sheet/sheet.spec.tsx | 62 ++ .../overlays/sheet/sheet.stories.tsx | 795 +++++++++++++----- 7 files changed, 755 insertions(+), 200 deletions(-) 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 index c0983ac12..1d48e2146 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-body/sheet-body.tsx @@ -4,23 +4,43 @@ 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, className }: SheetBodyProps): JSX.Element | null => { +export const SheetBody = ({ children, padding = 'default', className }: SheetBodyProps): JSX.Element | null => { const { collapsed } = useSheetContext(); if (collapsed) return null; - return
{children}
; + return ( +
+ {children} +
+ ); }; SheetBody.displayName = 'Sheet.Body'; 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 index 3a61de896..e774a0b5e 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx @@ -106,12 +106,24 @@ export interface SheetContentProps extends BreakpointSupport['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']; /** * Additional class name on the sheet panel. */ className?: string; /** - * Inline style applied to the sheet panel - handy for a custom `maxHeight` / `maxWidth`. + * Inline style applied to the sheet panel - handy for a custom `maxWidth`. */ style?: CSSProperties; } @@ -130,6 +142,8 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { keepMounted = false, visuallyHiddenDismiss = true, initialFocus, + minHeight, + maxHeight, className, style, } = props; @@ -212,6 +226,8 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { 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) { 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 index 4c4587c54..93ce02333 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-footer/sheet-footer.tsx @@ -4,12 +4,25 @@ 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. Placed on the left; when `right` - * is set they stay left and `right` is pushed to the opposite edge. + * 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. */ @@ -20,7 +33,13 @@ export interface SheetFooterProps { className?: string; } -export const SheetFooter = ({ children, right, className }: SheetFooterProps): JSX.Element | null => { +export const SheetFooter = ({ + children, + align = 'left', + fullWidth = false, + right, + className, +}: SheetFooterProps): JSX.Element | null => { const { collapsed } = useSheetContext(); if (collapsed) return null; @@ -30,7 +49,8 @@ export const SheetFooter = ({ children, right, className }: SheetFooterProps): J
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 index 031b87d25..88bd5820e 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx @@ -97,8 +97,8 @@ export const SheetHeader = (props: SheetHeaderProps): JSX.Element => { {collapsible && ( onCollapsedChange(!next)} + open={collapsed} + onOpenChange={onCollapsedChange} inverted={variant === 'brand'} aria-label={getLabel(collapsed ? 'sheet.expand' : 'sheet.collapse')} /> diff --git a/src/tedi/components/overlays/sheet/sheet.module.scss b/src/tedi/components/overlays/sheet/sheet.module.scss index 29e1c0511..2a0d79f73 100644 --- a/src/tedi/components/overlays/sheet/sheet.module.scss +++ b/src/tedi/components/overlays/sheet/sheet.module.scss @@ -149,6 +149,10 @@ $sheet-fade-duration: 200ms; background: var(--sheet-body-default); } +.tedi-sheet__body--no-padding { + padding: 0; +} + .tedi-sheet__footer { display: flex; gap: var(--layout-grid-gutters-08); @@ -162,10 +166,28 @@ $sheet-fade-duration: 200ms; justify-content: space-between; } -.tedi-sheet__footer--start { +.tedi-sheet__footer--left { justify-content: flex-start; } +.tedi-sheet__footer--center { + justify-content: center; +} + +.tedi-sheet__footer--right { + justify-content: flex-end; +} + +.tedi-sheet__footer--full-width { + .tedi-sheet__footer-side { + flex: 1 1 auto; + + > * { + flex: 1 1 0; + } + } +} + .tedi-sheet__footer-side { display: flex; gap: var(--layout-grid-gutters-08); diff --git a/src/tedi/components/overlays/sheet/sheet.spec.tsx b/src/tedi/components/overlays/sheet/sheet.spec.tsx index 8d35585c0..228f29472 100644 --- a/src/tedi/components/overlays/sheet/sheet.spec.tsx +++ b/src/tedi/components/overlays/sheet/sheet.spec.tsx @@ -473,6 +473,68 @@ describe('Sheet', () => { expect(document.querySelector('[class*="tedi-sheet__footer-side--right"]')).toBeInTheDocument(); }); + it('aligns footer actions via the align prop', () => { + const { rerender } = render( + + + + + + + + ); + expect(document.querySelector('[class*="tedi-sheet__footer--center"]')).toBeInTheDocument(); + + rerender( + + + + + + + + ); + expect(document.querySelector('[class*="tedi-sheet__footer--right"]')).toBeInTheDocument(); + }); + + it('stretches footer actions to full width', () => { + render( + + + + + + + + ); + + expect(document.querySelector('[class*="tedi-sheet__footer--full-width"]')).toBeInTheDocument(); + }); + + it('removes body padding when padding="none"', () => { + render( + + + Body + + + ); + + expect(document.querySelector('[class*="tedi-sheet__body--no-padding"]')).toBeInTheDocument(); + }); + + it('applies minHeight and maxHeight to the panel', () => { + render( + + + Body + + + ); + + expect(screen.getByRole('dialog')).toHaveStyle({ minHeight: '200px', maxHeight: '400px' }); + }); + it('snaps to the nearest snap point on drag end', () => { const onSnapPointChange = jest.fn(); render( diff --git a/src/tedi/components/overlays/sheet/sheet.stories.tsx b/src/tedi/components/overlays/sheet/sheet.stories.tsx index ce4bf5b8c..86df7bd8e 100644 --- a/src/tedi/components/overlays/sheet/sheet.stories.tsx +++ b/src/tedi/components/overlays/sheet/sheet.stories.tsx @@ -1,16 +1,20 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { Text } from '../../base/typography/text/text'; import { Button } from '../../buttons/button/button'; +import { Filter, FilterGroup } from '../../filter/filter'; import { Checkbox } from '../../form/checkbox/checkbox'; import { ChoiceGroup } from '../../form/choice-group/choice-group'; import { DateField } from '../../form/date-field/date-field'; +import { NumberField } from '../../form/number-field/number-field'; +import { Radio } from '../../form/radio/radio'; import { Search } from '../../form/search/search'; +import { ISelectOption, Select } from '../../form/select/select'; import { TextField } from '../../form/textfield/textfield'; import { TimeField } from '../../form/time-field/time-field'; -import { Toggle } from '../../form/toggle/toggle'; import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { Separator } from '../../misc/separator/separator'; import { Tabs } from '../../navigation/tabs/tabs'; import { Tag } from '../../tags/tag/tag'; import { Sheet } from './sheet'; @@ -40,17 +44,113 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const demoBody = ( - - Hinnatsoon - EUREF-EST97 - UTM 34N võrk - +const measurementTypes = [ + { id: 'sheet-measure-type-length', label: 'Mõõda joone pikkust', value: 'length' }, + { id: 'sheet-measure-type-area', label: 'Mõõda pindala', value: 'area' }, + { id: 'sheet-measure-type-radius', label: 'Mõõda ringina', value: 'radius' }, +]; + +const MeasurementSheetContent = (): JSX.Element => ( + <> + + + + + + + + + + + + + + + +
+ + Joone kogupikkus + + 345,234 km +
+
+ + Viimane jooksev lõik + + 34,23 km +
+
+ + +
+ + +
+
+
+ + +
+
+
+ + Rohkem valikuid + + } + > + + + + + + ); export const Default: Story = { render: (args) => ( + + + + + + + + + ), +}; + +export const HeaderNotCollapsible: Story = { + name: 'Header is not collapsible', + render: () => ( + @@ -59,39 +159,49 @@ export const Default: Story = {
- Saatja + Saatja
2 -
+ +
- }> + + Rohkem valikuid + + } + > @@ -103,6 +213,7 @@ export const Default: Story = { }; export const Collapsible: Story = { + name: 'Header is collapsible', render: () => ( @@ -112,12 +223,18 @@ export const Collapsible: Story = { - - - + + + - }> + + Rohkem valikuid + + } + > @@ -129,6 +246,7 @@ export const Collapsible: Story = { }; export const WithoutActionButtons: Story = { + name: 'Without actions buttons', render: () => ( @@ -153,64 +271,428 @@ export const WithoutActionButtons: Story = { ), }; -const headerExample = (trigger: string, header: JSX.Element) => ( +const headerExample = (trigger: string, content: JSX.Element) => ( - - {header} - {demoBody} - + {content} ); +const headerBody = Kaardikihtide ja mõõtühikute seaded.; + export const Header: Story = { render: () => ( Variant
- {headerExample('Primary', )} - {headerExample('Brand', )} + {headerExample( + 'Primary', + + + {headerBody} + + )} + {headerExample( + 'Brand', + + + {headerBody} + + )}
Type
- {headerExample('Default', )} - {headerExample('Collapsible', )} - {headerExample('Title center', )} + {headerExample( + 'Default', + + + {headerBody} + + )} + {headerExample( + 'Collapsible', + + + {headerBody} + + )} + {headerExample( + 'Title center', + + + {headerBody} + + )} {headerExample( 'Without closing button', - + + + {headerBody} + )} {headerExample( 'Different closing button', - - + + } + /> + {headerBody} + + )} + {headerExample( + 'Extra action', + + + Täisekraan + + } + /> + {headerBody} + )} {headerExample( 'With slot', - 2} /> + + 2} /> + {headerBody} + + )} + {headerExample( + 'With handle', + + + {headerBody} + + )} + {headerExample( + 'Without handle', + + + {headerBody} + )} - {headerExample('With handle', )}
), }; +export const FooterActions: Story = { + render: () => { + const alignments = ['left', 'center', 'right'] as const; + return ( +
+ {alignments.map((align) => ( + + + + + + + {headerBody} + + + + + + + + + ))} + + + + + + + {headerBody} + + + + + + + + + + + + + + + {headerBody} + + Rohkem valikuid + + } + > + + + + + + + +
+ ); + }, +}; + +export const Radius: Story = { + render: () => ( +
+ {(['default', 'card', 'none'] as const).map((radius) => ( + + + + + + + {headerBody} + + + ))} + + + + + + + {headerBody} + + +
+ ), +}; + +export const EdgeToEdge: Story = { + name: 'Edge-to-edge body', + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Maastik + Muu + + +
+ + + + + +
+
+ +
+ + + + +
+
+
+
+ + + + + + +
+
+ + +
+ ), +}; + +interface LayerOption { + id: string; + label: string; + description?: string; +} + +const NATURE_LAYERS: LayerOption[] = [ + { + id: 'maainfo', + label: 'Maainfo', + description: + 'Maainfo kaardirakenduse temaatiline andmekihtide kogum, mis pakub teavet maaomandi, maakasutuse ja maaparanduse kohta.', + }, + { id: 'geo-400k', label: '1:400 000 geoloogiline kaart' }, + { id: 'geo-50k', label: '1:50 000 geoloogiline kaart' }, + { id: 'karuputk', label: 'Karuputk' }, + { + id: 'kaitsealad', + label: 'Kaitsealad', + description: 'Kaitsealade piirid ja vööndid koos kaitsekorra kirjeldusega.', + }, + { id: 'jahikaart', label: 'Jahikaart' }, + { id: 'looduskaitse', label: 'Looduskaitse' }, +]; + +/** + * A searchable, single-select layer list: edge-to-edge rows with a radio + label, an expandable + * **Loe lisaks** description per row and the selected row highlighted. Used in `Edge-to-edge body`. + */ +function LayerListSheet(): JSX.Element { + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState('maainfo'); + const [expanded, setExpanded] = useState('kaitsealad'); + + const visible = NATURE_LAYERS.filter((layer) => layer.label.toLowerCase().includes(query.trim().toLowerCase())); + + return ( + + + + + + + +
+ +
+
+ + LOODUS JA LOOMAD + +
+ {visible.map((layer, index) => { + const isExpanded = expanded === layer.id; + return ( + + {index > 0 && } +
+ setSelected(String(value))} + /> + {layer.description && ( + + )} +
+ {isExpanded && layer.description && ( +
+ + {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() { @@ -252,8 +734,8 @@ export const SnapPoints: Story = { - {Array.from({ length: 20 }, (_, i) => ( - Kaardikiht {i + 1} + {MAP_LAYERS.concat(MAP_LAYERS).map((layer, i) => ( + ))} @@ -282,39 +764,6 @@ export const KeepMounted: Story = { ), }; -/** - * **`radius`.** Override the sheet's top-corner radius - `default`, `card` (matches a `Card`), or - * `none`. It drives both the panel and header corners, and is breakpoint-aware, e.g. - * ``. For any other value, set the - * `--tedi-sheet-radius` custom property via `style`. - */ -export const Radius: Story = { - render: () => ( -
- {(['default', 'card', 'none'] as const).map((radius) => ( - - - - - - - {demoBody} - - - ))} - - - - - - - {demoBody} - - -
- ), -}; - /** * **Non-modal peek.** `showOverlay`, `trapFocus` and `lockScroll` off (plus * `closeOnBackdropClick={false}`) keep a `collapsible` sheet docked as a header peek while the page @@ -344,121 +793,87 @@ export const NonModalPeek: Story = { }, }; -const MAP_LAYERS = ['Hübriidkaart', 'Reljeef', 'Katastriüksused', 'Kitsendused', 'Aadressandmed']; - -const SearchableLayersSheet = () => { - const [query, setQuery] = useState(''); - const [checked, setChecked] = useState(['Hübriidkaart']); - - const visibleLayers = MAP_LAYERS.filter((layer) => layer.toLowerCase().includes(query.trim().toLowerCase())); - - const toggleLayer = (layer: string, isChecked: boolean) => - setChecked((prev) => (isChecked ? [...prev, layer] : prev.filter((item) => item !== layer))); - - return ( - - - - - - 0 ? {checked.length} : undefined} - /> - - - - {visibleLayers.length > 0 ? ( - - {visibleLayers.map((layer) => { - const index = MAP_LAYERS.indexOf(layer); - return ( - toggleLayer(layer, isChecked)} - /> - ); - })} - - ) : ( - Otsingule vastavaid kihte ei leitud. - )} - - - - - ); -}; - -export const Examples: Story = { - render: () => ( -
- - - - - - - - - - Maastik - Muu näitab - - - - - - - - - - - - - - - - - - - - - - - - +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. The header carries an extra + * fullscreen action alongside collapse / close, and the footer splits an **Lisa rida** link on the + * start edge from the grouped **Tühista** / **Salvesta** actions on the end. + */ +export const DesktopTable: Story = { + name: 'Desktop table input', + render: function DesktopTable() { + const [rows, setRows] = useState([0, 1, 2]); + const columnStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, minmax(6rem, 1fr))', gap: '0.5rem' }; - + return ( + - + - - + + + Täisekraan + + } + /> - - - - + + {rows.map((row) => ( +
+ + + + + + +
+ ))}
+ + + + + + + } + > + +
-
- ), + ); + }, }; From e58caed5623d2e866fb27474810644a8736b1630 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:01:32 +0300 Subject: [PATCH 08/10] fix(sheet): cr fixes #736 --- .../components/sheet-header/sheet-header.tsx | 4 ++-- .../components/overlays/sheet/sheet.spec.tsx | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) 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 index 88bd5820e..83b651226 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx @@ -72,9 +72,9 @@ export const SheetHeader = (props: SheetHeaderProps): JSX.Element => { const { onOpenChange, labelId, setHasTitle, collapsed, onCollapsedChange } = useSheetContext(); useEffect(() => { - setHasTitle(Boolean(title)); + setHasTitle(Boolean(title) && (children === undefined || children === null)); return () => setHasTitle(false); - }, [title, setHasTitle]); + }, [title, children, setHasTitle]); return (
{ expect(dialog).not.toHaveAttribute('aria-labelledby'); }); + it('keeps the Content aria-label when the header has both a title and custom children', () => { + render( + + + + Fully custom header + + Body + + + ); + + const dialog = screen.getByRole('dialog'); + + expect(dialog).toHaveAttribute('aria-label', 'Custom labelled sheet'); + expect(dialog).not.toHaveAttribute('aria-labelledby'); + }); + it('falls back to aria-label when no header title is set', () => { render( From 76a0f992aa28f36100f7485dc804ffed0d3b8ca1 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:26:59 +0300 Subject: [PATCH 09/10] feat(sheet): design review fixes #736 --- .../sheet-content/sheet-content.tsx | 17 +- .../components/sheet-header/sheet-header.tsx | 4 +- .../overlays/sheet/sheet.module.scss | 11 + .../components/overlays/sheet/sheet.spec.tsx | 20 + .../overlays/sheet/sheet.stories.tsx | 727 +++++++++++++----- src/tedi/components/overlays/sheet/sheet.tsx | 4 +- 6 files changed, 575 insertions(+), 208 deletions(-) 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 index e774a0b5e..1627c0ea8 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-content/sheet-content.tsx @@ -118,6 +118,16 @@ export interface SheetContentProps extends BreakpointSupport { initialFocus, minHeight, maxHeight, + padding = 'default', className, style, } = props; @@ -216,7 +227,11 @@ export const SheetContent = (props: SheetContentProps): JSX.Element | null => { if (!isMounted && !keepMounted) return null; - const panelClassName = cn(styles['tedi-sheet__panel'], className); + const panelClassName = cn( + styles['tedi-sheet__panel'], + { [styles['tedi-sheet__panel--padding-small']]: padding === 'small' }, + className + ); const active = isMounted; 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 index 83b651226..c01514fd5 100644 --- a/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx +++ b/src/tedi/components/overlays/sheet/components/sheet-header/sheet-header.tsx @@ -97,8 +97,8 @@ export const SheetHeader = (props: SheetHeaderProps): JSX.Element => { {collapsible && ( onCollapsedChange(!next)} inverted={variant === 'brand'} aria-label={getLabel(collapsed ? 'sheet.expand' : 'sheet.collapse')} /> diff --git a/src/tedi/components/overlays/sheet/sheet.module.scss b/src/tedi/components/overlays/sheet/sheet.module.scss index 2a0d79f73..3b0040fd7 100644 --- a/src/tedi/components/overlays/sheet/sheet.module.scss +++ b/src/tedi/components/overlays/sheet/sheet.module.scss @@ -35,6 +35,9 @@ $sheet-fade-duration: 200ms; } .tedi-sheet__panel { + // Padding scale (default). Core 6.9.0 doesn't publish the semantic `--sheet-*-padding-md/-sm` + // tokens yet - they resolve to empty, which collapses the padding - so these use the primitive + // dimension tokens (1rem / 0.5rem) for now. Swap to the semantic sheet tokens once core is updated. --tedi-sheet-radius: var(--tedi-dimensions-13); --tedi-sheet-padding-x: var(--tedi-dimensions-10); --tedi-sheet-padding-y: var(--tedi-dimensions-05); @@ -57,6 +60,14 @@ $sheet-fade-duration: 200ms; } } +.tedi-sheet__panel--padding-small { + // Tighter padding scale (small) - header, body and footer all read these panel-level custom + // properties, so the whole sheet shrinks together. + // TODO: uses the primitive dimension token (0.5rem) because core 6.9.0 has no `--sheet-*-padding-sm` + // token yet - replace with `var(--sheet-footer-padding-sm)` (and header/body) once core is updated. + --tedi-sheet-padding-x: var(--tedi-dimensions-05); +} + @media (prefers-reduced-motion: reduce) { .tedi-sheet__overlay::before, .tedi-sheet__panel { diff --git a/src/tedi/components/overlays/sheet/sheet.spec.tsx b/src/tedi/components/overlays/sheet/sheet.spec.tsx index e676483df..1fd9ecf99 100644 --- a/src/tedi/components/overlays/sheet/sheet.spec.tsx +++ b/src/tedi/components/overlays/sheet/sheet.spec.tsx @@ -541,6 +541,26 @@ describe('Sheet', () => { expect(document.querySelector('[class*="tedi-sheet__body--no-padding"]')).toBeInTheDocument(); }); + it('tightens the sheet padding when padding="small"', () => { + const { rerender } = render( + + + Body + + + ); + expect(document.querySelector('[class*="tedi-sheet__panel--padding-small"]')).not.toBeInTheDocument(); + + rerender( + + + Body + + + ); + expect(document.querySelector('[class*="tedi-sheet__panel--padding-small"]')).toBeInTheDocument(); + }); + it('applies minHeight and maxHeight to the panel', () => { render( diff --git a/src/tedi/components/overlays/sheet/sheet.stories.tsx b/src/tedi/components/overlays/sheet/sheet.stories.tsx index 86df7bd8e..6fbb449dd 100644 --- a/src/tedi/components/overlays/sheet/sheet.stories.tsx +++ b/src/tedi/components/overlays/sheet/sheet.stories.tsx @@ -1,8 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Fragment, useState } from 'react'; +import { Icon } from '../../base/icon/icon'; +import { Heading } from '../../base/typography/heading/heading'; import { Text } from '../../base/typography/text/text'; import { Button } from '../../buttons/button/button'; +import ClosingButton from '../../buttons/closing-button/closing-button'; import { Filter, FilterGroup } from '../../filter/filter'; import { Checkbox } from '../../form/checkbox/checkbox'; import { ChoiceGroup } from '../../form/choice-group/choice-group'; @@ -97,25 +100,6 @@ const MeasurementSheetContent = (): JSX.Element => ( 34,23 km
- - -
- - -
-
-
- - -
( ); +const profileDetails = [ + { label: 'Eesnimi', value: 'Tiina' }, + { label: 'Sünniaeg', value: '15.08.1987' }, + { label: 'Perekonnanimi', value: 'Tamm' }, + { label: 'Isikukood', value: '41234567891' }, + { label: 'Dokumendi number', value: 'AS0000226' }, + { label: 'Sugu', value: 'Naine' }, +]; + +const ProfileSheetContent = (): JSX.Element => ( + + +
+ Tiina Tamm + +
+
+ {profileDetails.map((detail) => ( +
+ + {detail.label} + + {detail.value} +
+ ))} +
+
+
+); + export const Default: Story = { render: (args) => ( - + - - + + ), }; -export const HeaderNotCollapsible: Story = { - name: 'Header is not collapsible', +export const Measurement: Story = { render: () => ( - + - - - -
- Saatja -
- 2 - -
-
- - - - -
-
- - Rohkem valikuid - - } - > - - - - - + +
), }; +const FILTER_SENDERS = [ + { id: 'mari-maasikas', label: 'Mari Maasikas' }, + { id: 'mait-muru', label: 'Mait Muru' }, + { id: 'kadri-kaasik', label: 'Kadri Kaasik' }, + { id: 'lenna-laasik', label: 'Lenna Laasik' }, + { id: 'jaan-jogi', label: 'Jaan Jõgi' }, + { id: 'kati-kask', label: 'Kati Kask' }, + { id: 'arvo-aru', label: 'Arvo Aru' }, + { id: 'tiina-tamm', label: 'Tiina Tamm' }, + { id: 'kristjan-koppel', label: 'Kristjan Koppel' }, +]; + +export const HeaderNotCollapsible: Story = { + name: 'Header is not collapsible', + render: function HeaderNotCollapsible() { + const [step, setStep] = useState<'filters' | 'senders'>('filters'); + const [senders, setSenders] = useState(['mari-maasikas', 'tiina-tamm']); + const [query, setQuery] = useState(''); + + const visibleSenders = FILTER_SENDERS.filter((sender) => + sender.label.toLowerCase().includes(query.trim().toLowerCase()) + ); + + const toggleSender = (id: string) => + setSenders((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + + return ( + // Reopen always starts on the filter step; the current step is kept during the close animation. + { + if (open) setStep('filters'); + }} + > + + + + + {/* The header stays "Filtreeri" across both steps; back navigation lives in the footer. */} + + {step === 'filters' ? ( + <> + + + {/* The whole "Saatja" row is the clickable target (per Figma); it drills into the + sender-selection step. */} + + + + + + + + + Rohkem valikuid + + } + > + + + + + + + ) : ( + <> + +
+ +
+ {visibleSenders.map((sender, index) => { + const isSelected = senders.includes(sender.id); + return ( + + {index > 0 && } + {/* Plain selectable name row - a check marks the chosen senders. */} + + + ); + })} +
+ + + + + )} +
+
+ ); + }, +}; + export const Collapsible: Story = { name: 'Header is collapsible', render: () => ( - + @@ -263,6 +396,7 @@ export const WithoutActionButtons: Story = { label="Näita pikkusi" defaultChecked /> + @@ -271,73 +405,211 @@ export const WithoutActionButtons: Story = { ), }; -const headerExample = (trigger: string, content: JSX.Element) => ( - - - - - {content} - +type HeaderTriggerVariant = 'primary' | 'secondary' | 'neutral'; + +interface TriggerSpec { + /** Button text - hints at what the sheet opens (the buttons are absent from Figma, so invented). */ + label?: string; + /** Leading Material icon on the trigger button. */ + icon?: string; + visualType?: HeaderTriggerVariant; +} + +/** + * A sheet trigger with the demoed example / functionality named in a caption above the button. The + * trigger buttons are absent from Figma, so they carry an invented, content-hinting Estonian label + + * icon while the caption documents the demo - the same label-above pattern is reused across the + * gallery stories below. + */ +const labeledTrigger = (caption: string, content: JSX.Element, trigger: TriggerSpec = {}) => { + const { label = 'Ava seaded', icon = 'tune', visualType = 'secondary' } = trigger; + return ( + + + {caption} + + + + + + {content} + + + ); +}; + +interface HeaderExample extends TriggerSpec { + caption: string; + content: JSX.Element; +} + +/** + * One labelled "example list" panel: a bordered card with a shared trigger-button style, so each + * subgroup reads as a distinct segment rather than one flat wall of identical buttons. Pass a + * per-example `visualType` to override the group default. + */ +const exampleGroup = (label: string, visualType: HeaderTriggerVariant, examples: HeaderExample[]) => ( + + {label} +
+ {examples.map((ex) => + labeledTrigger(ex.caption, ex.content, { + label: ex.label, + icon: ex.icon, + visualType: ex.visualType ?? visualType, + }) + )} +
+
); -const headerBody = Kaardikihtide ja mõõtühikute seaded.; +// 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 = ( + + + -
+
+ {alignments.map((align) => + labeledTrigger( + `align="${align}"`, {headerBody} @@ -417,13 +709,12 @@ export const FooterActions: Story = { - - - ))} - - - - + , + { label: 'Vaate seaded', icon: 'tune' } + ) + )} + {labeledTrigger( + 'fullWidth', {headerBody} @@ -433,12 +724,11 @@ export const FooterActions: Story = { - - - - - - + , + { label: 'Vaate seaded', icon: 'tune' } + )} + {labeledTrigger( + 'split (right)', {headerBody} @@ -454,8 +744,9 @@ export const FooterActions: Story = { - - + , + { label: 'Vaate seaded', icon: 'tune' } + )}
); }, @@ -463,27 +754,53 @@ export const FooterActions: Story = { export const Radius: Story = { render: () => ( -
- {(['default', 'card', 'none'] as const).map((radius) => ( - - - - +
+ {(['default', 'card', 'none'] as const).map((radius) => + labeledTrigger( + `radius="${radius}"`, {headerBody} - - - ))} - - - - + , + { label: 'Kaardi stiil', icon: 'map' } + ) + )} + {labeledTrigger( + 'none → card @ md', {headerBody} - - + , + { label: 'Kaardi stiil', icon: 'map' } + )} +
+ ), +}; + +/** + * **Padding.** `Sheet.Content`'s `padding` sets the sheet-wide padding scale - header, body and + * footer tighten together: `default` (1rem) or `small` (0.5rem). For edge-to-edge body content, + * reach for `Sheet.Body padding="none"` instead. + */ +export const Padding: Story = { + render: () => ( +
+ {(['default', 'small'] as const).map((padding) => + labeledTrigger( + `padding="${padding}"`, + + 2} /> + {headerBody} + + + + + + + , + { label: 'Vaate seaded', icon: 'tune' } + ) + )}
), }; @@ -499,23 +816,28 @@ export const EdgeToEdge: Story = { - - - - - - - - - - - + {/* Edge-to-edge action list: full-width neutral buttons, left-aligned (icon + label) so + the rows read like menu items. Neutral buttons have no horizontal padding, so add the + sheet's standard 1rem inset to keep the content off the edge. */} + {[ + { icon: 'download', label: 'Laadi alla' }, + { icon: 'share', label: 'Jaga' }, + { icon: 'delete', label: 'Kustuta' }, + ].map((action, index) => ( + + {index > 0 && } + + + + + ))}
@@ -612,7 +934,9 @@ function LayerListSheet(): JSX.Element { - + {/* minHeight keeps the sheet from collapsing as the search narrows the result list - the dev + opts into a stable height instead of the sheet shrinking to its content. */} +
@@ -664,7 +988,8 @@ function LayerListSheet(): JSX.Element { )}
{isExpanded && layer.description && ( -
+ // Top padding so the expanded detail isn't cramped against the row that toggles it. +
{layer.description} @@ -765,12 +1090,13 @@ export const KeepMounted: Story = { }; /** - * **Non-modal peek.** `showOverlay`, `trapFocus` and `lockScroll` off (plus - * `closeOnBackdropClick={false}`) keep a `collapsible` sheet docked as a header peek while the page - * behind stays usable and clicks don't dismiss it. + * **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 NonModalPeek: Story = { - render: function NonModalPeek() { +export const DockedPeek: Story = { + name: 'Docked peek', + render: function DockedPeek() { return ( @@ -813,8 +1139,8 @@ const PLACES: ISelectOption[] = [ /** * A larger desktop layout: a table-style input view inside the sheet. The header carries an extra - * fullscreen action alongside collapse / close, and the footer splits an **Lisa rida** link on the - * start edge from the grouped **Tühista** / **Salvesta** actions on the end. + * fullscreen action alongside collapse / close, and the footer groups the **Lisa rida** link + * together with the **Tühista** / **Salvesta** actions on the end edge. */ export const DesktopTable: Story = { name: 'Desktop table input', @@ -854,16 +1180,7 @@ export const DesktopTable: Story = { ))} - - - - - - - } - > + + + + + diff --git a/src/tedi/components/overlays/sheet/sheet.tsx b/src/tedi/components/overlays/sheet/sheet.tsx index bcec6b968..ae1932dec 100644 --- a/src/tedi/components/overlays/sheet/sheet.tsx +++ b/src/tedi/components/overlays/sheet/sheet.tsx @@ -73,8 +73,8 @@ export const Sheet = (props: SheetProps): JSX.Element => { ); useEffect(() => { - if (!isOpen && collapsed) setCollapsed(false); - }, [isOpen, collapsed]); + if (isOpen) setCollapsed(false); + }, [isOpen]); const { refs, context } = useFloating({ open: isOpen, From 9ff92b770115980989a1ddc3ac6c09810b214753 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:08:16 +0300 Subject: [PATCH 10/10] fix(sheet): design review fix #736 --- .../overlays/sheet/sheet.stories.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/tedi/components/overlays/sheet/sheet.stories.tsx b/src/tedi/components/overlays/sheet/sheet.stories.tsx index 6fbb449dd..e31bc83a6 100644 --- a/src/tedi/components/overlays/sheet/sheet.stories.tsx +++ b/src/tedi/components/overlays/sheet/sheet.stories.tsx @@ -1138,14 +1138,15 @@ const PLACES: ISelectOption[] = [ ]; /** - * A larger desktop layout: a table-style input view inside the sheet. The header carries an extra - * fullscreen action alongside collapse / close, and the footer groups the **Lisa rida** link - * together with the **Tühista** / **Salvesta** actions on the end edge. + * 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]); + const [rows, setRows] = useState([0, 1, 2, 3]); const columnStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, minmax(6rem, 1fr))', gap: '0.5rem' }; return ( @@ -1153,16 +1154,13 @@ export const DesktopTable: Story = { - + {/* maxHeight caps the panel so the table body scrolls once more rows are added. */} + - Täisekraan - - } + slot={