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