@instruments/materia-ui 0.1.0 → 0.2.0

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 (30) hide show
  1. package/agent-docs.md +4 -3
  2. package/dist/components/button/button-variants.js +53 -8
  3. package/dist/components/button-group/button-group-root.js +8 -6
  4. package/dist/components/calendar/calendar-root.js +39 -13
  5. package/dist/components/chart/chart-tooltip.js +7 -5
  6. package/dist/components/empty/empty-media.js +5 -5
  7. package/dist/components/input-group/input-group-textarea.d.ts +3 -2
  8. package/dist/components/input-group/input-group-textarea.js +8 -4
  9. package/dist/components/pill/pill-variants.js +21 -10
  10. package/dist/components/progress/progress-root.d.ts +7 -5
  11. package/dist/components/progress/progress-root.js +26 -5
  12. package/dist/components/radio-group/radio-group-root.d.ts +7 -3
  13. package/dist/components/radio-group/radio-group-root.js +14 -4
  14. package/dist/components/textarea/textarea-root.d.ts +1 -1
  15. package/dist/components/textarea/textarea-root.js +6 -9
  16. package/dist/components/textarea/use-textarea-rows.d.ts +22 -0
  17. package/dist/components/textarea/use-textarea-rows.js +43 -0
  18. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.d.ts +8 -6
  19. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.js +11 -20
  20. package/dist/compositions/product-card/product-card-color-count.js +26 -26
  21. package/dist/compositions/product-card/product-card-compact-arm.js +2 -2
  22. package/dist/compositions/product-card/product-card-content-types.d.ts +5 -0
  23. package/dist/compositions/product-card/product-card-full-arm.js +2 -2
  24. package/dist/compositions/product-card/product-card-provider-types.d.ts +10 -0
  25. package/dist/compositions/product-card/product-card-provider.js +1 -1
  26. package/dist/compositions/product-card/product-card-swatch-selector.js +4 -3
  27. package/dist/data/docs-manifest.js +24 -24
  28. package/dist/styles/index.css +17 -6
  29. package/lint/tokens.mjs +1 -0
  30. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { cn } from "@instruments/materia-ui/utils/cn";
6
6
  import { hasErrorInput } from "../../tokens/has-error-input.js";
7
7
  import { INPUT_SHELL } from "../../tokens/input-shell.js";
8
8
  import { useFieldContext } from "../field/field-context.js";
9
+ import { useTextareaRows } from "./use-textarea-rows.js";
9
10
  /**
10
11
  * Renders a textarea element for multi-line text input (Base UI
11
12
  * `Field.Control` rendering a `<textarea>`). Inside a Field it is the field's
@@ -26,21 +27,17 @@ import { useFieldContext } from "../field/field-context.js";
26
27
  * ```
27
28
  *
28
29
  * @bestPractice Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
29
- * @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for an initial height rather than pinning height via CSS.
30
+ * @bestPractice It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
30
31
  * @bestPractice Give it a label - inside a `Field` a `FieldLabel` targets it with no ids to wire; standalone, point a `Label`'s `htmlFor` at its `id`. A placeholder is not an accessible name.
31
32
  * @category forms
32
33
  */
