@instruments/materia-ui 0.4.8 → 0.4.10

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 (31) hide show
  1. package/agent-docs.md +15 -4
  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/location-input/location-input-root.js +9 -4
  8. package/dist/components/scroll-frame/scroll-frame-root.d.ts +18 -5
  9. package/dist/components/scroll-frame/scroll-frame-root.js +49 -17
  10. package/dist/components/scroll-frame.d.ts +1 -1
  11. package/dist/components/sidepanel/sidepanel-body.d.ts +4 -3
  12. package/dist/components/sidepanel/sidepanel-body.js +6 -4
  13. package/dist/components/stat/stat-root.d.ts +32 -0
  14. package/dist/components/stat/stat-root.js +28 -0
  15. package/dist/components/stat.d.ts +2 -0
  16. package/dist/components/stat.js +1 -0
  17. package/dist/components/table/table-root.d.ts +1 -1
  18. package/dist/components/table/table-root.js +3 -2
  19. package/dist/compositions/attribute-card.d.ts +1 -1
  20. package/dist/compositions/attribute-card.js +1 -1
  21. package/dist/compositions/data-grid-column-filter/data-grid-column-filter-root.js +2 -1
  22. package/dist/compositions/data-grid-table/data-grid-table-root.js +2 -1
  23. package/dist/compositions/mega-menu/mega-menu-content.js +3 -2
  24. package/dist/compositions/sidebar/sidebar-content.d.ts +1 -1
  25. package/dist/compositions/sidebar/sidebar-content.js +3 -2
  26. package/dist/compositions/sidebar/sidebar-menu-sub.d.ts +1 -1
  27. package/dist/compositions/sidebar/sidebar-menu-sub.js +2 -1
  28. package/dist/compositions/tag-selector/tag-selector-list.js +4 -3
  29. package/dist/data/docs-manifest.d.ts +1 -1
  30. package/dist/data/docs-manifest.js +137 -17
  31. package/package.json +5 -1
package/agent-docs.md CHANGED
@@ -14,7 +14,7 @@ Do and don't guidance for coding agents, one section per component, taken from `
14
14
  - Import icons through `Icon` and primitives through these components, never from `lucide-react` or `@base-ui/react` directly.
15
15
  - Check your work with the `materia-ui-lint` class scanner (`--all` for every rule). The Oxlint preset is `@instruments/materia-ui/lint/oxlint`.
16
16
 
17
- Documented components: 197
17
+ Documented components: 198
18
18
 
19
19
  ## AccordionRoot
20
20
 
@@ -88,7 +88,7 @@ Constrains its content to a fixed width-to-height ratio, sizing itself to the av
88
88
 
89
89
  AttributeCard UI component. Import from "@instruments/materia-ui/compositions/attribute-card".
90
90
 
91
- - Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead.
91
+ - Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead, and for a bare figure without a card, `Stat`.
92
92
  - Pass a Lucide component to `icon` (e.g. `RulerIcon`), not a rendered `<Icon/>` - it is handed to the internal `Icon` at a fixed `sm` size.
93
93
  - This is a preset composition of `Card`; restyle via the surrounding layout rather than reaching inside - for a bespoke layout compose `Card`/`CardContent` directly.
94
94
 
@@ -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
 
@@ -1780,6 +1782,15 @@ Orchestrates staggered entrance animations for child `StaggerItem` components. U
1780
1782
  - Tune rhythm with `stagger` (gap between items) and `delay` (lead-in) on the parent, rather than hand-setting per-child transition delays.
1781
1783
  - Needs `ReducedMotionProvider` in the tree so the orchestrated entrance both runs and honours the reduced-motion setting.
1782
1784
 
1785
+ ## Stat
1786
+
1787
+ `@instruments/materia-ui/components/stat`
1788
+
1789
+ A figure, what it measures and where it comes from, value first. The component owns the type of all three, so a page's figures match without setting a size or weight at the call site. Import from "@instruments/materia-ui/components/stat".
1790
+
1791
+ - Use `Stat` for a bare figure in running layout (a row of results, a cover, beside a chart); for a label-first tile in its own card reach for `AttributeCard`.
1792
+ - Keep `value` to the figure alone ("24", "1,284", "3–5") and put the sentence in `label`; `note` is for the source or date window, not a second label.
1793
+
1783
1794
  ## Stepper
1784
1795
 
1785
1796
  `@instruments/materia-ui/components/stepper`
@@ -1887,7 +1898,7 @@ Renders a toggle switch for boolean on/off states.
1887
1898
 
1888
1899
  `@instruments/materia-ui/components/table`
1889
1900
 
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.
1901
+ 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
1902
 
1892
1903
  - 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
