@instruments/materia-ui 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/agent-docs.md +4 -2
  2. package/dist/components/code/code-root.d.ts +1 -1
  3. package/dist/components/code/code-root.js +4 -1
  4. package/dist/components/combobox/combobox-root.js +3 -3
  5. package/dist/components/command/command-list.d.ts +1 -1
  6. package/dist/components/command/command-list.js +3 -2
  7. package/dist/components/country-combobox/country-combobox-root.d.ts +1 -1
  8. package/dist/components/input-group/input-group-button.d.ts +2 -2
  9. package/dist/components/location-input/location-input-root.js +9 -4
  10. package/dist/components/scroll-frame/scroll-frame-root.d.ts +18 -5
  11. package/dist/components/scroll-frame/scroll-frame-root.js +49 -17
  12. package/dist/components/scroll-frame.d.ts +1 -1
  13. package/dist/components/select/select-trigger.d.ts +1 -1
  14. package/dist/components/sidepanel/sidepanel-body.d.ts +4 -3
  15. package/dist/components/sidepanel/sidepanel-body.js +6 -4
  16. package/dist/components/swatch-group/swatch-group-item.d.ts +1 -1
  17. package/dist/components/swatch-selector/swatch-selector-item.d.ts +1 -1
  18. package/dist/components/table/table-root.d.ts +1 -1
  19. package/dist/components/table/table-root.js +3 -2
  20. package/dist/compositions/data-grid-column-filter/data-grid-column-filter-root.js +2 -1
  21. package/dist/compositions/data-grid-table/data-grid-table-root.js +2 -1
  22. package/dist/compositions/mega-menu/mega-menu-content.js +3 -2
  23. package/dist/compositions/mobile-navigation/mobile-navigation-header.d.ts +1 -1
  24. package/dist/compositions/quick-view/quick-view-body.d.ts +1 -1
  25. package/dist/compositions/sidebar/sidebar-content.d.ts +1 -1
  26. package/dist/compositions/sidebar/sidebar-content.js +3 -2
  27. package/dist/compositions/sidebar/sidebar-group.d.ts +1 -1
  28. package/dist/compositions/sidebar/sidebar-menu-sub.d.ts +2 -2
  29. package/dist/compositions/sidebar/sidebar-menu-sub.js +2 -1
  30. package/dist/compositions/sidebar/sidebar-trigger.d.ts +1 -1
  31. package/dist/compositions/tag-selector/tag-selector-list.js +4 -3
  32. package/dist/data/docs-manifest.js +270 -221
  33. package/package.json +1 -1
package/agent-docs.md CHANGED
@@ -1478,6 +1478,8 @@ A scrolling region that shows where there is more: each edge fades only while co
1478
1478
  - Give a vertical frame a bounded height (`className="h-80"`); a horizontal one needs only a bounded width, which a flex child with `min-w-0` already has.
1479
1479
  - Match `fadeTheme` to the surface behind the frame (`card` inside a Card). Over an image or a blur, use `fadeMode="mask"` instead.
1480
1480
  - For a horizontal strip used with a mouse, add `dragScroll` and `controls`; both stand aside on touch screens, and each control hides at the end it points to.
1481
+ - Every scrolling region in an app is a `ScrollFrame`, not a hand-rolled `overflow-y-auto` div: a pane, a list in a popover, a wide table (`orientation="both"`). A frame capped by `max-h-*` alone scrolls once its content passes the cap.
1482
+ - Hand a virtualiser the scrolling element through `viewportRef`.
1481
1483
 
1482
1484
  ## SearchInput
1483
1485
 
@@ -1688,7 +1690,7 @@ Root component for a sidepanel. Wraps Base UI Dialog.Root.
1688
1690
 
1689
1691
  `@instruments/materia-ui/components/sidepanel`
1690
1692
 
1691
- The scrolling region between a `SidepanelHeader` and `SidepanelFooter`: takes the remaining height of `SidepanelBody`, scrolls on its own and carries the panel's standard inset, so header and footer stay put while it moves.
1693
+ The scrolling region between a `SidepanelHeader` and `SidepanelFooter`, a `ScrollFrame`: takes the remaining height of `SidepanelBody`, scrolls on its own and carries the panel's standard inset, so header and footer stay put while it moves.
1692
1694
 
1693
1695
  - Put exactly one `SidepanelScroll` inside `SidepanelBody`, between the header and footer, rather than a hand-rolled `flex-1 overflow-y-auto` div.
1694
1696
 
@@ -1887,7 +1889,7 @@ Renders a toggle switch for boolean on/off states.
1887
1889
 
1888
1890
  `@instruments/materia-ui/components/table`
1889
1891
 
1890
- Semantic `<table>` primitive, wrapped in a horizontally scrollable container so wide tables overflow rather than break layout. Compose with the exported `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableFooter`, and `TableCaption` parts.
1892
+ Semantic `<table>` primitive, wrapped in a horizontal `ScrollFrame` so wide tables overflow rather than break layout. Compose with the exported `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableFooter`, and `TableCaption` parts.
1891
1893
 
1892
1894
  - Use `Table` for a small, static, hand-authored table; reach for `DataGrid` once you need sorting, filtering, pagination, column pinning/visibility, or row virtualisation - `Table` has no data model.
1893
1895
  - Include a `TableCaption` describing the table's contents - it is the accessible name screen readers announce for the `<table>`.
@@ -2,7 +2,7 @@ import * as React from "react";
2
2
  export interface CodeProps extends React.ComponentProps<"code"> {
3
3
  /**
4
4
  * Render a multi-line block (`<pre><code>`) instead of inline `<code>`. The
5
- * block scrolls horizontally rather than wrapping. @default false
5
+ * block scrolls horizontally, in a `ScrollFrame`, rather than wrapping. @default false
6
6
  */
7
7
  block?: boolean;
8
8
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cn } from "@instruments/materia-ui/utils/cn";
3
3
  import * as React from "react";
4
+ import { ScrollFrame } from "../scroll-frame.js";
4
5
  /**
5
6
  * Monospace code - inline `<code>` by default (a muted, rounded chip sized to
6
7
  * surrounding text), or a scrollable `<pre><code>` block when `block` is set.
@@ -11,7 +12,9 @@ import * as React from "react";
11
12
  */
12
13
  export const Code = ({ block = false, className, children, ...props }) => {
13
14
  if (block) {
14
- return (_jsx("pre", { className: cn("border-border bg-muted text-paragraph-sm overflow-x-auto rounded-lg border p-4 font-mono", className), "data-block": "true", "data-component": "code", children: _jsx("code", { ...props, children: children }) }));
15
+ // The block is as wide as its longest line, so a long line scrolls the frame and the padding travels with it;
16
+ // with `whitespace-pre-wrap` it is the frame's width and wraps.
17
+ return (_jsx(ScrollFrame, { className: "border-border bg-muted rounded-lg border", contentClassName: "min-w-0!", fadeTheme: "muted", orientation: "horizontal", children: _jsx("pre", { className: cn("text-paragraph-sm w-fit min-w-full p-4 font-mono", className), "data-block": "true", "data-component": "code", children: _jsx("code", { ...props, children: children }) }) }));
15
18
  }
16
19
  return (_jsx("code", { className: cn("bg-muted text-paragraph-sm rounded-sm px-1 py-0.5 font-mono", className), "data-component": "code", ...props, children: children }));
17
20
  };
@@ -10,6 +10,7 @@ import { CheckIcon, ChevronsUpDownIcon, SearchIcon } from "lucide-react";
10
10
  import { useImperativeHandle, useRef, useState } from "react";
11
11
  import { useControllableState } from "../../hooks/use-controllable-state.js";
12
12
  import { POPOVER_CONTENT_ANIMATION, POPOVER_SURFACE, } from "../../tokens/overlay-styles.js";