33
34
  const Textarea = ({ className, testId, hasError, id, name, disabled, rows, style, ...props }) => {
34
35
  const field = useFieldContext();
35
36
  const resolvedError = hasError ?? field?.invalid ?? false;
36
- // `field-sizing-content` sizes the box to its text and ignores `rows`, so
37
- // `rows` sets the starting height instead: that many lines plus the py-2
38
- // padding and the 1px border, before the box grows with its content.
39
- const sizedStyle = rows === undefined
40
- ? style
41
- : { minHeight: `calc(${rows}lh + 1rem + 2px)`, ...style };
37
+ const { className: sizingClassName, ref: sizingRef, style: sizingStyle, } = useTextareaRows(rows);
42
38
  // The control owns id/name/disabled so Field can label and validate it;
43
- // textarea-only props ride on the rendered element.
44
- return (_jsx(FieldPrimitive.Control, { render: _jsx("textarea", { className: cn(focusInput(), `flex field-sizing-content min-h-16 w-full ${INPUT_SHELL} placeholder:text-muted-foreground transition-color-shadow text-paragraph md:text-paragraph-sm px-3 py-2 shadow-xs disabled:cursor-not-allowed disabled:opacity-50`, hasErrorInput, className), "data-component": "textarea", "data-slot": "textarea", rows: rows, style: sizedStyle, ...(hasError === true ? { "aria-invalid": true } : {}), ...(resolvedError ? { "data-error": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(disabled === undefined ? {} : { disabled }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
39
+ // textarea-only props ride on the rendered element. The sizing ref sits on
40
+ // the control so a caller's own `ref` on the textarea still lands.
41
+ return (_jsx(FieldPrimitive.Control, { ref: sizingRef, render: _jsx("textarea", { className: cn(focusInput(), `flex w-full ${INPUT_SHELL} placeholder:text-muted-foreground transition-color-shadow text-paragraph md:text-paragraph-sm px-3 py-2 shadow-xs disabled:cursor-not-allowed disabled:opacity-50`, sizingClassName, hasErrorInput, className), "data-component": "textarea", "data-slot": "textarea", rows: rows, style: { ...sizingStyle, ...style }, ...(hasError === true ? { "aria-invalid": true } : {}), ...(resolvedError ? { "data-error": true } : {}), ...(testId === undefined ? {} : { "data-testid": testId }), ...props }), ...(disabled === undefined ? {} : { disabled }), ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) }));
45
42
  };
46
43
  export { Textarea };
@@ -0,0 +1,22 @@
1
+ import type { CSSProperties, RefCallback } from "react";
2
+ interface TextareaRowsSizing {
3
+ /** Sizing classes: `field-sizing-content`, plus `min-h-16` when there are no `rows`. */
4
+ className: string;
5
+ /** Goes on the textarea, to read back its padding and border once mounted. */
6
+ ref: RefCallback<HTMLTextAreaElement>;
7
+ /** The `rows`-high minimum, once the edges are known. */
8
+ style: CSSProperties | undefined;
9
+ }
10
+ /**
11
+ * Lets a textarea grow with its text (`field-sizing: content`) without
12
+ * losing `rows`, which content sizing ignores in every browser.
13
+ *
14
+ * Until it mounts, a textarea with `rows` is left to the browser, which
15
+ * sizes it from `rows` with whatever padding and border it has. Once
16
+ * mounted, those edges are read back from its computed style, the minimum
17
+ * height becomes `rows` lines plus them, and content sizing takes over to
18
+ * grow it beyond that. The `lh` unit keeps the minimum in step when the line
19
+ * height changes at a breakpoint.
20
+ */
21
+ export declare const useTextareaRows: (rows: number | undefined) => TextareaRowsSizing;
22
+ export {};
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { useCallback, useState } from "react";
3
+ /**
4
+ * Lets a textarea grow with its text (`field-sizing: content`) without
5
+ * losing `rows`, which content sizing ignores in every browser.
6
+ *
7
+ * Until it mounts, a textarea with `rows` is left to the browser, which
8
+ * sizes it from `rows` with whatever padding and border it has. Once
9
+ * mounted, those edges are read back from its computed style, the minimum
10
+ * height becomes `rows` lines plus them, and content sizing takes over to
11
+ * grow it beyond that. The `lh` unit keeps the minimum in step when the line
12
+ * height changes at a breakpoint.
13
+ */
14
+ export const useTextareaRows = (rows) => {
15
+ const [edges, setEdges] = useState();
16
+ const ref = useCallback((element) => {
17
+ if (element === null || rows === undefined) {
18
+ return;
19
+ }
20
+ const style = getComputedStyle(element);
21
+ setEdges([
22
+ style.paddingTop,
23
+ style.paddingBottom,
24
+ style.borderTopWidth,
25
+ style.borderBottomWidth,
26
+ ].join(" + "));
27
+ }, [rows]);
28
+ if (rows === undefined) {
29
+ return {
30
+ className: "field-sizing-content min-h-16",
31
+ ref,
32
+ style: undefined,
33
+ };
34
+ }
35
+ if (edges === undefined) {
36
+ return { className: "", ref, style: undefined };
37
+ }
38
+ return {
39
+ className: "field-sizing-content",
40
+ ref,
41
+ style: { minHeight: `calc(${rows}lh + ${edges})` },
42
+ };
43
+ };
@@ -13,9 +13,16 @@ interface DataGridPaginationProps {
13
13
  info?: string;
14
14
  /** Placeholder rendered in place of the range summary while loading. */
15
15
  infoSkeleton?: ReactNode;
16
+ /**
17
+ * Collapse the page buttons into groups of `moreLimit`, with ellipsis
18
+ * buttons that jump to the next or previous group. Off shows a button for
19
+ * every page.
20
+ * @default true
21
+ */
16
22
  more?: boolean;
17
23
  /**
18
- * Number of page buttons shown before collapsing to an ellipsis.
24
+ * Number of page buttons shown before collapsing to an ellipsis. Needs
25
+ * `more`.
19
26
  * @default 5
20
27
  */
21
28
  moreLimit?: number;
@@ -39,11 +46,6 @@ interface DataGridPaginationProps {
39
46
  * @default [5, 10, 25, 50, 100]
40
47
  */
41
48
  sizes?: number[];
42
- /** @default "per page" */
43
- sizesDescription?: string;
44
- sizesInfo?: string;
45
- /** @default "Show" */
46
- sizesLabel?: string;
47
49
  /** Placeholder rendered in place of the rows-per-page select while loading. */
48
50
  sizesSkeleton?: ReactNode;
49
51
  }
@@ -6,28 +6,17 @@ import { Skeleton } from "@instruments/materia-ui/components/skeleton";
6
6
  import { useDataGrid } from "@instruments/materia-ui/compositions/data-grid";
7
7
  import { cn } from "@instruments/materia-ui/utils/cn";
8
8
  import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
9
+ import { useId } from "react";
9
10
  const defaultProps = {
10
- /** Ellipsis text */
11
11
  ellipsisText: "...",
12
12
  info: "{from} - {to} of {count}",
13
- /** Info skeleton */
14
13
  infoSkeleton: _jsx(Skeleton, { className: "h-8 w-60" }),
15
- more: false,
16
- /** More limit */
14
+ more: true,
17
15
  moreLimit: 5,
18
- /** Next page label */
19
16
  nextPageLabel: "Go to next page",
20
- /** Previous page label */
21
17
  previousPageLabel: "Go to previous page",
22
- /** Rows per page label */
23
18
  rowsPerPageLabel: "Rows per page",
24
- /** Component size */
25
19
  sizes: [5, 10, 25, 50, 100],
26
- /** Component size */
27
- sizesDescription: "per page",
28
- /** Component size */
29
- sizesLabel: "Show",
30
- /** Component size */
31
20
  sizesSkeleton: _jsx(Skeleton, { className: "h-8 w-44" }),
32
21
  };
33
22
  const BTN_BASE_CLASSES = "size-7 p-0 text-paragraph-sm";
@@ -41,20 +30,22 @@ const formatPaginationInfo = (template, from, to, count) => template !== undefin
41
30
  : `${from} - ${to} of ${count}`;
42
31
  const RowsPerPageSelect = ({ rowsPerPageLabel, sizes, table, }) => {
43
32
  const { pageSize } = table.state.pagination;
44
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-muted-foreground text-paragraph-sm", children: rowsPerPageLabel }), _jsxs(Select, { onValueChange: (value) => {
33
+ const labelId = useId();
34
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-muted-foreground text-paragraph-sm", id: labelId, children: rowsPerPageLabel }), _jsxs(Select, { onValueChange: (value) => {
45
35
  const newPageSize = Number(value);
46
36
  table.setPageSize(newPageSize);
47
- }, value: `${pageSize}`, children: [_jsx(SelectTrigger, { className: "w-fit", size: "sm", children: _jsx(SelectValue, { placeholder: `${pageSize}` }) }), _jsx(SelectContent, { className: "min-w-12.5", side: "top", children: sizes?.map((size) => (_jsx(SelectItem, { value: `${size}`, children: size }, size))) })] })] }));
37
+ }, value: `${pageSize}`, children: [_jsx(SelectTrigger, { "aria-labelledby": labelId, className: "w-fit", size: "sm", children: _jsx(SelectValue, { placeholder: `${pageSize}` }) }), _jsx(SelectContent, { className: "min-w-12.5", side: "top", children: sizes?.map((size) => (_jsx(SelectItem, { value: `${size}`, children: size }, size))) })] })] }));
48
38
  };