1904
  - 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 }) }));
@@ -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,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, };
@@ -0,0 +1,32 @@
1
+ import type * as React from "react";
2
+ /** The sizes a Stat comes in: `base` sits among other content, `lg` leads a page or a section. */
3
+ export declare const STAT_SIZES: readonly ["base", "lg"];
4
+ /** A size a Stat comes in; see {@link STAT_SIZES}. */
5
+ export type StatSize = (typeof STAT_SIZES)[number];
6
+ interface StatProps {
7
+ /** CSS class name */
8
+ className?: string;
9
+ /** What the value measures, set under it. */
10
+ label?: React.ReactNode;
11
+ /** Where the value comes from, set small and muted under the label. */
12
+ note?: React.ReactNode;
13
+ /**
14
+ * `base` for a figure among other content (a table cell, a side panel); `lg` for one that leads a page or section.
15
+ * @default "base"
16
+ */
17
+ size?: StatSize;
18
+ /** The figure itself: a number, a percentage, a ratio. */
19
+ value: React.ReactNode;
20
+ }
21
+ /**
22
+ * A figure, what it measures and where it comes from, value first. The component owns the type of all three, so a
23
+ * page's figures match without setting a size or weight at the call site.
24
+ * Import from "@instruments/materia-ui/components/stat".
25
+ *
26
+ * @bestPractice Use `Stat` for a bare figure in running layout (a row of results, a cover, beside a chart); for a label-first tile in its own card reach for `AttributeCard`.
27
+ * @bestPractice Keep `value` to the figure alone ("24", "1,284", "3–5") and put the sentence in `label`; `note` is for the source or date window, not a second label.
28
+ * @category data
29
+ */
30
+ declare const Stat: ({ value, label, note, size, className }: StatProps) => React.JSX.Element;
31
+ export type { StatProps };
32
+ export { Stat };
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "@instruments/materia-ui/utils/cn";
3
+ import { Stack } from "../stack.js";
4
+ import { Text } from "../text.js";
5
+ /** The sizes a Stat comes in: `base` sits among other content, `lg` leads a page or a section. */
6
+ export const STAT_SIZES = ["base", "lg"];
7
+ /** The value's role at each size. `heading-lg` is past the sizes Text takes, so the value sets its own role. */
8
+ const VALUE_CLASSES = {
9
+ base: "text-subheading-lg",
10
+ lg: "text-heading-lg",
11
+ };
12
+ /** The label's role at each size. */
13
+ const LABEL_SIZES = {
14
+ base: "paragraph-sm",
15
+ lg: "paragraph",
16
+ };
17
+ const isSet = (node) => node !== undefined && node !== null && node !== "";
18
+ /**
19
+ * A figure, what it measures and where it comes from, value first. The component owns the type of all three, so a
20
+ * page's figures match without setting a size or weight at the call site.
21
+ * Import from "@instruments/materia-ui/components/stat".
22
+ *
23
+ * @bestPractice Use `Stat` for a bare figure in running layout (a row of results, a cover, beside a chart); for a label-first tile in its own card reach for `AttributeCard`.
24
+ * @bestPractice Keep `value` to the figure alone ("24", "1,284", "3–5") and put the sentence in `label`; `note` is for the source or date window, not a second label.
25
+ * @category data
26
+ */
27
+ const Stat = ({ value, label, note, size = "base", className }) => (_jsxs(Stack, { className: className, "data-component": "stat", "data-slot": "stat", gap: "xs", children: [_jsx("p", { className: cn("trim-both font-medium text-pretty", VALUE_CLASSES[size]), "data-slot": "stat-value", children: value }), isSet(label) && (_jsx(Text, { "data-slot": "stat-label", size: LABEL_SIZES[size], children: label })), isSet(note) && (_jsx(Text, { "data-slot": "stat-note", size: "caption", variant: "muted", children: note }))] }));
28
+ export { Stat };
@@ -0,0 +1,2 @@
1
+ export type { StatProps, StatSize } from "./stat/stat-root.js";
2
+ export { STAT_SIZES, Stat } from "./stat/stat-root.js";
@@ -0,0 +1 @@
1
+ export { STAT_SIZES, Stat } from "./stat/stat-root.js";
@@ -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
  /**
@@ -16,7 +16,7 @@ interface AttributeCardProps {
16
16
  * AttributeCard UI component.
17
17
  * Import from "@instruments/materia-ui/compositions/attribute-card".
18
18
  *
19
- * @bestPractice Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead.
19
+ * @bestPractice Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead, and for a bare figure without a card, `Stat`.
20
20
  * @bestPractice Pass a Lucide component to `icon` (e.g. `RulerIcon`), not a rendered `<Icon/>` - it is handed to the internal `Icon` at a fixed `sm` size.
21
21
  * @bestPractice This is a preset composition of `Card`; restyle via the surrounding layout rather than reaching inside - for a bespoke layout compose `Card`/`CardContent` directly.
22
22
  * @category catalogue
@@ -7,7 +7,7 @@ import { Icon } from "../components/icon.js";
7
7
  * AttributeCard UI component.
8
8
  * Import from "@instruments/materia-ui/compositions/attribute-card".
9
9
  *
10
- * @bestPractice Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead.
10
+ * @bestPractice Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead, and for a bare figure without a card, `Stat`.
11
11
  * @bestPractice Pass a Lucide component to `icon` (e.g. `RulerIcon`), not a rendered `<Icon/>` - it is handed to the internal `Icon` at a fixed `sm` size.
12
12
  * @bestPractice This is a preset composition of `Card`; restyle via the surrounding layout rather than reaching inside - for a bespoke layout compose `Card`/`CardContent` directly.
13
13
  * @category catalogue
@@ -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 }) }) }) })] }));
@@ -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 @@ 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";
@@ -5,6 +5,7 @@ import { Dot } from "@instruments/materia-ui/components/dot";
5
5
  import { Flex } from "@instruments/materia-ui/components/flex";
6
6
  import { Icon } from "@instruments/materia-ui/components/icon";
7
7
  import { MenuItem } from "@instruments/materia-ui/components/menu-item";
8
+ import { ScrollFrame } from "@instruments/materia-ui/components/scroll-frame";
8
9
  import { cn } from "@instruments/materia-ui/utils/cn";
9
10
  import { isString } from "../../utils/type-guards.js";
10
11
  /** The `<ul>` container for a nested submenu under a menu item; hidden when the sidebar collapses to an icon rail. */
@@ -13,7 +14,7 @@ export const SidebarMenuSub = ({ className, ...props }) => (_jsx(Flex, { as: "ul
13
14
  * Scroll viewport for sidebar submenu content inside floating surfaces.
14
15
  * Keeps menu list scrolling, scrollbar styling, and bottom fade treatment centralised.
15
16
  */
16
- export const SidebarMenuSubScrollArea = ({ children, className, fade = false, maxHeight = "none", ...props }) => (_jsxs("div", { className: cn("relative min-h-0", className), "data-component": "sidebar-menu-sub-scroll-area", "data-slot": "sidebar-menu-sub-scroll-area", ...props, children: [_jsx("div", { className: cn("scrollbar-menu overflow-x-hidden overflow-y-auto", maxHeight === "dropdown" && "max-h-menu-sidebar-list"), children: children }), fade ? (_jsx("div", { "aria-hidden": true, className: "to-popover pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-b from-transparent" })) : null] }));
17
+ export const SidebarMenuSubScrollArea = ({ children, className, fade = false, maxHeight = "none", ...props }) => (_jsx(ScrollFrame, { ...props, className: cn("min-h-0", maxHeight === "dropdown" && "max-h-menu-sidebar-list", className), "data-component": "sidebar-menu-sub-scroll-area", "data-slot": "sidebar-menu-sub-scroll-area", fadeSize: "3xl", fadeTheme: "popover", fades: fade ? "end" : "none", children: children }));
17
18
  /** The `<li>` wrapper for a single submenu row. */
18
19
  export const SidebarMenuSubItem = ({ className, ...props }) => (_jsx("li", { className: cn("group/menu-sub-item relative", className), "data-component": "sidebar-menu-sub-item", "data-sidebar": "menu-sub-item", "data-slot": "sidebar-menu-sub-item", ...props }));
19
20
  /**
@@ -7,6 +7,7 @@ import { Plus } from "lucide-react";
7
7
  import { useMemo } from "react";
8
8
  import { Button } from "../../components/button.js";
9
9
  import { Flex } from "../../components/flex.js";
10
+ import { ScrollFrame } from "../../components/scroll-frame.js";
10
11
  import { TagSelectorColorSwatches } from "../tag-selector/tag-selector-color-swatches.js";
11
12
  import { useTagSelectorContext } from "../tag-selector/tag-selector-context.js";
12
13
  import { TagSelectorItem } from "../tag-selector/tag-selector-item.js";
@@ -94,7 +95,7 @@ export const TagSelectorList = ({ emptyMessage = "No tags found.", className, })
94
95
  const exactMatch = tags.some((tag) => tag.label.toLowerCase() === searchQuery.toLowerCase());
95
96
  const showCreateOption = allowCreate && searchQuery !== "" && !exactMatch;
96
97
  const selectedIds = new Set(value.map((t) => t.id));
97
- return (_jsx(Flex, { className: cn("max-h-75 overflow-y-auto p-1", className), "data-component": "tag-selector-list", "data-slot": "tag-selector-list", direction: "column", id: listboxId,
98
- // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, react-doctor/prefer-tag-over-role -- this is a custom listbox widget whose children are interactive TagSelectorItem rows, a drag-to-reorder DndContext, and a "Create" affordance with a colour palette; <datalist>/<select> can only hold plain <option>s, so they cannot represent this content.
99
- role: "listbox", children: filteredTags.length === 0 && !showCreateOption ? (_jsx("div", { className: "text-muted-foreground text-paragraph-sm py-6 text-center", children: emptyMessage })) : (_jsxs(_Fragment, { children: [showCreateOption && _jsx(TagSelectorCreateOption, {}), _jsx(TagSelectorItems, { filteredTags: filteredTags, selectedIds: selectedIds })] })) }));
98
+ return (_jsx(ScrollFrame, { className: cn("max-h-75", className), fadeTheme: "popover", fades: "end", children: _jsx(Flex, { className: "p-1", "data-component": "tag-selector-list", "data-slot": "tag-selector-list", direction: "column", id: listboxId,
99
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role, react-doctor/prefer-tag-over-role -- this is a custom listbox widget whose children are interactive TagSelectorItem rows, a drag-to-reorder DndContext, and a "Create" affordance with a colour palette; <datalist>/<select> can only hold plain <option>s, so they cannot represent this content.
100
+ role: "listbox", children: filteredTags.length === 0 && !showCreateOption ? (_jsx("div", { className: "text-muted-foreground text-paragraph-sm py-6 text-center", children: emptyMessage })) : (_jsxs(_Fragment, { children: [showCreateOption && _jsx(TagSelectorCreateOption, {}), _jsx(TagSelectorItems, { filteredTags: filteredTags, selectedIds: selectedIds })] })) }) }));
100
101
  };
@@ -74,5 +74,5 @@ export interface DocsManifest {
74
74
  storybook: DocsStorybookInventory;
75
75
  }
76
76
  /** The slug of every documented component and composition. */
77
- export type DocsUnitSlug = "accordion" | "alert" | "alert-dialog" | "anchor" | "animated-number" | "aspect-ratio" | "attribute-card" | "avatar" | "badge" | "border-beam" | "box" | "breadcrumb" | "button" | "button-group" | "calendar" | "card" | "card-grid" | "carousel" | "center" | "chart" | "checkbox" | "checkbox-card" | "checkbox-field" | "code" | "collapsible" | "color-palette" | "color-wheel" | "combobox" | "command" | "container" | "context-menu" | "cookie-banner" | "country-combobox" | "country-select" | "data-grid" | "data-grid-column-filter" | "data-grid-column-header" | "data-grid-column-visibility" | "data-grid-pagination" | "data-grid-table" | "delivery-banner" | "description-list" | "dialog" | "document-list" | "dot" | "dot-matrix-loader" | "drawer" | "dropdown-menu" | "easing-curve" | "editorial-card" | "empty" | "empty-state" | "error-boundary" | "error-state" | "expand-button" | "expandable-text" | "fade-overlay" | "field" | "file-upload" | "flex" | "folder-tile" | "gallery" | "grid" | "group" | "heading" | "hotspot-indicator" | "hover-card" | "icon" | "icon-box" | "icon-card" | "icon-morph" | "identified-surface" | "input" | "input-card" | "input-group" | "input-otp" | "item" | "kbd" | "label" | "loading-state" | "location-input" | "logo" | "logotype" | "marquee-text" | "media-frame" | "mega-menu" | "menu-item" | "menubar" | "mobile-navigation" | "month-year-picker" | "morph-dialog" | "motion-config" | "native-select" | "navigation-menu" | "page-header" | "page-toolbar" | "pagination" | "phone-input" | "pill" | "pixel-loader" | "popover" | "product-card" | "product-specifications-selector" | "progress" | "progress-dots" | "prompt-panel" | "quantity-control" | "quick-view" | "radio" | "radio-card" | "radio-group" | "requirement-item" | "resizable" | "scrim" | "scroll-area" | "scroll-fade" | "scroll-frame" | "search-input" | "section" | "section-header" | "segmented-control" | "select" | "separator" | "settings-card" | "sheet" | "shimmer" | "shine" | "sidebar" | "sidepanel" | "simple-grid" | "site-footer" | "skeleton" | "slider" | "social-icon" | "space" | "spinner" | "stack" | "stagger" | "stepper" | "streaming-text" | "sub-navigation" | "success-tick-ripple" | "swatch" | "swatch-fan" | "swatch-group" | "swatch-selector" | "switch" | "tab-navigation" | "table" | "tabs" | "tag" | "tag-selector" | "text" | "textarea" | "thinking-loader" | "thinking-orb" | "thinking-trace" | "time-slot-picker" | "toast" | "toggle" | "toggle-group" | "tooltip" | "variant-selector" | "verge" | "visually-hidden" | "voice";
77
+ export type DocsUnitSlug = "accordion" | "alert" | "alert-dialog" | "anchor" | "animated-number" | "aspect-ratio" | "attribute-card" | "avatar" | "badge" | "border-beam" | "box" | "breadcrumb" | "button" | "button-group" | "calendar" | "card" | "card-grid" | "carousel" | "center" | "chart" | "checkbox" | "checkbox-card" | "checkbox-field" | "code" | "collapsible" | "color-palette" | "color-wheel" | "combobox" | "command" | "container" | "context-menu" | "cookie-banner" | "country-combobox" | "country-select" | "data-grid" | "data-grid-column-filter" | "data-grid-column-header" | "data-grid-column-visibility" | "data-grid-pagination" | "data-grid-table" | "delivery-banner" | "description-list" | "dialog" | "document-list" | "dot" | "dot-matrix-loader" | "drawer" | "dropdown-menu" | "easing-curve" | "editorial-card" | "empty" | "empty-state" | "error-boundary" | "error-state" | "expand-button" | "expandable-text" | "fade-overlay" | "field" | "file-upload" | "flex" | "folder-tile" | "gallery" | "grid" | "group" | "heading" | "hotspot-indicator" | "hover-card" | "icon" | "icon-box" | "icon-card" | "icon-morph" | "identified-surface" | "input" | "input-card" | "input-group" | "input-otp" | "item" | "kbd" | "label" | "loading-state" | "location-input" | "logo" | "logotype" | "marquee-text" | "media-frame" | "mega-menu" | "menu-item" | "menubar" | "mobile-navigation" | "month-year-picker" | "morph-dialog" | "motion-config" | "native-select" | "navigation-menu" | "page-header" | "page-toolbar" | "pagination" | "phone-input" | "pill" | "pixel-loader" | "popover" | "product-card" | "product-specifications-selector" | "progress" | "progress-dots" | "prompt-panel" | "quantity-control" | "quick-view" | "radio" | "radio-card" | "radio-group" | "requirement-item" | "resizable" | "scrim" | "scroll-area" | "scroll-fade" | "scroll-frame" | "search-input" | "section" | "section-header" | "segmented-control" | "select" | "separator" | "settings-card" | "sheet" | "shimmer" | "shine" | "sidebar" | "sidepanel" | "simple-grid" | "site-footer" | "skeleton" | "slider" | "social-icon" | "space" | "spinner" | "stack" | "stagger" | "stat" | "stepper" | "streaming-text" | "sub-navigation" | "success-tick-ripple" | "swatch" | "swatch-fan" | "swatch-group" | "swatch-selector" | "switch" | "tab-navigation" | "table" | "tabs" | "tag" | "tag-selector" | "text" | "textarea" | "thinking-loader" | "thinking-orb" | "thinking-trace" | "time-slot-picker" | "toast" | "toggle" | "toggle-group" | "tooltip" | "variant-selector" | "verge" | "visually-hidden" | "voice";
78
78
  export declare const docsManifest: DocsManifest;
@@ -2403,7 +2403,7 @@ export const docsManifest = {
2403
2403
  type: "boolean",
2404
2404
  required: false,
2405
2405
  defaultValue: "false",
2406
- description: "Render a multi-line block (`<pre><code>`) instead of inline `<code>`. The\nblock scrolls horizontally rather than wrapping.",
2406
+ description: "Render a multi-line block (`<pre><code>`) instead of inline `<code>`. The\nblock scrolls horizontally, in a `ScrollFrame`, rather than wrapping.",
2407
2407
  },
2408
2408
  ],
2409
2409
  },
@@ -2418,7 +2418,7 @@ export const docsManifest = {
2418
2418
  "`block` renders `<pre><code>` that scrolls horizontally; add `whitespace-pre-wrap` via `className` only when wrapping beats scrolling (long prose-like messages).",
2419
2419
  ],
2420
2420
  keywords: [],
2421
- uses: [],
2421
+ uses: ["scroll-frame"],
2422
2422
  usedBy: [],
2423
2423
  },
2424
2424
  {
@@ -2634,7 +2634,7 @@ export const docsManifest = {
2634
2634
  "Provide `items` as `{ label, value }[]` - the visible text comes from `label` while selection is tracked by `value`.",
2635
2635
  ],
2636
2636
  keywords: ["autocomplete", "typeahead", "searchable select"],
2637
- uses: ["button", "flex", "icon"],
2637
+ uses: ["button", "flex", "icon", "scroll-frame"],
2638
2638
  usedBy: ["country-combobox"],
2639
2639
  },
2640
2640
  {
@@ -2845,7 +2845,7 @@ export const docsManifest = {
2845
2845
  "A plain click-triggered action menu is `DropdownMenu`; a remembered selection is `Combobox`.",
2846
2846
  ],
2847
2847
  keywords: ["command palette", "spotlight", "cmd+k", "quick search"],
2848
- uses: ["dialog", "flex", "icon"],
2848
+ uses: ["dialog", "flex", "icon", "scroll-frame"],
2849
2849
  usedBy: [],
2850
2850
  anatomy: {
2851
2851
  children: [
@@ -6914,7 +6914,7 @@ export const docsManifest = {
6914
6914
  'Pick `mode="city"` vs `"address"` to scope the geocoding; drive it controlled via the `Location` `value` + `onChange` (which also fires on clear).',
6915
6915
  ],
6916
6916
  keywords: [],
6917
- uses: ["button", "icon", "menu-item"],
6917
+ uses: ["button", "icon", "menu-item", "scroll-frame"],
6918
6918
  usedBy: [],
6919
6919
  },
6920
6920
  {
@@ -9480,6 +9480,12 @@ export const docsManifest = {
9480
9480
  type: "string | undefined",
9481
9481
  required: false,
9482
9482
  },
9483
+ {
9484
+ name: "contentClassName",
9485
+ type: "string | undefined",
9486
+ required: false,
9487
+ description: "Classes for the element holding the children, inside the scrolling one.\nA vertical frame's content is already as wide as the frame, so text wraps\nand `w-full` children fill it; a horizontal or two-way frame's content is\nas wide as its widest child.",
9488
+ },
9483
9489
  {
9484
9490
  name: "controls",
9485
9491
  type: "boolean | undefined",
@@ -9531,10 +9537,10 @@ export const docsManifest = {
9531
9537
  },
9532
9538
  {
9533
9539
  name: "orientation",
9534
- type: "Orientation | undefined",
9540
+ type: "ScrollFrameOrientation | undefined",
9535
9541
  required: false,
9536
9542
  defaultValue: '"vertical"',
9537
- description: "Scroll axis: `vertical` scrolls up and down, `horizontal` left and right, as for a row of chips.",
9543
+ description: "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.",
9538
9544
  },
9539
9545
  {
9540
9546
  name: "viewportClassName",
@@ -9542,6 +9548,12 @@ export const docsManifest = {
9542
9548
  required: false,
9543
9549
  description: "Classes for the scrolling element.",
9544
9550
  },
9551
+ {
9552
+ name: "viewportRef",
9553
+ type: "Ref<HTMLDivElement | null> | undefined",
9554
+ required: false,
9555
+ description: "The scrolling element, for a virtualiser or anything else that reads or sets its scroll position.",
9556
+ },
9545
9557
  ],
9546
9558
  },
9547
9559
  {
@@ -9554,6 +9566,11 @@ export const docsManifest = {
9554
9566
  kind: "type",
9555
9567
  props: [],
9556
9568
  },
9569
+ {
9570
+ name: "ScrollFrameOrientation",
9571
+ kind: "type",
9572
+ props: [],
9573
+ },
9557
9574
  {
9558
9575
  name: "ScrollFrameProps",
9559
9576
  kind: "interface",
@@ -9565,10 +9582,25 @@ export const docsManifest = {
9565
9582
  '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.',
9566
9583
  'Match `fadeTheme` to the surface behind the frame (`card` inside a Card). Over an image or a blur, use `fadeMode="mask"` instead.',
9567
9584
  "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.",
9585
+ '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.',
9586
+ "Hand a virtualiser the scrolling element through `viewportRef`.",
9568
9587
  ],
9569
9588
  keywords: [],
9570
9589
  uses: ["button", "scroll-area", "scroll-fade"],
9571
- usedBy: ["page-toolbar"],
9590
+ usedBy: [
9591
+ "code",
9592
+ "combobox",
9593
+ "command",
9594
+ "data-grid-column-filter",
9595
+ "data-grid-table",
9596
+ "location-input",
9597
+ "mega-menu",
9598
+ "page-toolbar",
9599
+ "sidebar",
9600
+ "sidepanel",
9601
+ "table",
9602
+ "tag-selector",
9603
+ ],
9572
9604
  },
9573
9605
  {
9574
9606
  slug: "search-input",
@@ -10759,7 +10791,7 @@ export const docsManifest = {
10759
10791
  "Compose nested panels with `SidepanelNestedRoot`, and always render a `SidepanelTitle` for the accessible name.",
10760
10792
  ],
10761
10793
  keywords: [],
10762
- uses: ["button", "flex", "stack"],
10794
+ uses: ["button", "flex", "scroll-frame", "stack"],
10763
10795
  usedBy: [],
10764
10796
  anatomy: {
10765
10797
  children: [
@@ -11371,6 +11403,7 @@ export const docsManifest = {
11371
11403
  "sidepanel",
11372
11404
  "site-footer",
11373
11405
  "skeleton",
11406
+ "stat",
11374
11407
  ],
11375
11408
  },
11376
11409
  {
@@ -11433,6 +11466,74 @@ export const docsManifest = {
11433
11466
  uses: [],
11434
11467
  usedBy: [],
11435
11468
  },
11469
+ {
11470
+ slug: "stat",
11471
+ module: "components/stat",
11472
+ title: "Stat",
11473
+ category: "data",
11474
+ description: 'A figure, what it measures and where it comes from, value first. The component owns the type of all three, so a\npage\'s figures match without setting a size or weight at the call site.\nImport from "@instruments/materia-ui/components/stat".',
11475
+ exports: [
11476
+ {
11477
+ name: "Stat",
11478
+ kind: "component",
11479
+ props: [
11480
+ {
11481
+ name: "className",
11482
+ type: "string",
11483
+ required: false,
11484
+ description: "CSS class name",
11485
+ },
11486
+ {
11487
+ name: "label",
11488
+ type: "React.ReactNode",
11489
+ required: false,
11490
+ description: "What the value measures, set under it.",
11491
+ },
11492
+ {
11493
+ name: "note",
11494
+ type: "React.ReactNode",
11495
+ required: false,
11496
+ description: "Where the value comes from, set small and muted under the label.",
11497
+ },
11498
+ {
11499
+ name: "size",
11500
+ type: '"base" | "lg"',
11501
+ required: false,
11502
+ defaultValue: '"base"',
11503
+ description: "`base` for a figure among other content (a table cell, a side panel); `lg` for one that leads a page or section.",
11504
+ },
11505
+ {
11506
+ name: "value",
11507
+ type: "React.ReactNode",
11508
+ required: true,
11509
+ description: "The figure itself: a number, a percentage, a ratio.",
11510
+ },
11511
+ ],
11512
+ },
11513
+ {
11514
+ name: "STAT_SIZES",
11515
+ kind: "const",
11516
+ props: [],
11517
+ },
11518
+ {
11519
+ name: "StatProps",
11520
+ kind: "interface",
11521
+ props: [],
11522
+ },
11523
+ {
11524
+ name: "StatSize",
11525
+ kind: "type",
11526
+ props: [],
11527
+ },
11528
+ ],
11529
+ bestPractices: [
11530
+ "Use `Stat` for a bare figure in running layout (a row of results, a cover, beside a chart); for a label-first tile in its own card reach for `AttributeCard`.",
11531
+ 'Keep `value` to the figure alone ("24", "1,284", "3–5") and put the sentence in `label`; `note` is for the source or date window, not a second label.',
11532
+ ],
11533
+ keywords: [],
11534
+ uses: ["stack", "text"],
11535
+ usedBy: [],
11536
+ },
11436
11537
  {
11437
11538
  slug: "stepper",
11438
11539
  module: "components/stepper",
@@ -12334,7 +12435,7 @@ export const docsManifest = {
12334
12435
  module: "components/table",
12335
12436
  title: "Table",
12336
12437
  category: "data",
12337
- description: "Semantic `<table>` primitive, wrapped in a horizontally scrollable container\nso wide tables overflow rather than break layout. Compose with the exported\n`TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`,\n`TableFooter`, and `TableCaption` parts.",
12438
+ description: "Semantic `<table>` primitive, wrapped in a horizontal `ScrollFrame`\nso wide tables overflow rather than break layout. Compose with the exported\n`TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`,\n`TableFooter`, and `TableCaption` parts.",
12338
12439
  exports: [
12339
12440
  {
12340
12441
  name: "Table",
@@ -12383,7 +12484,7 @@ export const docsManifest = {
12383
12484
  "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.",
12384
12485
  ],
12385
12486
  keywords: [],
12386
- uses: [],
12487
+ uses: ["scroll-frame"],
12387
12488
  usedBy: [],
12388
12489
  },
12389
12490
  {
@@ -12807,6 +12908,7 @@ export const docsManifest = {
12807
12908
  "section-header",
12808
12909
  "site-footer",
12809
12910
  "slider",
12911
+ "stat",
12810
12912
  "streaming-text",
12811
12913
  "thinking-trace",
12812
12914
  ],
@@ -13844,7 +13946,7 @@ export const docsManifest = {
13844
13946
  },
13845
13947
  ],
13846
13948
  bestPractices: [
13847
- "Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead.",
13949
+ "Use `AttributeCard` for a single label + prominent value stat tile (a KPI/metric); for many key/value pairs about one entity reach for `DescriptionList` instead, and for a bare figure without a card, `Stat`.",
13848
13950
  "Pass a Lucide component to `icon` (e.g. `RulerIcon`), not a rendered `<Icon/>` - it is handed to the internal `Icon` at a fixed `sm` size.",
13849
13951
  "This is a preset composition of `Card`; restyle via the surrounding layout rather than reaching inside - for a bespoke layout compose `Card`/`CardContent` directly.",
13850
13952
  ],
@@ -14811,7 +14913,15 @@ export const docsManifest = {
14811
14913
  "The typed search survives each pick, so several matches can be ticked from one query; it resets when the popup closes.",
14812
14914
  ],
14813
14915
  keywords: [],
14814
- uses: ["badge", "button", "center", "flex", "icon", "separator"],
14916
+ uses: [
14917
+ "badge",
14918
+ "button",
14919
+ "center",
14920
+ "flex",
14921
+ "icon",
14922
+ "scroll-frame",
14923
+ "separator",
14924
+ ],
14815
14925
  usedBy: [],
14816
14926
  },
14817
14927
  {
@@ -15275,7 +15385,7 @@ export const docsManifest = {
15275
15385
  "Use `DataGridTable` for a data-driven grid; for a small static table with hand-authored `<TableRow>`s reach for `Table` instead - no virtualisation or TanStack instance needed.",
15276
15386
  ],
15277
15387
  keywords: [],
15278
- uses: ["center", "data-grid", "flex", "spinner"],
15388
+ uses: ["center", "data-grid", "flex", "scroll-frame", "spinner"],
15279
15389
  usedBy: [],
15280
15390
  },
15281
15391
  {
@@ -16728,7 +16838,15 @@ export const docsManifest = {
16728
16838
  "Keep it to navigation. Every entry is a `MegaMenuLink`, which closes the menu as it navigates; actions belong in a `DropdownMenu`.",
16729
16839
  ],
16730
16840
  keywords: [],
16731
- uses: ["anchor", "box", "grid", "heading", "popover", "stack"],
16841
+ uses: [
16842
+ "anchor",
16843
+ "box",
16844
+ "grid",
16845
+ "heading",
16846
+ "popover",
16847
+ "scroll-frame",
16848
+ "stack",
16849
+ ],
16732
16850
  usedBy: [],
16733
16851
  },
16734
16852
  {
@@ -21963,7 +22081,7 @@ export const docsManifest = {
21963
22081
  type: "boolean",
21964
22082
  required: false,
21965
22083
  defaultValue: "false",
21966
- description: "Add a bottom gradient fade hinting at scrollable overflow.",
22084
+ description: "Fade the bottom edge while more of the list is below.",
21967
22085
  },
21968
22086
  {
21969
22087
  name: "maxHeight",
@@ -22277,6 +22395,7 @@ export const docsManifest = {
22277
22395
  "input",
22278
22396
  "kbd",
22279
22397
  "menu-item",
22398
+ "scroll-frame",
22280
22399
  "separator",
22281
22400
  "sheet",
22282
22401
  "skeleton",
@@ -23275,6 +23394,7 @@ export const docsManifest = {
23275
23394
  "popover",
23276
23395
  "scroll-area",
23277
23396
  "scroll-fade",
23397
+ "scroll-frame",
23278
23398
  "tag",
23279
23399
  ],
23280
23400
  usedBy: [],
@@ -23784,7 +23904,7 @@ export const docsManifest = {
23784
23904
  storybook: {
23785
23905
  addons: ["@storybook/addon-docs", "@storybook/react-vite"],
23786
23906
  anatomyFiles: 31,
23787
- storyFiles: 172,
23907
+ storyFiles: 173,
23788
23908
  storiesWithPlay: 63,
23789
23909
  },
23790
23910
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instruments/materia-ui",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Materia design system: React components, tokens and Tailwind v4 styles.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -462,6 +462,10 @@
462
462
  "types": "./dist/components/stagger.d.ts",
463
463
  "default": "./dist/components/stagger.js"
464
464
  },
465
+ "./components/stat": {
466
+ "types": "./dist/components/stat.d.ts",
467
+ "default": "./dist/components/stat.js"
468
+ },
465
469
  "./components/stepper": {
466
470
  "types": "./dist/components/stepper.d.ts",
467
471
  "default": "./dist/components/stepper.js"