13
+ import { ScrollFrame } from "../scroll-frame.js";
13
14
  const OPTION_CLASSES = "data-highlighted:bg-muted data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center rounded-full py-2.5 pr-8 pl-4 text-paragraph-sm outline-hidden select-none";
14
15
  /** A single selectable option row, shared by virtualised and plain lists. */
15
16
  const ComboboxOption = ({ item, className, ...props }) => (_jsxs(ComboboxPrimitive.Item, { className: cn(OPTION_CLASSES, className), "data-slot": "combobox-item", value: item, ...props, children: [item.label, _jsx(ComboboxPrimitive.ItemIndicator, { className: "absolute right-2 flex size-4 items-center justify-center", children: _jsx(Icon, { icon: CheckIcon, size: "xs" }) })] }));
@@ -33,7 +34,7 @@ const VirtualizedOptions = ({ virtualization, virtualizerRef, }) => {
33
34
  overscan: virtualization.overscan ?? 8,
34
35
  });
35
36
  useImperativeHandle(virtualizerRef, () => rowVirtualizer);
36
- return (_jsx("div", { className: "overflow-y-auto overscroll-contain", ref: setScrollEl, role: "presentation", style: { maxHeight: virtualization.maxHeightPx ?? 300 }, children: _jsx("div", { className: "relative w-full", role: "presentation", style: { height: rowVirtualizer.getTotalSize() }, children: rowVirtualizer.getVirtualItems().map((virtualRow) => {
37
+ return (_jsx(ScrollFrame, { fadeTheme: "popover", fades: "end", role: "presentation", style: { maxHeight: virtualization.maxHeightPx ?? 300 }, viewportRef: setScrollEl, children: _jsx("div", { className: "relative w-full", role: "presentation", style: { height: rowVirtualizer.getTotalSize() }, children: rowVirtualizer.getVirtualItems().map((virtualRow) => {
37
38
  const item = filteredItems[virtualRow.index];
38
39
  if (!item) {
39
40
  return null;
@@ -105,6 +106,5 @@ export const Combobox = ({ items, value, onValueChange, placeholder = "Select…
105
106
  // It renders the kit's Button, a native <button>.
106
107
  , {
107
108
  // It renders the kit's Button, a native <button>.
108
- nativeButton: true, render: _jsx(ComboboxTriggerButton, { className: buttonClassName, "data-testid": dataTestId, size: size }), children: selected?.label ?? placeholder }), _jsx(ComboboxPrimitive.Portal, { children: _jsx(ComboboxPrimitive.Positioner, { align: "start", className: "z-50", sideOffset: 4, children: _jsxs(ComboboxPrimitive.Popup, { "aria-label": placeholder, className: cn(`${POPOVER_CONTENT_ANIMATION} ${POPOVER_SURFACE} w-(--anchor-width) min-w-32 origin-(--transform-origin) rounded-3xl`, contentClassName), "data-component": "combobox-content", "data-slot": "combobox-content", children: [_jsxs(Flex, { align: "center", className: "h-9 border-b px-3", "data-slot": "combobox-input-wrapper", gap: "xs", children: [_jsx(Icon, { className: "shrink-0 opacity-50", icon: SearchIcon, size: "xs" }), _jsx(ComboboxPrimitive.Input, { className: "placeholder:text-muted-foreground text-paragraph-sm flex h-10 w-full bg-transparent py-3 outline-hidden disabled:cursor-not-allowed disabled:opacity-50", "data-slot": "combobox-input", placeholder: "Search\u2026" })] }), _jsx(ComboboxPrimitive.Empty, { className: "text-paragraph-sm py-6 text-center empty:p-0", "data-slot": "combobox-empty", children: "No results found." }), _jsx(ComboboxPrimitive.List, { className: cn("p-1 empty:p-0", !shouldVirtualize &&
109
- "max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto"), children: shouldVirtualize && virtualization ? (_jsx(VirtualizedOptions, { virtualization: virtualization, virtualizerRef: virtualizerRef })) : ((item) => (_jsx(ComboboxOption, { item: item }, item.value))) })] }) }) })] }));
109
+ nativeButton: true, render: _jsx(ComboboxTriggerButton, { className: buttonClassName, "data-testid": dataTestId, size: size }), children: selected?.label ?? placeholder }), _jsx(ComboboxPrimitive.Portal, { children: _jsx(ComboboxPrimitive.Positioner, { align: "start", className: "z-50", sideOffset: 4, children: _jsxs(ComboboxPrimitive.Popup, { "aria-label": placeholder, className: cn(`${POPOVER_CONTENT_ANIMATION} ${POPOVER_SURFACE} w-(--anchor-width) min-w-32 origin-(--transform-origin) rounded-3xl`, contentClassName), "data-component": "combobox-content", "data-slot": "combobox-content", children: [_jsxs(Flex, { align: "center", className: "h-9 border-b px-3", "data-slot": "combobox-input-wrapper", gap: "xs", children: [_jsx(Icon, { className: "shrink-0 opacity-50", icon: SearchIcon, size: "xs" }), _jsx(ComboboxPrimitive.Input, { className: "placeholder:text-muted-foreground text-paragraph-sm flex h-10 w-full bg-transparent py-3 outline-hidden disabled:cursor-not-allowed disabled:opacity-50", "data-slot": "combobox-input", placeholder: "Search\u2026" })] }), _jsx(ComboboxPrimitive.Empty, { className: "text-paragraph-sm py-6 text-center empty:p-0", "data-slot": "combobox-empty", children: "No results found." }), shouldVirtualize && virtualization ? (_jsx(ComboboxPrimitive.List, { className: "p-1 empty:p-0", children: _jsx(VirtualizedOptions, { virtualization: virtualization, virtualizerRef: virtualizerRef }) })) : (_jsx(ScrollFrame, { className: "max-h-75", fadeTheme: "popover", fades: "end", children: _jsx(ComboboxPrimitive.List, { className: "scroll-py-1 p-1 empty:p-0", children: (item) => (_jsx(ComboboxOption, { item: item }, item.value)) }) }))] }) }) })] }));
110
110
  };
@@ -1,7 +1,7 @@
1
1
  import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";
2
2
  import type * as React from "react";
3
3
  /**
4
- * CommandList is the scrollable container for a Command's filtered items.
4
+ * CommandList is the scrolling container for a Command's filtered items, in a `ScrollFrame` capped at 300px.
5
5
  * Pass a function child: it receives each filtered item (or group, for grouped
6
6
  * `items`) and returns a `CommandItem` (or `CommandGroup`).
7
7
  * Import from "@instruments/materia-ui/components/command".
@@ -2,10 +2,11 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";
4
4
  import { cn } from "@instruments/materia-ui/utils/cn";
5
+ import { ScrollFrame } from "../scroll-frame.js";
5
6
  /**
6
- * CommandList is the scrollable container for a Command's filtered items.
7
+ * CommandList is the scrolling container for a Command's filtered items, in a `ScrollFrame` capped at 300px.
7
8
  * Pass a function child: it receives each filtered item (or group, for grouped
8
9
  * `items`) and returns a `CommandItem` (or `CommandGroup`).
9
10
  * Import from "@instruments/materia-ui/components/command".
10
11
  */
11
- export const CommandList = ({ className, ...props }) => (_jsx(AutocompletePrimitive.List, { className: cn("max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain empty:hidden", className), "data-component": "command-list", "data-slot": "command-list", ...props }));
12
+ export const CommandList = ({ className, ...props }) => (_jsx(ScrollFrame, { className: "max-h-75 shrink-0", fadeTheme: "popover", fades: "end", children: _jsx(AutocompletePrimitive.List, { className: cn("scroll-py-1 empty:hidden", className), "data-component": "command-list", "data-slot": "command-list", ...props }) }));
@@ -1,5 +1,5 @@
1
1
  import type { ComboboxProps } from "../combobox.js";
2
- type CountryComboboxProps = Omit<ComboboxProps, "items" | "value"> & {
2
+ type CountryComboboxProps = Omit<ComboboxProps, "items" | "onValueChange" | "value"> & {
3
3
  /** Current country code (ISO 3166-1 alpha-2) or country name */
4
4
  value?: string;
5
5
  /** Value change handler - receives ISO country code */
@@ -2,8 +2,8 @@ import type { LucideIcon } from "lucide-react";
2
2
  import type * as React from "react";
3
3
  import type { ButtonEmphasis, ButtonProps, ButtonTone } from "../button.js";
4
4
  import { Button } from "../button.js";
5
- export type InputGroupButtonProps = Omit<React.ComponentProps<typeof Button>, "size"> & {
6
- /** Icon to display. */
5
+ export type InputGroupButtonProps = Omit<React.ComponentProps<typeof Button>, "emphasis" | "icon" | "radius" | "size" | "square" | "tone"> & {
6
+ /** Icon shown in the button, passed as a component (such as a Lucide icon); with no children the button is icon-only. */
7
7
  icon?: LucideIcon | React.ComponentType<React.SVGProps<SVGSVGElement>>;
8
8
  /** Semantic colour, as on Button. @default "neutral" */
9
9
  tone?: ButtonTone;
@@ -9,6 +9,7 @@ import { getResponsiveSizeClasses } from "../../tokens/responsive.js";
9
9
  import { Button } from "../button.js";
10
10
  import { Icon } from "../icon.js";
11
11
  import { MenuItem } from "../menu-item.js";
12
+ import { ScrollFrame } from "../scroll-frame.js";
12
13
  import { useLocationInput } from "./use-location-input.js";
13
14
  /** Default LocationInput size. */
14
15
  const DEFAULT_LOCATION_INPUT_SIZE = "xl";
@@ -34,6 +35,11 @@ export const LOCATION_INPUT_SIZE_CLASSES = {
34
35
  };
35
36
  const inputWrapperVariants = cva(INPUT_WRAPPER);
36
37
  const inputVariants = cva(INPUT_CONTROL_RESET);
38
+ /** The suggestions under the field, in a frame that scrolls past ten or so. */
39
+ const Suggestions = ({ onSelect, predictions, }) => (_jsx(ScrollFrame, { className: "bg-popover absolute inset-x-0 top-full z-50 max-h-60 rounded-3xl border shadow-xs", fadeTheme: "popover", fades: "end", children: _jsx("ul", { className: "m-0 list-none p-0", children: predictions.map((prediction) => (_jsx("li", { children: _jsx(MenuItem, { className: "hover:bg-muted hover:text-accent-foreground focus:bg-muted focus:text-accent-foreground rounded-full px-4 py-2.5", onClick: () => {
40
+ onSelect(prediction.placeId);
41
+ }, children: _jsxs("span", { children: [_jsx("span", { className: "font-medium", children: prediction.mainText }), prediction.secondaryText !== undefined &&
42
+ prediction.secondaryText !== "" ? (_jsx("span", { className: "text-muted-foreground ml-1", children: prediction.secondaryText })) : null] }) }) }, prediction.placeId))) }) }));
37
43
  /**
38
44
  * Location autocomplete input with a dropdown of suggestions for city or
39
45
  * address search. The geocoder is injected: pass `search` (query to
@@ -79,8 +85,7 @@ export const LocationInput = ({ value, onChange, mode = "city", placeholder, siz
79
85
  // Mirrors focusInput() default in focus-within form (see token JSDoc).
80
86
  "focus-within:border-ring overflow-hidden rounded-md focus-within:ring-3 focus-within:ring-gray-500/80", hasError === true && hasErrorInput, disabled === true && "pointer-events-none opacity-50"), "data-component": "location-input", "data-testid": testId, children: [_jsx(Icon, { className: "text-muted-foreground shrink-0", icon: MapPin, size: "xs" }), _jsx("input", { "aria-label": "Search location", className: cn(inputVariants(), getResponsiveSizeClasses(size, LOCATION_INPUT_SIZE_CLASSES, DEFAULT_LOCATION_INPUT_SIZE)), disabled: disabled, id: id, onChange: (event) => {
81
87
  handleInputChange(event.target.value);
82
- }, onFocus: handleFocus, placeholder: effectivePlaceholder, type: "text", value: query }), hasValue && disabled !== true ? (_jsx(Button, { "aria-label": "Clear location", className: "text-muted-foreground hover:text-foreground", emphasis: "ghost", icon: X, onClick: handleClear, radius: "rounded", size: "icon-2xs", square: true, tone: "neutral", type: "button" })) : null, isLoading ? (_jsx("span", { className: "text-muted-foreground text-caption shrink-0", children: "\u2026" })) : null] }), isOpen && disabled !== true && predictions.length > 0 ? (_jsx("ul", { className: "bg-popover absolute inset-x-0 top-full z-50 m-0 max-h-60 list-none overflow-auto rounded-3xl border p-0 shadow-xs", children: predictions.map((prediction) => (_jsx("li", { children: _jsx(MenuItem, { className: "hover:bg-muted hover:text-accent-foreground focus:bg-muted focus:text-accent-foreground rounded-full px-4 py-2.5", onClick: () => {
83
- void select(prediction.placeId);
84
- }, children: _jsxs("span", { children: [_jsx("span", { className: "font-medium", children: prediction.mainText }), prediction.secondaryText !== undefined &&
85
- prediction.secondaryText !== "" ? (_jsx("span", { className: "text-muted-foreground ml-1", children: prediction.secondaryText })) : null] }) }) }, prediction.placeId))) })) : null] }));
88
+ }, onFocus: handleFocus, placeholder: effectivePlaceholder, type: "text", value: query }), hasValue && disabled !== true ? (_jsx(Button, { "aria-label": "Clear location", className: "text-muted-foreground hover:text-foreground", emphasis: "ghost", icon: X, onClick: handleClear, radius: "rounded", size: "icon-2xs", square: true, tone: "neutral", type: "button" })) : null, isLoading ? (_jsx("span", { className: "text-muted-foreground text-caption shrink-0", children: "\u2026" })) : null] }), isOpen && disabled !== true && predictions.length > 0 ? (_jsx(Suggestions, { onSelect: (placeId) => {
89
+ void select(placeId);
90
+ }, predictions: predictions })) : null] }));
86
91
  };
@@ -1,5 +1,5 @@
1
1
  import type { ComponentSize } from "@instruments/materia-ui/tokens/size";
2
- import type { ReactNode } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
3
  import type { Orientation } from "../../tokens/orientation.js";
4
4
  import type { ScrollFadeProps } from "../scroll-fade/scroll-fade-root.js";
5
5
  /** Which edges may fade. */
@@ -11,11 +11,20 @@ export type ScrollFrameFades = "both" | "end" | "none" | "start";
11
11
  * match.
12
12
  */
13
13
  export type ScrollFrameFadeMode = "color" | "mask";
14
- export interface ScrollFrameProps {
14
+ /** Which way the frame scrolls: one axis, or both, as for a wide table. */
15
+ export type ScrollFrameOrientation = Orientation | "both";
16
+ export interface ScrollFrameProps extends Omit<ComponentPropsWithoutRef<"div">, "aria-label" | "children" | "className"> {
15
17
  /** Names the frame; it becomes a labelled region. */
16
18
  "aria-label"?: string | undefined;
17
19
  children: ReactNode;
18
20
  className?: string | undefined;
21
+ /**
22
+ * Classes for the element holding the children, inside the scrolling one.
23
+ * A vertical frame's content is already as wide as the frame, so text wraps
24
+ * and `w-full` children fill it; a horizontal or two-way frame's content is
25
+ * as wide as its widest child.
26
+ */
27
+ contentClassName?: string | undefined;
19
28
  /**
20
29
  * Previous and next buttons at the edges, each shown while there is more
21
30
  * that way. They step most of a viewport, and stay hidden on touch screens,
@@ -56,12 +65,14 @@ export interface ScrollFrameProps {
56
65
  */
57
66
  hideScrollbar?: boolean | undefined;
58
67
  /**
59
- * Scroll axis: `vertical` scrolls up and down, `horizontal` left and right, as for a row of chips.
68
+ * Scroll axis: `vertical` scrolls up and down, `horizontal` left and right, as for a row of chips, and `both` either way, as for a wide table. Controls and drag follow the vertical axis when both scroll.
60
69
  * @default "vertical"
61
70
  */
62
- orientation?: Orientation | undefined;
71
+ orientation?: ScrollFrameOrientation | undefined;
63
72
  /** Classes for the scrolling element. */
64
73
  viewportClassName?: string | undefined;
74
+ /** The scrolling element, for a virtualiser or anything else that reads or sets its scroll position. */
75
+ viewportRef?: Ref<HTMLDivElement | null> | undefined;
65
76
  }
66
77
  /**
67
78
  * A scrolling region that shows where there is more: each edge fades only
@@ -80,6 +91,8 @@ export interface ScrollFrameProps {
80
91
  * @bestPractice Give a vertical frame a bounded height (`className="h-80"`); a horizontal one needs only a bounded width, which a flex child with `min-w-0` already has.
81
92
  * @bestPractice Match `fadeTheme` to the surface behind the frame (`card` inside a Card). Over an image or a blur, use `fadeMode="mask"` instead.
82
93
  * @bestPractice For a horizontal strip used with a mouse, add `dragScroll` and `controls`; both stand aside on touch screens, and each control hides at the end it points to.
94
+ * @bestPractice Every scrolling region in an app is a `ScrollFrame`, not a hand-rolled `overflow-y-auto` div: a pane, a list in a popover, a wide table (`orientation="both"`). A frame capped by `max-h-*` alone scrolls once its content passes the cap.
95
+ * @bestPractice Hand a virtualiser the scrolling element through `viewportRef`.
83
96
  * @category layout
84
97
  */
85
- export declare const ScrollFrame: ({ "aria-label": ariaLabel, children, className, controls, dragScroll, fadeMode, fades, fadeSize, fadeTheme, hideScrollbar, orientation, viewportClassName, }: ScrollFrameProps) => import("react").JSX.Element;
98
+ export declare const ScrollFrame: ({ "aria-label": ariaLabel, children, className, contentClassName, controls, dragScroll, fadeMode, fades, fadeSize, fadeTheme, hideScrollbar, orientation, viewportClassName, viewportRef, ...props }: ScrollFrameProps) => import("react").JSX.Element;
@@ -4,7 +4,7 @@ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
4
4
  import { focusRing } from "@instruments/materia-ui/tokens/focus-ring";
5
5
  import { cn } from "@instruments/materia-ui/utils/cn";
6
6
  import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, } from "lucide-react";
7
- import { useState } from "react";
7
+ import { useImperativeHandle, useState } from "react";
8
8
  import { useDragScroll } from "../../hooks/use-drag-scroll.js";
9
9
  import { useScrollEdges } from "../../hooks/use-scroll-edges.js";
10
10
  import { Button } from "../button.js";
@@ -23,12 +23,15 @@ const FADE_LENGTH = {
23
23
  xs: "0.75rem",
24
24
  };
25
25
  const fadesEdge = (fades, edge) => fades === "both" || fades === edge;
26
- const maskStyle = (orientation, length, start, end) => {
27
- const direction = orientation === "horizontal" ? "to right" : "to bottom";
26
+ /** The axes a frame scrolls along. */
27
+ const axesOf = (orientation) => orientation === "both" ? ["vertical", "horizontal"] : [orientation];
28
+ /** The one axis that controls and drag move: vertical when both scroll. */
29
+ const leadAxis = (orientation) => orientation === "horizontal" ? "horizontal" : "vertical";
30
+ const maskRamp = (axis, length, start, end) => {
31
+ const direction = axis === "horizontal" ? "to right" : "to bottom";
28
32
  const from = start ? length : "0px";
29
33
  const to = end ? length : "0px";
30
- const image = `linear-gradient(${direction}, transparent, black ${from}, black calc(100% - ${to}), transparent)`;
31
- return { WebkitMaskImage: image, maskImage: image };
34
+ return `linear-gradient(${direction}, transparent, black ${from}, black calc(100% - ${to}), transparent)`;
32
35
  };
33
36
  const prefersReducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
34
37
  const CONTROL_ICONS = {
@@ -60,20 +63,39 @@ const moreOf = (edges) => ({
60
63
  end: edges.scrollable && !edges.atEnd,
61
64
  start: edges.scrollable && !edges.atStart,
62
65
  });
63
- /** The viewport's mask in mask mode; nothing in colour mode. */
64
- const frameMask = (fadeMode, orientation, fadeSize, fades, more) => fadeMode === "mask"
65
- ? maskStyle(orientation, FADE_LENGTH[fadeSize], more.start && fadesEdge(fades, "start"), more.end && fadesEdge(fades, "end"))
66
- : undefined;
66
+ /** The viewport's mask in mask mode, one ramp per axis intersected; nothing in colour mode. */
67
+ const frameMask = (fadeMode, orientation, fadeSize, fades, more) => {
68
+ if (fadeMode !== "mask") {
69
+ return undefined;
70
+ }
71
+ const image = axesOf(orientation)
72
+ .map((axis) => maskRamp(axis, FADE_LENGTH[fadeSize], more[axis].start && fadesEdge(fades, "start"), more[axis].end && fadesEdge(fades, "end")))
73
+ .join(", ");
74
+ return orientation === "both"
75
+ ? {
76
+ maskComposite: "intersect",
77
+ maskImage: image,
78
+ WebkitMaskComposite: "source-in",
79
+ WebkitMaskImage: image,
80
+ }
81
+ : { maskImage: image, WebkitMaskImage: image };
82
+ };
67
83
  /** The colour fades, each shown while there is more beyond its edge. */
68
- const Fades = ({ fades, fadeSize, fadeTheme, more, orientation, }) => EDGES.filter((edge) => fadesEdge(fades, edge)).map((edge) => (_jsx(ScrollFade, { className: cn("duration-normal transition-opacity motion-reduce:transition-none", !more[edge] && "opacity-0"), "data-slot": "scroll-frame-fade", orientation: orientation, position: edge, size: fadeSize, theme: fadeTheme }, edge)));
84
+ const Fades = ({ fades, fadeSize, fadeTheme, more, orientation, }) => axesOf(orientation).flatMap((axis) => EDGES.filter((edge) => fadesEdge(fades, edge)).map((edge) => (_jsx(ScrollFade, { className: cn("duration-normal transition-opacity motion-reduce:transition-none", !more[axis][edge] && "opacity-0"), "data-slot": "scroll-frame-fade", orientation: axis, position: edge, size: fadeSize, theme: fadeTheme }, `${axis}-${edge}`))));
69
85
  /** Previous and next, each shown while there is more its way. */
70
86
  const Controls = ({ more, orientation, viewport, }) => EDGES.map((edge) => (_jsx(Button, { "aria-label": edge === "start" ? "Scroll back" : "Scroll on", className: cn("duration-normal absolute z-20 transition-opacity after:absolute after:-inset-1.5 motion-reduce:transition-none pointer-coarse:hidden", CONTROL_PLACEMENT[orientation][edge], !more[edge] && "pointer-events-none opacity-0"), "data-slot": "scroll-frame-control", emphasis: "outline", icon: CONTROL_ICONS[orientation][edge], onClick: () => {
71
87
  stepBy(viewport, orientation, edge);
72
88
  }, radius: "full", size: "icon-sm", square: true, tabIndex: more[edge] ? undefined : -1, tone: "neutral", type: "button" }, edge)));
73
89
  /** The scrolling element and its content, which a mouse can drag. */
74
- const Viewport = ({ children, className, dragScroll, mask, onViewport, orientation, scrollable, viewport, }) => {
75
- const drag = useDragScroll(viewport, orientation, { enabled: dragScroll });
76
- return (_jsx(ScrollAreaPrimitive.Viewport, { className: cn(focusRing(), "rounded-inherit size-full focus-visible:ring-inset", className), "data-slot": "scroll-frame-viewport", ref: onViewport, style: mask, children: _jsx(ScrollAreaPrimitive.Content, { className: cn(dragScroll && scrollable && "cursor-grab", drag.dragging && "cursor-grabbing select-none"), "data-dragging": drag.dragging ? "" : undefined, ...drag.handlers, children: children }) }));
90
+ const Viewport = ({ children, className, contentClassName, dragScroll, mask, onViewport, orientation, scrollable, viewport, }) => {
91
+ const drag = useDragScroll(viewport, leadAxis(orientation), {
92
+ enabled: dragScroll,
93
+ });
94
+ return (_jsx(ScrollAreaPrimitive.Viewport, { className: cn(focusRing(),
95
+ // A frame sized only by max-height still scrolls: the viewport takes that cap as its own.
96
+ "rounded-inherit size-full max-h-[inherit] focus-visible:ring-inset", className), "data-slot": "scroll-frame-viewport", ref: onViewport, style: mask, children: _jsx(ScrollAreaPrimitive.Content, { className: cn(
97
+ // Base UI sizes the content to its widest child; a vertical frame lays out at its own width.
98
+ orientation === "vertical" && "min-w-0!", dragScroll && scrollable && "cursor-grab", drag.dragging && "cursor-grabbing select-none", contentClassName), "data-dragging": drag.dragging ? "" : undefined, ...drag.handlers, children: children }) }));
77
99
  };
78
100
  /**
79
101
  * A scrolling region that shows where there is more: each edge fades only
@@ -92,11 +114,21 @@ const Viewport = ({ children, className, dragScroll, mask, onViewport, orientati
92
114
  * @bestPractice Give a vertical frame a bounded height (`className="h-80"`); a horizontal one needs only a bounded width, which a flex child with `min-w-0` already has.
93
115
  * @bestPractice Match `fadeTheme` to the surface behind the frame (`card` inside a Card). Over an image or a blur, use `fadeMode="mask"` instead.
94
116
  * @bestPractice For a horizontal strip used with a mouse, add `dragScroll` and `controls`; both stand aside on touch screens, and each control hides at the end it points to.
117
+ * @bestPractice Every scrolling region in an app is a `ScrollFrame`, not a hand-rolled `overflow-y-auto` div: a pane, a list in a popover, a wide table (`orientation="both"`). A frame capped by `max-h-*` alone scrolls once its content passes the cap.
118
+ * @bestPractice Hand a virtualiser the scrolling element through `viewportRef`.
95
119
  * @category layout
96
120
  */
97
- export const ScrollFrame = ({ "aria-label": ariaLabel, children, className, controls = false, dragScroll = false, fadeMode = "color", fades = "both", fadeSize = "base", fadeTheme = "background", hideScrollbar = false, orientation = "vertical", viewportClassName, }) => {
121
+ export const ScrollFrame = ({ "aria-label": ariaLabel, children, className, contentClassName, controls = false, dragScroll = false, fadeMode = "color", fades = "both", fadeSize = "base", fadeTheme = "background", hideScrollbar = false, orientation = "vertical", viewportClassName, viewportRef, ...props }) => {
98
122
  const [viewport, setViewport] = useState(null);
99
- const edges = useScrollEdges(viewport, orientation);
100
- const more = moreOf(edges);
101
- return (_jsxs(ScrollAreaPrimitive.Root, { "aria-label": ariaLabel, className: cn("relative min-w-0 overflow-hidden", className), "data-component": "scroll-frame", "data-orientation": orientation, "data-slot": "scroll-frame", role: ariaLabel === undefined ? undefined : "region", children: [_jsx(Viewport, { className: viewportClassName, dragScroll: dragScroll, mask: frameMask(fadeMode, orientation, fadeSize, fades, more), onViewport: setViewport, orientation: orientation, scrollable: edges.scrollable, viewport: viewport, children: children }), hideScrollbar ? null : _jsx(ScrollBar, { orientation: orientation }), fadeMode === "color" ? (_jsx(Fades, { fadeSize: fadeSize, fadeTheme: fadeTheme, fades: fades, more: more, orientation: orientation })) : null, controls ? (_jsx(Controls, { more: more, orientation: orientation, viewport: viewport })) : null] }));
123
+ const vertical = useScrollEdges(viewport, "vertical");
124
+ const horizontal = useScrollEdges(viewport, "horizontal");
125
+ const more = {
126
+ horizontal: moreOf(horizontal),
127
+ vertical: moreOf(vertical),
128
+ };
129
+ const lead = leadAxis(orientation);
130
+ useImperativeHandle(viewportRef, () => viewport, [viewport]);
131
+ return (_jsxs(ScrollAreaPrimitive.Root, { ...props, "aria-label": ariaLabel, className: cn("relative min-w-0 overflow-hidden", className), "data-component": "scroll-frame", "data-orientation": orientation, "data-slot": "scroll-frame", role: ariaLabel === undefined ? undefined : "region", children: [_jsx(Viewport, { className: viewportClassName, contentClassName: contentClassName, dragScroll: dragScroll, mask: frameMask(fadeMode, orientation, fadeSize, fades, more), onViewport: setViewport, orientation: orientation, scrollable: (lead === "vertical" ? vertical : horizontal).scrollable, viewport: viewport, children: children }), hideScrollbar
132
+ ? null
133
+ : axesOf(orientation).map((axis) => (_jsx(ScrollBar, { orientation: axis }, axis))), orientation === "both" ? _jsx(ScrollAreaPrimitive.Corner, {}) : null, fadeMode === "color" ? (_jsx(Fades, { fadeSize: fadeSize, fadeTheme: fadeTheme, fades: fades, more: more, orientation: orientation })) : null, controls ? (_jsx(Controls, { more: more[lead], orientation: lead, viewport: viewport })) : null] }));
102
134
  };
@@ -1 +1 @@
1
- export { ScrollFrame, type ScrollFrameFadeMode, type ScrollFrameFades, type ScrollFrameProps, } from "./scroll-frame/scroll-frame-root.js";
1
+ export { ScrollFrame, type ScrollFrameFadeMode, type ScrollFrameFades, type ScrollFrameOrientation, type ScrollFrameProps, } from "./scroll-frame/scroll-frame-root.js";
@@ -7,7 +7,7 @@ import type { ButtonProps } from "../button.js";
7
7
  * SelectValue placeholder), with a trailing chevron; forwards Button styling
8
8
  * props - defaults to `size="xl"`, `emphasis="outline"`, `radius="rounded"`.
9
9
  */
10
- declare const SelectTrigger: ({ className, children, testId, tone, size, emphasis, radius, pressed, ...props }: WithTestId<Omit<SelectPrimitive.Trigger.Props, "className" | "render"> & Pick<ButtonProps, "emphasis" | "pressed" | "radius" | "tone"> & {
10
+ declare const SelectTrigger: ({ className, children, testId, tone, size, emphasis, radius, pressed, ...props }: WithTestId<Omit<SelectPrimitive.Trigger.Props, "className" | "render"> & {
11
11
  className?: string;
12
12
  /** Semantic colour of the trigger, as on Button. @default "neutral" */
13
13
  tone?: ButtonProps["tone"];
@@ -7,9 +7,10 @@ type SidepanelBodyProps = ComponentProps<"div">;
7
7
  declare const SidepanelBody: ({ className, ...props }: SidepanelBodyProps) => import("react").JSX.Element;
8
8
  type SidepanelScrollProps = ComponentProps<"div">;
9
9
  /**
10
- * The scrolling region between a `SidepanelHeader` and `SidepanelFooter`:
11
- * takes the remaining height of `SidepanelBody`, scrolls on its own and carries
12
- * the panel's standard inset, so header and footer stay put while it moves.
10
+ * The scrolling region between a `SidepanelHeader` and `SidepanelFooter`, a
11
+ * `ScrollFrame`: takes the remaining height of `SidepanelBody`, scrolls on its
12
+ * own and carries the panel's standard inset, so header and footer stay put
13
+ * while it moves.
13
14
  *
14
15
  * @bestPractice Put exactly one `SidepanelScroll` inside `SidepanelBody`, between the header and footer, rather than a hand-rolled `flex-1 overflow-y-auto` div.
15
16
  */
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Flex } from "@instruments/materia-ui/components/flex";
4
4
  import { cn } from "@instruments/materia-ui/utils/cn";
5
+ import { ScrollFrame } from "../scroll-frame.js";
5
6
  /**
6
7
  * Body container for sidepanel content. Provides vertical flex layout
7
8
  * for header, scrollable content, and footer.
@@ -10,11 +11,12 @@ const SidepanelBody = ({ className, ...props }) => (_jsx(Flex, { className: cn("
10
11
  // Allow text selection in the body, even inside a swipe-dismissable surface
11
12
  "select-text", className), "data-component": "sidepanel-body", "data-slot": "sidepanel-body", direction: "column", ...props }));
12
13
  /**
13
- * The scrolling region between a `SidepanelHeader` and `SidepanelFooter`:
14
- * takes the remaining height of `SidepanelBody`, scrolls on its own and carries
15
- * the panel's standard inset, so header and footer stay put while it moves.
14
+ * The scrolling region between a `SidepanelHeader` and `SidepanelFooter`, a
15
+ * `ScrollFrame`: takes the remaining height of `SidepanelBody`, scrolls on its
16
+ * own and carries the panel's standard inset, so header and footer stay put
17
+ * while it moves.
16
18
  *
17
19
  * @bestPractice Put exactly one `SidepanelScroll` inside `SidepanelBody`, between the header and footer, rather than a hand-rolled `flex-1 overflow-y-auto` div.
18
20
  */
19
- const SidepanelScroll = ({ className, ...props }) => (_jsx(Flex, { className: cn("min-h-0 flex-1 overflow-y-auto px-6 py-4", className), "data-component": "sidepanel-scroll", "data-slot": "sidepanel-scroll", direction: "column", gap: "base", ...props }));
21
+ const SidepanelScroll = ({ className, ...props }) => (_jsx(ScrollFrame, { className: "min-h-0 flex-1", fades: "end", children: _jsx(Flex, { className: cn("px-6 py-4", className), "data-component": "sidepanel-scroll", "data-slot": "sidepanel-scroll", direction: "column", gap: "base", ...props }) }));
20
22
  export { SidepanelBody, SidepanelScroll, };
@@ -1,7 +1,7 @@
1
1
  import { Radio as RadioPrimitive } from "@base-ui/react/radio";
2
2
  import type React from "react";
3
3
  import type { SwatchSize } from "../../tokens/swatch.js";
4
- type SwatchGroupItemProps = Omit<RadioPrimitive.Root.Props<string>, "children"> & {
4
+ type SwatchGroupItemProps = Omit<RadioPrimitive.Root.Props<string>, "aria-label" | "children"> & {
5
5
  /**
6
6
  * CSS colour value (hex, rgb, hsl, etc.).
7
7
  * If provided along with children, children render on top.
@@ -1,6 +1,6 @@
1
1
  import type { ComponentProps } from "react";
2
2
  import type { SwatchSize } from "../../tokens/swatch.js";
3
- type SwatchSelectorItemProps = Omit<ComponentProps<"button">, "children" | "defaultValue" | "value"> & {
3
+ type SwatchSelectorItemProps = Omit<ComponentProps<"button">, "aria-label" | "children" | "defaultValue" | "value"> & {
4
4
  /** Unique value for this swatch */
5
5
  value: string;
6
6
  /**
@@ -1,6 +1,6 @@
1
1
  import type * as React from "react";
2
2
  /**
3
- * Semantic `<table>` primitive, wrapped in a horizontally scrollable container
3
+ * Semantic `<table>` primitive, wrapped in a horizontal `ScrollFrame`
4
4
  * so wide tables overflow rather than break layout. Compose with the exported
5
5
  * `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`,
6
6
  * `TableFooter`, and `TableCaption` parts.
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "@instruments/materia-ui/utils/cn";
4
+ import { ScrollFrame } from "../scroll-frame.js";
4
5
  /**
5
- * Semantic `<table>` primitive, wrapped in a horizontally scrollable container
6
+ * Semantic `<table>` primitive, wrapped in a horizontal `ScrollFrame`
6
7
  * so wide tables overflow rather than break layout. Compose with the exported
7
8
  * `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`,
8
9
  * `TableFooter`, and `TableCaption` parts.
@@ -12,7 +13,7 @@ import { cn } from "@instruments/materia-ui/utils/cn";
12
13
  * @bestPractice Compose real `TableHeader`/`TableBody`/`TableRow`/`TableHead`/`TableCell` parts (semantic `thead`/`tbody`/`th`/`td`); don't fake rows with styled divs or you lose the table semantics.
13
14
  * @category data
14
15
  */
15
- const Table = ({ className, ...props }) => (_jsx("div", { className: "relative w-full overflow-auto", "data-component": "table-wrapper", "data-slot": "table-wrapper", children: _jsx("table", { className: cn("text-foreground text-paragraph-sm w-full caption-bottom", className), "data-component": "table", "data-slot": "table", ...props }) }));
16
+ const Table = ({ className, ...props }) => (_jsx(ScrollFrame, { className: "w-full", contentClassName: "min-w-0!", orientation: "horizontal", children: _jsx("table", { className: cn("text-foreground text-paragraph-sm w-full caption-bottom", className), "data-component": "table", "data-slot": "table", ...props }) }));
16
17
  /** table header section */
17
18
  const TableHeader = ({ className, ...props }) => (_jsx("thead", { className: cn("[&_tr]:border-b", className), "data-component": "table-header", "data-slot": "table-header", ...props }));
18
19
  /**
@@ -6,6 +6,7 @@ import { Button } from "@instruments/materia-ui/components/button";
6
6
  import { Center } from "@instruments/materia-ui/components/center";
7
7
  import { Flex } from "@instruments/materia-ui/components/flex";
8
8
  import { Icon } from "@instruments/materia-ui/components/icon";
9
+ import { ScrollFrame } from "@instruments/materia-ui/components/scroll-frame";
9
10
  import { Separator } from "@instruments/materia-ui/components/separator";
10
11
  import { focusRing } from "@instruments/materia-ui/tokens/focus-ring";
11
12
  import { cn } from "@instruments/materia-ui/utils/cn";
@@ -57,6 +58,6 @@ const DataGridColumnFilter = ({ column, title, options, }) => {
57
58
  // It renders the kit's Button, a native <button>.
58
59
  , {
59
60
  // It renders the kit's Button, a native <button>.
60
- nativeButton: true, "aria-label": title, render: _jsx(Button, { tone: "neutral", emphasis: "outline", "data-component": "data-grid-column-filter", pressed: false, size: "sm" }), children: [_jsx(Icon, { icon: CirclePlus, size: "xs" }), title, _jsx(FilterTriggerBadges, { options: options, selectedValues: selectedValues })] }), _jsx(ComboboxPrimitive.Portal, { children: _jsx(ComboboxPrimitive.Positioner, { align: "start", className: "z-50", sideOffset: 4, children: _jsx(ComboboxPrimitive.Popup, { "aria-label": title, className: `${POPOVER_CONTENT_ANIMATION} ${POPOVER_SURFACE} w-50 origin-(--transform-origin) rounded-3xl`, "data-slot": "data-grid-column-filter-content", children: _jsxs(Flex, { className: "bg-popover text-popover-foreground size-full overflow-hidden rounded-lg border shadow-xs", direction: "column", children: [_jsxs(Flex, { align: "center", className: "h-9 border-b px-3", gap: "xs", children: [_jsx(Icon, { className: "shrink-0 opacity-50", icon: Search, size: "xs" }), _jsx(ComboboxPrimitive.Input, { className: "placeholder:text-muted-foreground text-paragraph-sm flex h-10 w-full bg-transparent py-3 outline-hidden", placeholder: title })] }), _jsx(ComboboxPrimitive.Empty, { className: "text-paragraph-sm py-6 text-center empty:p-0", children: "No results found." }), _jsx(ComboboxPrimitive.List, { className: "max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 empty:hidden", children: (option) => (_jsx(FilterOptionItem, { count: facets?.get(option.value) ?? 0, isSelected: selectedValues.has(option.value), option: option }, option.value)) }), selectedValues.size > 0 && (_jsx("div", { className: "border-t p-1", children: _jsx("button", { className: cn(focusRing(), "hover:bg-accent hover:text-accent-foreground text-paragraph-sm flex w-full cursor-default items-center justify-center rounded-sm px-2.5 py-1.5 select-none"), onClick: () => column?.setFilterValue(undefined), type: "button", children: "Clear filters" }) }))] }) }) }) })] }));
61
+ nativeButton: true, "aria-label": title, render: _jsx(Button, { tone: "neutral", emphasis: "outline", "data-component": "data-grid-column-filter", pressed: false, size: "sm" }), children: [_jsx(Icon, { icon: CirclePlus, size: "xs" }), title, _jsx(FilterTriggerBadges, { options: options, selectedValues: selectedValues })] }), _jsx(ComboboxPrimitive.Portal, { children: _jsx(ComboboxPrimitive.Positioner, { align: "start", className: "z-50", sideOffset: 4, children: _jsx(ComboboxPrimitive.Popup, { "aria-label": title, className: `${POPOVER_CONTENT_ANIMATION} ${POPOVER_SURFACE} w-50 origin-(--transform-origin) rounded-3xl`, "data-slot": "data-grid-column-filter-content", children: _jsxs(Flex, { className: "bg-popover text-popover-foreground size-full overflow-hidden rounded-lg border shadow-xs", direction: "column", children: [_jsxs(Flex, { align: "center", className: "h-9 border-b px-3", gap: "xs", children: [_jsx(Icon, { className: "shrink-0 opacity-50", icon: Search, size: "xs" }), _jsx(ComboboxPrimitive.Input, { className: "placeholder:text-muted-foreground text-paragraph-sm flex h-10 w-full bg-transparent py-3 outline-hidden", placeholder: title })] }), _jsx(ComboboxPrimitive.Empty, { className: "text-paragraph-sm py-6 text-center empty:p-0", children: "No results found." }), _jsx(ScrollFrame, { className: "max-h-75", fadeTheme: "popover", fades: "end", children: _jsx(ComboboxPrimitive.List, { className: "scroll-py-1 p-1 empty:hidden", children: (option) => (_jsx(FilterOptionItem, { count: facets?.get(option.value) ?? 0, isSelected: selectedValues.has(option.value), option: option }, option.value)) }) }), selectedValues.size > 0 && (_jsx("div", { className: "border-t p-1", children: _jsx("button", { className: cn(focusRing(), "hover:bg-accent hover:text-accent-foreground text-paragraph-sm flex w-full cursor-default items-center justify-center rounded-sm px-2.5 py-1.5 select-none"), onClick: () => column?.setFilterValue(undefined), type: "button", children: "Clear filters" }) }))] }) }) }) })] }));
61
62
  };
62
63
  export { DataGridColumnFilter };
@@ -3,6 +3,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { DataGridFilterableHeader, useDataGrid, } from "@instruments/materia-ui/compositions/data-grid";
4
4
  import { Fragment } from "react";
5
5
  import { Center } from "../../components/center.js";
6
+ import { ScrollFrame } from "../../components/scroll-frame.js";
6
7
  import { Spinner } from "../../components/spinner/spinner-root.js";
7
8
  import { DataGridTableBody, DataGridTableBodyRow, DataGridTableBodyRowCell, DataGridTableBodyRowExpandded, DataGridTableBodyRowSkeleton, DataGridTableBodyRowSkeletonCell, DataGridTableEmpty, DataGridTableRowSpacer, flexRender, } from "../data-grid-table/data-grid-table-body.js";
8
9
  import { DataGridTableBase, DataGridTableHead, DataGridTableHeadRow, DataGridTableHeadRowCell, DataGridTableHeadRowCellResize, } from "../data-grid-table/data-grid-table-header.js";
@@ -100,7 +101,7 @@ const DataGridTable = () => {
100
101
  rowHeight,
101
102
  rows,
102
103
  });
103
- return (_jsx("div", { className: "overflow-auto", ref: tableContainerRef, style: { maxHeight: "calc(100vh - 8rem)" }, children: _jsxs(DataGridTableBase, { children: [_jsx(DataGridTableHeadContent, { context: context }), (props.tableLayout?.striped === true ||
104
+ return (_jsx(ScrollFrame, { contentClassName: "min-w-0!", fades: "none", orientation: "both", style: { maxHeight: "calc(100vh - 8rem)" }, viewportRef: tableContainerRef, children: _jsxs(DataGridTableBase, { children: [_jsx(DataGridTableHeadContent, { context: context }), (props.tableLayout?.striped === true ||
104
105
  props.tableLayout?.rowBorder !== true) && _jsx(DataGridTableRowSpacer, {}), _jsx(DataGridTableBody, { children: _jsx(DataGridTableBodyContent, { context: context, measureRow: measureRow, paddingBottom: paddingBottom, paddingTop: paddingTop, pageSize: pagination.pageSize, renderedRowIndexes: renderedRowIndexes, rows: rows }) })] }) }));
105
106
  };
106
107
  export { DataGridTableBody, DataGridTableBodyRow, DataGridTableBodyRowCell, DataGridTableBodyRowExpandded, DataGridTableBodyRowSkeleton, DataGridTableBodyRowSkeletonCell, DataGridTableEmpty, DataGridTableLoader, DataGridTableRowSelect, DataGridTableRowSelectAll, DataGridTableRowSpacer, } from "../data-grid-table/data-grid-table-body.js";
@@ -3,6 +3,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { Box } from "@instruments/materia-ui/components/box";
4
4
  import { Grid } from "@instruments/materia-ui/components/grid";
5
5
  import { PopoverBackdrop, PopoverContent, } from "@instruments/materia-ui/components/popover";
6
+ import { ScrollFrame } from "@instruments/materia-ui/components/scroll-frame";
6
7
  import { cn } from "@instruments/materia-ui/utils/cn";
7
8
  // The window's top edge. The panel hangs from it, full width, in the shell's
8
9
  // overlay layer: above the sidebar's nav and the page, beneath the chrome
@@ -33,6 +34,6 @@ const BACKDROP_MOTION = [
33
34
  export const MegaMenuContent = ({ "aria-label": ariaLabel, children, className, ...props }) => (_jsxs(_Fragment, { children: [_jsx(PopoverBackdrop, { className: cn("z-30 bg-black/20 backdrop-blur-sm", BACKDROP_MOTION) }), _jsx(PopoverContent, { align: "start", anchor: WINDOW_TOP,
34
35
  // It runs edge to edge across the window, so it is never nudged in from
35
36
  // the viewport's edge.
36
- collisionPadding: 0, className: cn("bg-background max-h-svh w-(--anchor-width) overflow-y-auto overscroll-contain rounded-none p-0 shadow-xl", PANEL_MOTION, className), "data-component": "mega-menu-content", "data-slot": "mega-menu-content",
37
+ collisionPadding: 0, className: cn("bg-background max-h-svh w-(--anchor-width) overflow-hidden rounded-none p-0 shadow-xl", PANEL_MOTION, className), "data-component": "mega-menu-content", "data-slot": "mega-menu-content",
37
38
  // Beneath the chrome's layer, above the sidebar's.
38
- positionerClassName: "z-30", side: "bottom", sideOffset: 0, ...props, children: _jsx(Box, { "aria-label": ariaLabel, as: "nav", className: "pt-header @container", children: _jsx(Grid, { align: "flex-start", className: "px-page-gutter pt-6 pb-10", columns: { base: 2, lg: 3 }, "data-slot": "mega-menu-grid", responsiveMode: "container", xGap: "2xl", yGap: "lg", children: children }) }) })] }));
39
+ positionerClassName: "z-30", side: "bottom", sideOffset: 0, ...props, children: _jsx(ScrollFrame, { className: "max-h-svh", fades: "none", children: _jsx(Box, { "aria-label": ariaLabel, as: "nav", className: "pt-header @container", children: _jsx(Grid, { align: "flex-start", className: "px-page-gutter pt-6 pb-10", columns: { base: 2, lg: 3 }, "data-slot": "mega-menu-grid", responsiveMode: "container", xGap: "2xl", yGap: "lg", children: children }) }) }) })] }));
@@ -38,7 +38,7 @@ export declare const MobileNavigationHeaderSearchSlot: ({ className, ...props }:
38
38
  *
39
39
  * @bestPractice Keep children to a few characters, such as a cart count, and give the action an `aria-label` that says what the number means.
40
40
  */
41
- export declare const MobileNavigationHeaderAction: ({ children, className, emphasis, ...props }: React.ComponentProps<typeof Button> & {
41
+ export declare const MobileNavigationHeaderAction: ({ children, className, emphasis, ...props }: Omit<React.ComponentProps<typeof Button>, "emphasis"> & {
42
42
  /** Visual weight, as on Button. @default "soft" */
43
43
  emphasis?: React.ComponentProps<typeof Button>["emphasis"];
44
44
  }) => React.JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { ScrollArea } from "@instruments/materia-ui/components/scroll-area";
2
2
  import type { ComponentProps } from "react";
3
- export type QuickViewBodyProps = Omit<ComponentProps<typeof ScrollArea>, "as" | "orientation" | "render"> & {
3
+ export type QuickViewBodyProps = Omit<ComponentProps<typeof ScrollArea>, "aria-label" | "as" | "orientation" | "render"> & {
4
4
  /** Names the scrolling region for assistive tech, e.g. "Product details". */
5
5
  "aria-label": string;
6
6
  };
@@ -1,3 +1,3 @@
1
1
  import type React from "react";
2
- /** Scrollable main region of the sidebar, between header and footer; overflow is clipped when collapsed to an icon rail. */
2
+ /** The sidebar's main region between header and footer, in a `ScrollFrame`; it stops scrolling when collapsed to an icon rail. */
3
3
  export declare const SidebarContent: ({ className, ...props }: React.ComponentProps<"div">) => React.JSX.Element;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Flex } from "@instruments/materia-ui/components/flex";
4
+ import { ScrollFrame } from "@instruments/materia-ui/components/scroll-frame";
4
5
  import { cn } from "@instruments/materia-ui/utils/cn";
5
- /** Scrollable main region of the sidebar, between header and footer; overflow is clipped when collapsed to an icon rail. */
6
- export const SidebarContent = ({ className, ...props }) => (_jsx(Flex, { className: cn("min-h-0 flex-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden", className), "data-component": "sidebar-content", "data-sidebar": "content", "data-slot": "sidebar-content", direction: "column", ...props }));
6
+ /** The sidebar's main region between header and footer, in a `ScrollFrame`; it stops scrolling when collapsed to an icon rail. */
7
+ export const SidebarContent = ({ className, ...props }) => (_jsx(ScrollFrame, { className: "min-h-0 flex-1", fadeTheme: "gray", fades: "none", viewportClassName: "group-data-[collapsible=icon]:overflow-hidden!", children: _jsx(Flex, { className: cn("min-h-full", className), "data-component": "sidebar-content", "data-sidebar": "content", "data-slot": "sidebar-content", direction: "column", ...props }) }));
@@ -6,7 +6,7 @@ export declare const SidebarGroup: ({ className, ...props }: React.ComponentProp
6
6
  /** Heading for a {@link SidebarGroup}; fades out (keeping layout) when collapsed to an icon rail. Pass `render` to use a custom element. */
7
7
  export declare const SidebarGroupLabel: ({ className, render, ...props }: useRender.ComponentProps<"div">) => React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
8
8
  /** Icon button pinned to a group's top-right corner (defaults to a `+`); hidden when collapsed unless `showWhenCollapsed`. */
9
- export declare const SidebarGroupAction: ({ className, icon, size, radius, emphasis, showWhenCollapsed, ...props }: React.ComponentProps<typeof Button> & {
9
+ export declare const SidebarGroupAction: ({ className, icon, size, radius, emphasis, showWhenCollapsed, ...props }: Omit<React.ComponentProps<typeof Button>, "emphasis" | "size"> & {
10
10
  /** Size token, as on Button. @default "icon-sm" */
11
11
  size?: React.ComponentProps<typeof Button>["size"];
12
12
  /** Visual weight, as on Button. @default "ghost" */
@@ -6,7 +6,7 @@ type SidebarMenuSubProps = React.ComponentProps<"ul">;
6
6
  /** The `<ul>` container for a nested submenu under a menu item; hidden when the sidebar collapses to an icon rail. */
7
7
  export declare const SidebarMenuSub: ({ className, ...props }: SidebarMenuSubProps) => React.JSX.Element;
8
8
  type SidebarMenuSubScrollAreaProps = React.ComponentProps<"div"> & {
9
- /** Add a bottom gradient fade hinting at scrollable overflow. @default false */
9
+ /** Fade the bottom edge while more of the list is below. @default false */
10
10
  fade?: boolean;
11
11
  /** Cap the height: unconstrained, or clamped to the shared dropdown max. @default "none" */
12
12
  maxHeight?: "none" | "dropdown";
@@ -19,7 +19,7 @@ export declare const SidebarMenuSubScrollArea: ({ children, className, fade, max
19
19
  type SidebarMenuSubItemProps = React.ComponentProps<"li">;
20
20
  /** The `<li>` wrapper for a single submenu row. */
21
21
  export declare const SidebarMenuSubItem: ({ className, ...props }: SidebarMenuSubItemProps) => React.JSX.Element;
22
- type SidebarMenuSubButtonProps = Omit<MenuItemProps, "icon" | "dot"> & {
22
+ type SidebarMenuSubButtonProps = Omit<MenuItemProps, "dot" | "icon" | "iconClassName" | "size" | "variant"> & {
23
23
  /** Dot indicator in the left column (takes precedence over icon): `true` for a neutral dot, or a {@link Tone} to tint it. */
24
24
  dot?: boolean | Tone;
25
25
  /** Icon to display in the left icon column */