49
- const PaginationNav = ({ ellipsisText, moreLimit, nextPageLabel, previousPageLabel, table, }) => {
39
+ const PaginationNav = ({ ellipsisText, more, moreLimit, nextPageLabel, previousPageLabel, table, }) => {
50
40
  const { pageIndex } = table.state.pagination;
51
41
  const pageCount = table.getPageCount();
52
42
  if (pageCount <= 1) {
53
43
  return null;
54
44
  }
55
- // Determine the start and end of the pagination group
56
- const currentGroupStart = Math.floor(pageIndex / moreLimit) * moreLimit;
57
- const currentGroupEnd = Math.min(currentGroupStart + moreLimit, pageCount);
45
+ // Without `more`, one group holds every page and no ellipsis renders.
46
+ const groupSize = more ? moreLimit : pageCount;
47
+ const currentGroupStart = Math.floor(pageIndex / groupSize) * groupSize;
48
+ const currentGroupEnd = Math.min(currentGroupStart + groupSize, pageCount);
58
49
  // Page indices for the current group
59
50
  const pageIndices = Array.from({ length: Math.max(0, currentGroupEnd - currentGroupStart) }, (_, idx) => currentGroupStart + idx);
60
51
  return (_jsxs(Flex, { align: "center", className: "order-1 space-x-1 sm:order-2", children: [_jsx(Button, { "aria-label": previousPageLabel, emphasis: "ghost", className: BTN_ARROW_CLASSES, disabled: !table.getCanPreviousPage(), icon: ChevronLeftIcon, onClick: () => {
@@ -91,6 +82,6 @@ const DataGridPagination = (props) => {
91
82
  const from = pageIndex * pageSize + 1;
92
83
  const to = Math.min((pageIndex + 1) * pageSize, recordCount);
93
84
  const paginationInfo = formatPaginationInfo(mergedProps.info, from, to, recordCount);
94
- return (_jsxs(Flex, { align: "center", className: cn("grow gap-2.5 py-2.5 sm:flex-row sm:py-0", mergedProps?.className), "data-component": "data-grid-pagination", "data-slot": "data-grid-pagination", direction: "column", justify: "space-between", wrap: "wrap", children: [_jsx(Flex, { align: "center", className: "order-2 space-x-2.5 pb-2.5 sm:order-1 sm:pb-0", wrap: "wrap", children: loading === true ? (mergedProps?.sizesSkeleton) : (_jsx(RowsPerPageSelect, { rowsPerPageLabel: mergedProps.rowsPerPageLabel, sizes: mergedProps.sizes, table: table })) }), _jsx(Flex, { align: "center", className: "order-1 gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0", direction: "column", justify: "center", children: loading === true ? (mergedProps?.infoSkeleton) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-muted-foreground text-paragraph-sm order-2 text-nowrap sm:order-1", children: paginationInfo }), _jsx(PaginationNav, { ellipsisText: mergedProps.ellipsisText, moreLimit: mergedProps?.moreLimit ?? 5, nextPageLabel: mergedProps.nextPageLabel, previousPageLabel: mergedProps.previousPageLabel, table: table })] })) })] }));
85
+ return (_jsxs(Flex, { align: "center", className: cn("grow gap-2.5 py-2.5 sm:flex-row sm:py-0", mergedProps?.className), "data-component": "data-grid-pagination", "data-slot": "data-grid-pagination", direction: "column", justify: "space-between", wrap: "wrap", children: [_jsx(Flex, { align: "center", className: "order-2 space-x-2.5 pb-2.5 sm:order-1 sm:pb-0", wrap: "wrap", children: loading === true ? (mergedProps?.sizesSkeleton) : (_jsx(RowsPerPageSelect, { rowsPerPageLabel: mergedProps.rowsPerPageLabel, sizes: mergedProps.sizes, table: table })) }), _jsx(Flex, { align: "center", className: "order-1 gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0", direction: "column", justify: "center", children: loading === true ? (mergedProps?.infoSkeleton) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-muted-foreground text-paragraph-sm order-2 text-nowrap sm:order-1", children: paginationInfo }), _jsx(PaginationNav, { ellipsisText: mergedProps.ellipsisText, more: mergedProps.more ?? true, moreLimit: mergedProps?.moreLimit ?? 5, nextPageLabel: mergedProps.nextPageLabel, previousPageLabel: mergedProps.previousPageLabel, table: table })] })) })] }));
95
86
  };
96
87
  export { DataGridPagination };
@@ -15,25 +15,24 @@ import { useProductCard } from "./product-card-context.js";
15
15
  * instantly where a count cannot.
16
16
  *
17
17
  * The design draws 16px dots on a 26px pitch, the selected one wearing a ring,
18
- * then `+N` for the remainder; what renders is the nearest token step - see
19
- * {@link INLINE_SWATCHES}. Clicking `+N` opens the full set.
18
+ * then `+N` for the remainder; what renders is the nearest token step. The
19
+ * card's `maxSwatches` caps the row (five by default) and clicking `+N` opens
20
+ * the full set.
20
21
  */
21
22
  /**
22
- * Circles shown inline before the rest go behind `+N`.
23
+ * Circles a card under 240px wide can show beside the overflow label.
23
24
  *
24
- * The design draws five dots on a 26px pitch. the kit's smallest chip step is
25
- * `xs` (20px), which on the row's 10px gap gives a 30px pitch - five dots plus
26
- * the overflow label come to ~170px, which fits the full card's 204px content
27
- * column.
28
- *
29
- * Below 240px one dot is dropped and counted into `+N` instead: a 180px card
30
- * offers 144px and the label would clip off the right edge.
25
+ * The kit's smallest chip step is `xs` (20px), which on the row's 10px gap
26
+ * gives a 30px pitch. Five dots plus the label come to ~170px, which fits the
27
+ * full card's 204px content column, but a 180px card offers 144px and the label
28
+ * would clip off the right edge. So below 240px the row stops at four and
29
+ * counts the rest into `+N`.
31
30
  */
32
- const INLINE_SWATCHES = 5;
31
+ const NARROW_SWATCHES = 4;
33
32
  /** The popover still caps its grid, so a 200-colourway family stays a panel. */
34
33
  const MAX_POPOVER_SWATCHES = 25;
35
34
  export const ProductCardColorCount = () => {
36
- const { product, selectedVariantId, onVariantChange, renderImage } = useProductCard();
35
+ const { maxSwatches, onVariantChange, product, renderImage, selectedVariantId, } = useProductCard();
37
36
  const variantCount = product.variantCount ?? 1;
38
37
  const variants = product.variants ?? [product];
39
38
  // One colourway is not a choice, so it is not a control. Same rule the primary
@@ -41,22 +40,23 @@ export const ProductCardColorCount = () => {
41
40
  if (variantCount <= 1) {
42
41
  return null;
43
42
  }
44
- const inlineVariants = variants.slice(0, INLINE_SWATCHES);
43
+ const inlineVariants = variants.slice(0, Math.max(0, maxSwatches));
45
44
  // The overflow counts against the GRAPH's total, not the array we happen to
46
45
  // hold - a page of variants is not the family.
47
46
  const inlineOverflow = variantCount - inlineVariants.length;
48
- // The fifth dot is hidden below 240px, so the label there must count it. It
49
- // only exists to be hidden when the row actually reached five.
50
- const hasBreakpointOnlySwatch = inlineVariants.length === INLINE_SWATCHES;
51
- const mediumOverflow = inlineOverflow + (hasBreakpointOnlySwatch ? 1 : 0);
52
- // WHICH dot drops below 240px. The last one, unless the last one is the
53
- // SELECTED colourway - hiding that leaves a narrow card showing four dots
54
- // with no ring on any of them, so the card reads as if nothing is chosen
55
- // while its photograph shows a colourway the row does not contain.
56
- const lastIndex = INLINE_SWATCHES - 1;
57
- const breakpointOnlyIndex = inlineVariants[lastIndex]?.id === selectedVariantId
58
- ? lastIndex - 1
59
- : lastIndex;
47
+ // Dots past NARROW_SWATCHES are hidden below 240px, so the label there must
48
+ // count them too.
49
+ const narrowCount = Math.min(inlineVariants.length, NARROW_SWATCHES);
50
+ const mediumOverflow = inlineOverflow + (inlineVariants.length - narrowCount);
51
+ // WHICH dots drop below 240px: the ones past the cut, unless one of them is
52
+ // the SELECTED colourway. Hiding that leaves a narrow card with no ring on
53
+ // any dot, so the card reads as if nothing is chosen while its photograph
54
+ // shows a colourway the row does not contain. The selected dot keeps its
55
+ // place and the last dot before the cut drops instead.
56
+ const selectedIndex = inlineVariants.findIndex((variant) => variant.id === selectedVariantId);
57
+ const isNarrowHidden = (index) => index < narrowCount
58
+ ? selectedIndex >= narrowCount && index === narrowCount - 1
59
+ : index !== selectedIndex;
60
60
  const hasPopoverOverflow = variantCount > MAX_POPOVER_SWATCHES;
61
61
  const popoverVisibleCount = hasPopoverOverflow
62
62
  ? MAX_POPOVER_SWATCHES - 1
@@ -71,7 +71,7 @@ export const ProductCardColorCount = () => {
71
71
  sizes: size,
72
72
  src: variant.images[0] ?? "",
73
73
  })] }));
74
- return (_jsxs(Box, { className: "flex items-center gap-2.5", children: [_jsx(SwatchSelector, { className: "flex items-center gap-2.5", onValueChange: onVariantChange, size: "xs", value: selectedVariantId, children: inlineVariants.map((variant, index) => (_jsx(Swatch, { className: hasBreakpointOnlySwatch && index === breakpointOnlyIndex
74
+ return (_jsxs(Box, { className: "flex items-center gap-2.5", children: [_jsx(SwatchSelector, { className: "flex items-center gap-2.5", onValueChange: onVariantChange, size: "xs", value: selectedVariantId, children: inlineVariants.map((variant, index) => (_jsx(Swatch, { className: isNarrowHidden(index)
75
75
  ? "hidden @[240px]/product-card:inline-flex"
76
76
  : undefined, unavailable: variant.available === false, value: variant.id, children: swatchImage(variant, "20px") }, variant.id))) }), mediumOverflow > 0 ? (_jsxs(Popover, { children: [_jsxs(PopoverTrigger, { render: _jsx("button", { "aria-label": `Show all ${variantCount} ${product.variantLabel ?? "colours"}`, className: cn("text-muted-foreground hover:text-foreground text-caption cursor-pointer tabular-nums",
77
77
  // Exactly five colourways leaves nothing behind once the fifth
@@ -14,8 +14,8 @@ import { ProductCardStatusBadge } from "./product-card-status-badge.js";
14
14
  import { ProductCardTools } from "./product-card-tools.js";
15
15
  /** Compact variant: below 180px, or always in compact mode. */
16
16
  export const ProductCardCompactArm = ({ compactFooterControl, overrideImageUrl, priority, similarityScore, toolsActive, }) => {
17
- const { action, mode, onQuickView, statusBadge } = useProductCard();
17
+ const { action, mode, onQuickView, showSwatches, statusBadge } = useProductCard();
18
18
  const isCompactMode = mode === "compact";
19
19
  const hasCompactFooterControl = compactFooterControl !== null && compactFooterControl !== undefined;
20
- return (_jsxs(Box, { className: cn("flex-col", isCompactMode ? "flex" : "flex h-full @[180px]/product-card:hidden"), children: [_jsxs(Box, { className: "relative w-full overflow-hidden rounded-3xl shadow-sm", children: [_jsx(ProductCardImageLink, { imageClassName: isCompactMode ? "!p-0" : undefined, overrideImageUrl: overrideImageUrl, priority: priority, similarityScore: similarityScore }), _jsx(ProductCardTools, { active: toolsActive }), _jsx(ProductCardQuickView, { forceVisible: true, onQuickView: onQuickView })] }), _jsxs(VStack, { className: "min-h-0 flex-1 gap-2.5 px-1.5 py-2", children: [_jsxs(VStack, { className: "gap-1 px-0.5", children: [_jsxs(Box, { className: "flex items-start justify-between gap-2", children: [_jsx(VStack, { className: "min-w-0 flex-1 gap-0.5", children: _jsx(ProductCardCaption, { scale: "compact" }) }), statusBadge?.icon ? (_jsx(ProductCardStatusBadge, { ...statusBadge })) : null] }), _jsx(ProductCardMeta, {}), action ? _jsx(ProductCardAction, { ...action }) : null, mode !== "single" && (_jsx(Box, { className: "min-h-5 pt-2", children: _jsx(ProductCardColorCount, {}) }))] }), hasCompactFooterControl ? (_jsx(Box, { className: "mt-auto flex justify-end", children: compactFooterControl })) : null] })] }));
20
+ return (_jsxs(Box, { className: cn("flex-col", isCompactMode ? "flex" : "flex h-full @[180px]/product-card:hidden"), children: [_jsxs(Box, { className: "relative w-full overflow-hidden rounded-3xl shadow-sm", children: [_jsx(ProductCardImageLink, { imageClassName: isCompactMode ? "!p-0" : undefined, overrideImageUrl: overrideImageUrl, priority: priority, similarityScore: similarityScore }), _jsx(ProductCardTools, { active: toolsActive }), _jsx(ProductCardQuickView, { forceVisible: true, onQuickView: onQuickView })] }), _jsxs(VStack, { className: "min-h-0 flex-1 gap-2.5 px-1.5 py-2", children: [_jsxs(VStack, { className: "gap-1 px-0.5", children: [_jsxs(Box, { className: "flex items-start justify-between gap-2", children: [_jsx(VStack, { className: "min-w-0 flex-1 gap-0.5", children: _jsx(ProductCardCaption, { scale: "compact" }) }), statusBadge?.icon ? (_jsx(ProductCardStatusBadge, { ...statusBadge })) : null] }), _jsx(ProductCardMeta, {}), action ? _jsx(ProductCardAction, { ...action }) : null, showSwatches && mode !== "single" && (_jsx(Box, { className: "min-h-5 pt-2", children: _jsx(ProductCardColorCount, {}) }))] }), hasCompactFooterControl ? (_jsx(Box, { className: "mt-auto flex justify-end", children: compactFooterControl })) : null] })] }));
21
21
  };
@@ -28,6 +28,11 @@ interface ProductCardDisplayOptions {
28
28
  * reserved height.
29
29
  */
30
30
  nameLines?: 1 | 2 | undefined;
31
+ /**
32
+ * Show the row of colourway swatches. Single-colour products and `single`
33
+ * mode never show it.
34
+ * @default true
35
+ */
31
36
  swatches?: boolean | undefined;
32
37
  }
33
38
  /**
@@ -31,6 +31,6 @@ import { ProductCardTools } from "./product-card-tools.js";
31
31
  * fetched - and the popover mounts nothing until it opens.
32
32
  */
33
33
  export const ProductCardFullArm = ({ footerControl, overrideImageUrl, priority, similarityScore, toolsActive, }) => {
34
- const { action, active, mode, onQuickView, statusBadge } = useProductCard();
35
- return (_jsxs(VStack, { className: cn("bg-card duration-normal h-full overflow-hidden rounded-3xl transition-shadow ease-in-out @[240px]/product-card:rounded-4xl", mode === "compact" ? "hidden" : "hidden @[180px]/product-card:flex", active ? "shadow-lg" : "shadow-sm hover:shadow-md"), gap: "none", children: [_jsxs(Box, { className: "relative w-full", children: [_jsx(ProductCardImageLink, { overrideImageUrl: overrideImageUrl, priority: priority, similarityScore: similarityScore }), statusBadge && !statusBadge.icon ? (_jsx(ProductCardStatusBadge, { ...statusBadge })) : null, _jsx(ProductCardTools, { active: toolsActive }), _jsx(ProductCardQuickView, { onQuickView: onQuickView })] }), _jsx(VStack, { className: "flex-1 pt-2 pb-4", gap: "none", children: _jsxs(VStack, { className: "min-h-0 flex-1 gap-2.5 px-4 @[240px]/product-card:gap-4", gap: "none", children: [_jsxs(VStack, { className: "gap-1 px-0.5", gap: "none", children: [_jsxs(Box, { className: "flex items-start justify-between gap-2", children: [_jsx(VStack, { className: "min-w-0 flex-1", gap: "none", children: _jsx(ProductCardCaption, {}) }), statusBadge?.icon ? (_jsx(ProductCardStatusBadge, { ...statusBadge })) : null] }), _jsx(ProductCardMeta, {}), action ? _jsx(ProductCardAction, { ...action }) : null, mode !== "single" && (_jsx(Box, { className: "pt-2", children: _jsx(Box, { className: "min-h-5", children: _jsx(ProductCardColorCount, {}) }) }))] }), footerControl !== null && footerControl !== undefined ? (_jsx(HStack, { className: "mt-auto", children: footerControl })) : null] }) })] }));
34
+ const { action, active, mode, onQuickView, showSwatches, statusBadge } = useProductCard();
35
+ return (_jsxs(VStack, { className: cn("bg-card duration-normal h-full overflow-hidden rounded-3xl transition-shadow ease-in-out @[240px]/product-card:rounded-4xl", mode === "compact" ? "hidden" : "hidden @[180px]/product-card:flex", active ? "shadow-lg" : "shadow-sm hover:shadow-md"), gap: "none", children: [_jsxs(Box, { className: "relative w-full", children: [_jsx(ProductCardImageLink, { overrideImageUrl: overrideImageUrl, priority: priority, similarityScore: similarityScore }), statusBadge && !statusBadge.icon ? (_jsx(ProductCardStatusBadge, { ...statusBadge })) : null, _jsx(ProductCardTools, { active: toolsActive }), _jsx(ProductCardQuickView, { onQuickView: onQuickView })] }), _jsx(VStack, { className: "flex-1 pt-2 pb-4", gap: "none", children: _jsxs(VStack, { className: "min-h-0 flex-1 gap-2.5 px-4 @[240px]/product-card:gap-4", gap: "none", children: [_jsxs(VStack, { className: "gap-1 px-0.5", gap: "none", children: [_jsxs(Box, { className: "flex items-start justify-between gap-2", children: [_jsx(VStack, { className: "min-w-0 flex-1", gap: "none", children: _jsx(ProductCardCaption, {}) }), statusBadge?.icon ? (_jsx(ProductCardStatusBadge, { ...statusBadge })) : null] }), _jsx(ProductCardMeta, {}), action ? _jsx(ProductCardAction, { ...action }) : null, showSwatches && mode !== "single" && (_jsx(Box, { className: "pt-2", children: _jsx(Box, { className: "min-h-5", children: _jsx(ProductCardColorCount, {}) }) }))] }), footerControl !== null && footerControl !== undefined ? (_jsx(HStack, { className: "mt-auto", children: footerControl })) : null] }) })] }));
36
36
  };
@@ -134,6 +134,11 @@ interface ProductCardProviderProps {
134
134
  * dynamic routes).
135
135
  */
136
136
  linkPrefetch?: boolean | undefined;
137
+ /**
138
+ * How many colourway swatches the row shows before the rest go behind `+N`.
139
+ * Cards under 240px wide show at most four.
140
+ * @default 5
141
+ */
137
142
  maxSwatches?: number | undefined;
138
143
  /**
139
144
  * Mode determines the card behavior:
@@ -253,6 +258,11 @@ type ProductCardProps = ComponentProps<"div"> & Pick<ProductCardProviderProps, "
253
258
  showAddToCart?: boolean | undefined;
254
259
  showProductBrand?: boolean | undefined;
255
260
  showProductName?: boolean | undefined;
261
+ /**
262
+ * Show the row of colourway swatches. Single-colour products and `single`
263
+ * mode never show it.
264
+ * @default true
265
+ */
256
266
  showSwatches?: boolean | undefined;
257
267
  similarityScore?: number | undefined;
258
268
  };
@@ -66,7 +66,7 @@ const resolveDisplay = (display, hasRenderAddToCart) => ({
66
66
  showAddToCart: (display?.addToCart ?? true) && hasRenderAddToCart,
67
67
  showProductBrand: display?.brand ?? true,
68
68
  showProductName: display?.name ?? true,
69
- showSwatches: display?.swatches ?? false,
69
+ showSwatches: display?.swatches ?? true,
70
70
  });
71
71
  /**
72
72
  * Which image the card is showing. Card-local because it is a way of LOOKING
@@ -9,9 +9,10 @@ export const ProductCardSwatchSelector = ({ className, ...props }) => {
9
9
  return null;
10
10
  }
11
11
  const variants = product.variants ?? [product];
12
- const visibleVariants = variants.slice(0, maxSwatches);
13
- // Use variantCount (total in DB) for overflow, not just loaded variants
14
- const overflowCount = (product.variantCount ?? 1) - maxSwatches;
12
+ const visibleVariants = variants.slice(0, Math.max(0, maxSwatches));
13
+ // Count the overflow against the family's total (variantCount), not the
14
+ // loaded variants, and against the dots actually shown, not the cap.
15
+ const overflowCount = (product.variantCount ?? variants.length) - visibleVariants.length;
15
16
  return (_jsxs(SwatchSelector, { className: cn("px-6", className), onValueChange: onVariantChange, value: selectedVariantId, ...props, children: [visibleVariants.map((variant) => (_jsx(Swatch, { unavailable: variant.available === false, value: variant.id, children: renderImage({
16
17
  alt: `${variant.name} variant`,
17
18
  className: "object-cover",
@@ -5118,6 +5118,7 @@ export const docsManifest = {
5118
5118
  "pixel-loader",
5119
5119
  "prompt-panel",
5120
5120
  "quick-view",
5121
+ "radio-group",
5121
5122
  "search-input",
5122
5123
  "site-footer",
5123
5124
  "streaming-text",
@@ -6197,7 +6198,7 @@ export const docsManifest = {
6197
6198
  "Set `size`/`radius`/`disabled` once on `InputGroup`; children inherit them via context, so don't re-set them per child.",
6198
6199
  "Inside a `Field` the group inherits invalid state and its `InputGroupInput`/`InputGroupTextarea` becomes the field's control (labelled, described, `aria-invalid`) - pass `hasError` only to force error styling and `aria-invalid` independently.",
6199
6200
  ],
6200
- uses: ["button", "field", "flex", "icon"],
6201
+ uses: ["button", "field", "flex", "icon", "textarea"],
6201
6202
  usedBy: ["country-select", "search-input"],
6202
6203
  anatomy: {
6203
6204
  children: [
@@ -8361,7 +8362,7 @@ export const docsManifest = {
8361
8362
  module: "components/progress",
8362
8363
  title: "Progress",
8363
8364
  category: "feedback",
8364
- description: "Linear progress bar showing completion from 0 to 100; pass `value={null}`\nfor an indeterminate (empty) track. Built on Base UI Progress for accessible\nsemantics.",
8365
+ description: "Linear progress bar showing completion from 0 to 100. Pass `value={null}`\nwhen the amount of work is unknown: a short bar slides across the track on a\nloop, and under reduced motion it holds still in the middle. Built on Base\nUI Progress for accessible semantics.",
8365
8366
  exports: [
8366
8367
  {
8367
8368
  name: "Progress",
@@ -8437,8 +8438,9 @@ export const docsManifest = {
8437
8438
  },
8438
8439
  ],
8439
8440
  bestPractices: [
8440
- "Use `Progress` for determinate 0–100 completion (`value={null}` for an indeterminate track); position in a stepped flow is `ProgressDots`.",
8441
+ "Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.",
8441
8442
  "For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.",
8443
+ "Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).",
8442
8444
  ],
8443
8445
  uses: ["center"],
8444
8446
  usedBy: ["file-upload"],
@@ -8787,6 +8789,12 @@ export const docsManifest = {
8787
8789
  defaultValue: '"sm"',
8788
8790
  description: 'Radio size - a single token or a per-breakpoint object like\n`{ base: "sm", md: "lg" }`.',
8789
8791
  },
8792
+ {
8793
+ name: "description",
8794
+ type: "React.ReactNode",
8795
+ required: false,
8796
+ description: "Optional helper text below the label; wired to the radio via `aria-describedby`.",
8797
+ },
8790
8798
  ],
8791
8799
  },
8792
8800
  {
@@ -8800,7 +8808,7 @@ export const docsManifest = {
8800
8808
  "Drive selection with `value` + `onValueChange` (controlled) or `defaultValue` (uncontrolled) - don't set both on the same group.",
8801
8809
  "Prefer `RadioGroupItem` (radio + wired `Label`) over hand-pairing `Radio` and `Label`; it auto-generates the `id`/`htmlFor` link.",
8802
8810
  ],
8803
- uses: ["flex", "label", "radio"],
8811
+ uses: ["flex", "grid", "label", "radio", "text"],
8804
8812
  usedBy: [],
8805
8813
  },
8806
8814
  {
@@ -12413,6 +12421,7 @@ export const docsManifest = {
12413
12421
  "page-header",
12414
12422
  "pixel-loader",
12415
12423
  "product-card",
12424
+ "radio-group",
12416
12425
  "section-header",
12417
12426
  "site-footer",
12418
12427
  "slider",
@@ -12447,11 +12456,11 @@ export const docsManifest = {
12447
12456
  ],
12448
12457
  bestPractices: [
12449
12458
  "Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.",
12450
- "It auto-grows with content (`field-sizing-content`); set `rows` for an initial height rather than pinning height via CSS.",
12459
+ "It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.",
12451
12460
  "Give it a label - inside a `Field` a `FieldLabel` targets it with no ids to wire; standalone, point a `Label`'s `htmlFor` at its `id`. A placeholder is not an accessible name.",
12452
12461
  ],
12453
12462
  uses: ["field"],
12454
- usedBy: [],
12463
+ usedBy: ["input-group"],
12455
12464
  },
12456
12465
  {
12457
12466
  slug: "thinking-loader",
@@ -14506,13 +14515,15 @@ export const docsManifest = {
14506
14515
  name: "more",
14507
14516
  type: "boolean",
14508
14517
  required: false,
14518
+ defaultValue: "true",
14519
+ description: "Collapse the page buttons into groups of `moreLimit`, with ellipsis\nbuttons that jump to the next or previous group. Off shows a button for\nevery page.",
14509
14520
  },
14510
14521
  {
14511
14522
  name: "moreLimit",
14512
14523
  type: "number",
14513
14524
  required: false,
14514
14525
  defaultValue: "5",
14515
- description: "Number of page buttons shown before collapsing to an ellipsis.",
14526
+ description: "Number of page buttons shown before collapsing to an ellipsis. Needs\n`more`.",
14516
14527
  },
14517
14528
  {
14518
14529
  name: "nextPageLabel",
@@ -14542,23 +14553,6 @@ export const docsManifest = {
14542
14553
  defaultValue: "[5, 10, 25, 50, 100]",
14543
14554
  description: "Page-size options offered by the rows-per-page select.",
14544
14555
  },
14545
- {
14546
- name: "sizesDescription",
14547
- type: "string",
14548
- required: false,
14549
- defaultValue: '"per page"',
14550
- },
14551
- {
14552
- name: "sizesInfo",
14553
- type: "string",
14554
- required: false,
14555
- },
14556
- {
14557
- name: "sizesLabel",
14558
- type: "string",
14559
- required: false,
14560
- defaultValue: '"Show"',
14561
- },
14562
14556
  {
14563
14557
  name: "sizesSkeleton",
14564
14558
  type: "ReactNode",
@@ -18517,6 +18511,8 @@ export const docsManifest = {
18517
18511
  name: "maxSwatches",
18518
18512
  type: "number | undefined",
18519
18513
  required: false,
18514
+ defaultValue: "5",
18515
+ description: "How many colourway swatches the row shows before the rest go behind `+N`.\nCards under 240px wide show at most four.",
18520
18516
  },
18521
18517
  {
18522
18518
  name: "menuOpen",
@@ -18643,6 +18639,8 @@ export const docsManifest = {
18643
18639
  name: "showSwatches",
18644
18640
  type: "boolean | undefined",
18645
18641
  required: false,
18642
+ defaultValue: "true",
18643
+ description: "Show the row of colourway swatches. Single-colour products and `single`\nmode never show it.",
18646
18644
  },
18647
18645
  {
18648
18646
  name: "similarityScore",
@@ -19119,6 +19117,8 @@ export const docsManifest = {
19119
19117
  name: "maxSwatches",
19120
19118
  type: "number | undefined",
19121
19119
  required: false,
19120
+ defaultValue: "5",
19121
+ description: "How many colourway swatches the row shows before the rest go behind `+N`.\nCards under 240px wide show at most four.",
19122
19122
  },
19123
19123
  {
19124
19124
  name: "mode",
@@ -164,7 +164,9 @@
164
164
  --color-muted-foreground: var(--color-gray-600);
165
165
  --color-accent: var(--color-gray-200);
166
166
  --color-accent-foreground: var(--color-gray-900);
167
- --color-destructive: var(--color-red-600);
167
+ /* Red-700, not 600: red-50 on red-600 is 4.4:1 and drops under the button
168
+ sheen and hover; red-700 holds 4.5:1 as a fill and as text on the page. */
169
+ --color-destructive: var(--color-red-700);
168
170
  --color-destructive-foreground: var(--color-red-50);
169
171
  --color-favorite: var(--color-red-500);
170
172
  --color-border: var(--color-gray-200);
@@ -213,8 +215,9 @@
213
215
  --color-text-muted: var(--color-gray-600);
214
216
 
215
217
  /* Calm semantic colours - Next.js inspired */
218
+ /* Accents double as text on the page, so they hold 4.5:1 on white. */
216
219
  --color-affirmative-soft: #f0fdf4; /* green-50 */
217
- --color-affirmative-accent: #16a34a; /* green-600 */
220
+ --color-affirmative-accent: #15803d; /* green-700 */
218
221
  --color-affirmative-foreground: #14532d; /* green-900 */
219
222
 
220
223
  --color-warning-soft: #fffbeb; /* very soft amber */
@@ -226,13 +229,17 @@
226
229
  --color-error-foreground: #991b1b; /* red-800 */
227
230
 
228
231
  --color-info-soft: #eff6ff; /* blue-50 */
229
- --color-info-accent: #3b82f6; /* blue-500 */
232
+ --color-info-accent: #2563eb; /* blue-600 */
230
233
  --color-info-foreground: #1e3a5f; /* blue-900 */
231
234
 
232
235
  --color-brand: #ffe600; /* Brand yellow, per the Figma base/brand token */
233
236
  --color-brand-soft: #fffef5; /* very soft brand yellow (code-only) */
234
237
  --color-brand-accent: #dda600; /* per Figma base/brand accent - consumed at /40 for borders/accents */
235
238
  --color-brand-foreground: var(--color-gray-800); /* #1F2937 - per Figma */
239
+ /* Brand-hued text on the page background, for brand buttons without a fill.
240
+ 6.1:1 on white, so it still passes under a faded hover; override it
241
+ alongside --color-brand. */
242
+ --color-brand-strong: #776000;
236
243
 
237
244
  /* Status border tokens (light mode base values - desaturated blends) */
238
245
  --color-affirmative-border: #dcfce7; /* green-100 - muted */
@@ -547,7 +554,7 @@
547
554
  --color-muted-foreground: var(--color-gray-600);
548
555
  --color-accent: var(--color-gray-200);
549
556
  --color-accent-foreground: var(--color-gray-900);
550
- --color-destructive: var(--color-red-600);
557
+ --color-destructive: var(--color-red-700);
551
558
  --color-destructive-foreground: var(--color-red-50);
552
559
  --color-favorite: var(--color-red-500);
553
560
  --color-border: var(--color-gray-200);
@@ -602,8 +609,10 @@
602
609
  --color-muted-foreground: var(--color-gray-400);
603
610
  --color-accent: var(--color-surface-2);
604
611
  --color-accent-foreground: var(--color-foreground);
605
- --color-destructive: var(--color-red-500);
606
- --color-destructive-foreground: var(--color-red-50);
612
+ /* Like the other dark solids, danger is a light fill with dark text: no
613
+ red reads both as text on graphite and under light text. */
614
+ --color-destructive: var(--color-red-400);
615
+ --color-destructive-foreground: var(--color-surface-sunk);
607
616
  --color-favorite: var(--color-red-400);
608
617
  --color-border: oklch(0.82 0.007 250 / 0.1);
609
618
  --color-input: var(--color-surface-1);
@@ -629,6 +638,8 @@
629
638
  --color-info-accent: oklch(0.72 0.1 245);
630
639
  --color-info-foreground: oklch(0.74 0.08 245);
631
640
  --color-brand-soft: var(--color-surface-1);
641
+ /* The brand itself reads on graphite (14.6:1 on the background). */
642
+ --color-brand-strong: var(--color-brand);
632
643
 
633
644
  /* Dark surfaces are separated by the role ladder and hairline, never
634
645
  elevation. This also neutralises custom shadow utilities package-wide. */
package/lint/tokens.mjs CHANGED
@@ -40,6 +40,7 @@ export const colorTokens = [
40
40
  "brand-accent",
41
41
  "brand-foreground",
42
42
  "brand-soft",
43
+ "brand-strong",
43
44
  "card",
44
45
  "card-background",
45
46
  "card-border",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instruments/materia-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Materia design system: React components, tokens and Tailwind v4 styles.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {