@instruments/materia-ui 0.1.0 → 0.3.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 (55) hide show
  1. package/agent-docs.md +4 -3
  2. package/dist/components/animated-number/number-digit.d.ts +5 -3
  3. package/dist/components/animated-number/number-digit.js +39 -16
  4. package/dist/components/animated-number/number-section.d.ts +7 -5
  5. package/dist/components/animated-number/number-section.js +81 -65
  6. package/dist/components/animated-number/number-symbol.js +2 -0
  7. package/dist/components/animated-number/target-widths.d.ts +7 -0
  8. package/dist/components/animated-number/target-widths.js +13 -1
  9. package/dist/components/button/button-variants.js +53 -8
  10. package/dist/components/button-group/button-group-root.js +8 -6
  11. package/dist/components/calendar/calendar-root.js +39 -13
  12. package/dist/components/chart/chart-tooltip.js +7 -5
  13. package/dist/components/empty/empty-media.js +5 -5
  14. package/dist/components/input-group/input-group-textarea.d.ts +3 -2
  15. package/dist/components/input-group/input-group-textarea.js +8 -4
  16. package/dist/components/pill/pill-variants.js +21 -10
  17. package/dist/components/progress/progress-root.d.ts +7 -5
  18. package/dist/components/progress/progress-root.js +26 -5
  19. package/dist/components/radio-group/radio-group-root.d.ts +7 -3
  20. package/dist/components/radio-group/radio-group-root.js +14 -4
  21. package/dist/components/segmented-control/segmented-control-indicator.js +1 -1
  22. package/dist/components/segmented-control/segmented-control-item.js +1 -1
  23. package/dist/components/segmented-control/segmented-control-root.js +1 -1
  24. package/dist/components/segmented-control/segmented-control-variants.js +1 -1
  25. package/dist/components/stepper/stepper-item.js +6 -2
  26. package/dist/components/stepper/stepper-variants.d.ts +1 -0
  27. package/dist/components/stepper/stepper-variants.js +11 -1
  28. package/dist/components/tab-navigation/tab-navigation-link.js +1 -1
  29. package/dist/components/tabs/tabs-indicator.js +1 -1
  30. package/dist/components/tabs/tabs-trigger.js +1 -1
  31. package/dist/components/tabs/tabs-variants.js +1 -1
  32. package/dist/components/textarea/textarea-root.d.ts +1 -1
  33. package/dist/components/textarea/textarea-root.js +6 -9
  34. package/dist/components/textarea/use-textarea-rows.d.ts +22 -0
  35. package/dist/components/textarea/use-textarea-rows.js +43 -0
  36. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.d.ts +8 -6
  37. package/dist/compositions/data-grid-pagination/data-grid-pagination-root.js +11 -20
  38. package/dist/compositions/product-card/product-card-color-count.js +26 -26
  39. package/dist/compositions/product-card/product-card-compact-arm.js +2 -2
  40. package/dist/compositions/product-card/product-card-content-types.d.ts +5 -0
  41. package/dist/compositions/product-card/product-card-full-arm.js +2 -2
  42. package/dist/compositions/product-card/product-card-provider-types.d.ts +10 -0
  43. package/dist/compositions/product-card/product-card-provider.js +1 -1
  44. package/dist/compositions/product-card/product-card-swatch-selector.js +4 -3
  45. package/dist/data/docs-manifest.d.ts +1 -1
  46. package/dist/data/docs-manifest.js +29 -74
  47. package/dist/styles/index.css +17 -6
  48. package/dist/{components/segmented/segmented-variants.d.ts → tokens/segmented.d.ts} +12 -1
  49. package/dist/{components/segmented/segmented-variants.js → tokens/segmented.js} +11 -0
  50. package/lint/tokens.mjs +1 -0
  51. package/package.json +5 -5
  52. package/dist/components/segmented/segmented-root.d.ts +0 -12
  53. package/dist/components/segmented/segmented-root.js +0 -12
  54. package/dist/components/segmented.d.ts +0 -1
  55. package/dist/components/segmented.js +0 -1
package/agent-docs.md CHANGED
@@ -1259,10 +1259,11 @@ The component renders only the field stack - no card, heading, or CTA chrome. Co
1259
1259
 
1260
1260
  `@instruments/materia-ui/components/progress`
1261
1261
 
1262
- Linear progress bar showing completion from 0 to 100; pass `value={null}` for an indeterminate (empty) track. Built on Base UI Progress for accessible semantics.
1262
+ Linear progress bar showing completion from 0 to 100. Pass `value={null}` when the amount of work is unknown: a short bar slides across the track on a loop, and under reduced motion it holds still in the middle. Built on Base UI Progress for accessible semantics.
1263
1263
 
1264
- - Use `Progress` for determinate 0–100 completion (`value={null}` for an indeterminate track); position in a stepped flow is `ProgressDots`.
1264
+ - Use `Progress` for determinate 0–100 completion and `value={null}` when the total is unknown; position in a stepped flow is `ProgressDots`.
1265
1265
  - For a ring instead of a bar use `ProgressCircle` - it needs an explicit `label` for its native `<progress>` accessible name.
1266
+ - Mount `ReducedMotionProvider` at the app root - the indeterminate bar slides via `m.*` (LazyMotion).
1266
1267
 
1267
1268
  ## ProgressDots
1268
1269
 
@@ -1945,7 +1946,7 @@ The typography primitive for body copy - renders a `<p>` (or any element via `as
1945
1946
  Renders a textarea element for multi-line text input (Base UI `Field.Control` rendering a `<textarea>`). Inside a Field it is the field's control: the FieldLabel targets it, FieldDescription/FieldError describe it, and it inherits the field's invalid state.
1946
1947
 
1947
1948
  - Inside a `Field`, let invalid state cascade like `Input` - omit `hasError`; pass it only to force error styling on a standalone `Textarea`.
1948
- - It auto-grows with content (`field-sizing-content`); set `rows` for an initial height rather than pinning height via CSS.
1949
+ - It auto-grows with content (`field-sizing-content`); set `rows` for a minimum height in lines rather than pinning height via CSS.
1949
1950
  - 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.
1950
1951
 
1951
1952
  ## ThinkingLoader
@@ -15,10 +15,12 @@ interface NumberDigitProps extends HTMLMotionProps<"span"> {
15
15
  * through every intermediate numeral in either direction, including across the
16
16
  * 9/0 wrap when `trend` asks for it. New leading digits enter with
17
17
  * `initialValue={0}` and roll up from zero; exiting digits roll to zero so
18
- * removal also animates. Per-digit width animates to the target glyph width so
19
- * proportional fonts stay aligned.
18
+ * removal also animates. The column's real width tweens to the target glyph
19
+ * width on the roll's timing, so proportional fonts stay aligned without a
20
+ * one-frame jump.
20
21
  *
21
- * Under reduced motion the FLIP is skipped: the digit snaps to its value.
22
+ * Under reduced motion the FLIP and the width tween are skipped: the digit
23
+ * snaps to its value and width.
22
24
  */
23
25
  export declare const NumberDigit: ({ value: _value, trend, ...rest }: NumberDigitProps) => import("react").JSX.Element;
24
26
  export {};
@@ -1,11 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { animate, MotionConfigContext, PresenceContext, useIsPresent, useReducedMotion, } from "motion/react";
3
+ import { animate, MotionConfigContext, PresenceContext, useIsPresent, useMotionValue, useReducedMotion, } from "motion/react";
4
4
  import * as m from "motion/react-m";
5
- import { use, useEffect, useLayoutEffect, useRef, useState } from "react";
5
+ import { use, useEffect, useLayoutEffect, useRef } from "react";
6
6
  import { getWidthInEm } from "./get-width-in-em.js";
7
7
  import { maskHeight } from "./mask.js";
8
- import { targetWidths } from "./target-widths.js";
8
+ import { targetWidths, widthTiming } from "./target-widths.js";
9
9
  const mod = (n, base) => ((n % base) + base) % base;
10
10
  /** The nine digits above and below `value`, wrapping mod 10. */
11
11
  const ABOVE_OFFSETS = [9, 8, 7, 6, 5, 4, 3, 2, 1];
@@ -24,10 +24,12 @@ const digitFillStyle = {
24
24
  * through every intermediate numeral in either direction, including across the
25
25
  * 9/0 wrap when `trend` asks for it. New leading digits enter with
26
26
  * `initialValue={0}` and roll up from zero; exiting digits roll to zero so
27
- * removal also animates. Per-digit width animates to the target glyph width so
28
- * proportional fonts stay aligned.
27
+ * removal also animates. The column's real width tweens to the target glyph
28
+ * width on the roll's timing, so proportional fonts stay aligned without a
29
+ * one-frame jump.
29
30
  *
30
- * Under reduced motion the FLIP is skipped: the digit snaps to its value.
31
+ * Under reduced motion the FLIP and the width tween are skipped: the digit
32
+ * snaps to its value and width.
31
33
  */
32
34
  export const NumberDigit = ({ value: _value, trend = 0, ...rest }) => {
33
35
  const { transition } = use(MotionConfigContext);
@@ -45,15 +47,23 @@ export const NumberDigit = ({ value: _value, trend = 0, ...rest }) => {
45
47
  // Exit isn't a normal AnimatePresence exit because it must trigger a resize.
46
48
  const isPresent = useIsPresent();
47
49
  const value = isPresent ? _value : 0;
48
- // Set the width to the initial value's width immediately, so the next render
49
- // animates from it.
50
+ // The column's real layout width, tweened to each value's glyph width so a
51
+ // proportional font never jumps its neighbours. Natural until mounted.
52
+ const width = useMotionValue("auto");
53
+ // Set the width to the initial value's width before paint, so the first
54
+ // change tweens from it.
50
55
  useLayoutEffect(() => {
51
56
  const initialEl = numberRefs.current[initialValueRef.current];
52
57
  if (!scope.current || !initialEl) {
53
58
  return;
54
59
  }
55
- scope.current.style.width = getWidthInEm(initialEl);
56
- }, []);
60
+ const initialWidth = getWidthInEm(initialEl);
61
+ scope.current.style.width = initialWidth;
62
+ width.jump(initialWidth);
63
+ }, [width]);
64
+ useEffect(() => () => {
65
+ width.stop();
66
+ }, [width]);
57
67
  // FLIP the y in a layout effect. initialY is the pixel offset from the new
58
68
  // resting position, including any in-flight offset (inner vs outer rect top)
59
69
  // so mid-roll re-targets stay continuous.
@@ -83,24 +93,37 @@ export const NumberDigit = ({ value: _value, trend = 0, ...rest }) => {
83
93
  // (equivalent to the original's cleanup, without a returned teardown).
84
94
  prevValue.current = value;
85
95
  }, [value, trend, shouldReduceMotion, transition]);
86
- const [width, setWidth] = useState();
96
+ const sizedValue = useRef(null);
87
97
  useEffect(() => {
88
98
  const el = numberRefs.current[value];
89
- if (!el) {
99
+ if (!el || sizedValue.current === value) {
90
100
  return;
91
101
  }
102
+ sizedValue.current = value;
92
103
  const w = getWidthInEm(el);
93
104
  // Store the target width immediately, so the section resize can use it.
94
105
  if (ref.current) {
95
106
  targetWidths.set(ref.current, w);
96
107
  }
97
- // Trigger the layout animation by causing another render.
98
- setWidth(w);
99
- }, [value]);
108
+ if (shouldReduceMotion === true || width.get() === w) {
109
+ width.jump(w);
110
+ return;
111
+ }
112
+ // A change mid-tween retargets from the width it has reached.
113
+ animate(width, w, widthTiming(transition));
114
+ }, [value, shouldReduceMotion, transition, width]);
100
115
  const renderNumber = (digit) => (_jsx("span", { ref: (r) => {
101
116
  numberRefs.current[digit] = r;
102
117
  }, style: { display: "inline-block", padding: `calc(${maskHeight}/2) 0` }, children: digit }, digit));
103
- return (_jsx(m.span, { ...rest, "data-state": isPresent ? undefined : "exiting", layout: "position", ref: ref, style: { display: "inline-flex", justifyContent: "center", width }, children: _jsxs("span", { ref: scope, style: {
118
+ return (_jsx(m.span, { ...rest, "data-state": isPresent ? undefined : "exiting", layout: "position", ref: ref,
119
+ // Never shrinks: while the section's width tweens, the glyphs overflow
120
+ // it at their own widths rather than squeezing.
121
+ style: {
122
+ display: "inline-flex",
123
+ flexShrink: 0,
124
+ justifyContent: "center",
125
+ width,
126
+ }, children: _jsxs("span", { ref: scope, style: {
104
127
  alignItems: "center",
105
128
  display: "inline-flex",
106
129
  flexDirection: "column",
@@ -12,11 +12,13 @@ interface NumberSectionProps extends HTMLMotionProps<"span"> {
12
12
  }
13
13
  /**
14
14
  * A run of digits/symbols (pre, integer, fraction, post) sharing one anchor.
15
- * It holds a fixed measured width so a new glyph is appended at the end before
16
- * the layout animation begins - making it look like it was already there - then
17
- * re-measures by momentarily removing exiting children and applying the digits'
18
- * target widths (the exact resize trick), so reflow accounts for negative
19
- * margins between glyphs.
15
+ *
16
+ * Its real layout width is a Motion value: when the glyphs change it measures
17
+ * where the width will settle (exiting glyphs removed, digits at their target
18
+ * widths) and tweens to it on the roll's timing, so nothing laid out around the
19
+ * number jumps when a digit, separator, prefix or suffix arrives or leaves. In
20
+ * the meantime the glyphs hold to the section's anchor edge and overflow the
21
+ * other, which the digit mask clips. Under reduced motion the width snaps.
20
22
  */
21
23
  export declare const NumberSection: ({ parts, justify, mode, style, name, layoutDependency, trend, ...rest }: NumberSectionProps) => import("react").JSX.Element;
22
24
  export {};
@@ -1,93 +1,109 @@
1
1
  "use client";
2
- // Port of Motion+ AnimateNumber (motion-plus 2.0.0).
3
- "use client";
4
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
- import { AnimatePresence } from "motion/react";
3
+ import { animate, AnimatePresence, MotionConfigContext, useMotionValue, useReducedMotion, } from "motion/react";
6
4
  import * as m from "motion/react-m";
7
- import { useEffect, useMemo, useRef, useState } from "react";
5
+ import { use, useEffect, useMemo, useRef } from "react";
8
6
  import { isDigitPart } from "./format-parts.js";
9
7
  import { getWidthInEm } from "./get-width-in-em.js";
10
8
  import { NumberDigit } from "./number-digit.js";
11
9
  import { NumberSymbol } from "./number-symbol.js";
12
10
  import { SectionContext } from "./section-context.js";
13
- import { targetWidths } from "./target-widths.js";
11
+ import { targetWidths, widthTiming } from "./target-widths.js";
12
+ /**
13
+ * The section's natural width once the change lands, in `em`: exiting glyphs
14
+ * removed and each digit at its target width, measured at `max-content` and
15
+ * then put back as it was, in one synchronous pass so nothing paints between.
16
+ */
17
+ const measureTargetWidth = (section) => {
18
+ const undos = [];
19
+ // A static list: the loop removes children as it goes.
20
+ for (const child of section.querySelectorAll(":scope > *")) {
21
+ if (!(child instanceof HTMLElement)) {
22
+ continue;
23
+ }
24
+ if (child.dataset.state === "exiting") {
25
+ const next = child.nextSibling;
26
+ child.remove();
27
+ undos.push(() => {
28
+ if (next === null) {
29
+ section.append(child);
30
+ }
31
+ else {
32
+ next.before(child);
33
+ }
34
+ });
35
+ continue;
36
+ }
37
+ const target = targetWidths.get(child);
38
+ if (target === undefined) {
39
+ continue;
40
+ }
41
+ const current = child.style.width;
42
+ child.style.width = target;
43
+ undos.push(() => {
44
+ child.style.width = current;
45
+ });
46
+ }
47
+ const held = section.style.width;
48
+ section.style.width = "max-content";
49
+ const width = getWidthInEm(section);
50
+ section.style.width = held;
51
+ for (let i = undos.length - 1; i >= 0; i -= 1) {
52
+ undos[i]?.();
53
+ }
54
+ return width;
55
+ };
14
56
  /**
15
57
  * A run of digits/symbols (pre, integer, fraction, post) sharing one anchor.
16
- * It holds a fixed measured width so a new glyph is appended at the end before
17
- * the layout animation begins - making it look like it was already there - then
18
- * re-measures by momentarily removing exiting children and applying the digits'
19
- * target widths (the exact resize trick), so reflow accounts for negative
20
- * margins between glyphs.
58
+ *
59
+ * Its real layout width is a Motion value: when the glyphs change it measures
60
+ * where the width will settle (exiting glyphs removed, digits at their target
61
+ * widths) and tweens to it on the roll's timing, so nothing laid out around the
62
+ * number jumps when a digit, separator, prefix or suffix arrives or leaves. In
63
+ * the meantime the glyphs hold to the section's anchor edge and overflow the
64
+ * other, which the digit mask clips. Under reduced motion the width snaps.
21
65
  */
22
66
  export const NumberSection = ({ parts, justify = "left", mode, style, name, layoutDependency, trend, ...rest }) => {
23
67
  const ref = useRef(null);
24
68
  const context = useMemo(() => ({ justify }), [justify]);
25
- const measuredRef = useRef(null);
26
- // A fixed width for the section so new characters are added at the end
27
- // before the layout animation starts (they look like they were there).
28
- const [width, setWidth] = useState();
69
+ const { transition } = use(MotionConfigContext);
70
+ const shouldReduceMotion = useReducedMotion();
71
+ // Natural until mounted, then always an `em` length so it can tween.
72
+ const width = useMotionValue("auto");
29
73
  const partsSignature = parts.map((p) => p.value).join("");
30
- // On mount, fix the width to the first measured width.
31
74
  useEffect(() => {
32
- const measured = measuredRef.current;
33
- if (measured && ref.current) {
34
- ref.current.style.width = getWidthInEm(measured);
75
+ if (ref.current) {
76
+ width.set(getWidthInEm(ref.current));
35
77
  }
36
- }, []);
78
+ return () => {
79
+ width.stop();
80
+ };
81
+ }, [width]);
37
82
  const measuredSignature = useRef(partsSignature);
38
83
  useEffect(() => {
39
- const measured = measuredRef.current;
40
- if (!measured || measuredSignature.current === partsSignature) {
84
+ const section = ref.current;
85
+ if (!section || measuredSignature.current === partsSignature) {
41
86
  return;
42
87
  }
43
88
  measuredSignature.current = partsSignature;
44
- // Find the new width by removing exiting elements, measuring, then
45
- // re-adding them. We query the DOM because AnimatePresence popLayout
46
- // overwrites ref props.
47
- const undos = [];
48
- // Snapshot the children first - we mutate the live collection below.
49
- const children = [...measured.children];
50
- for (const child of children) {
51
- if (!(child instanceof HTMLElement)) {
52
- continue;
53
- }
54
- if (child.dataset.state === "exiting") {
55
- const next = child.nextSibling;
56
- child.remove();
57
- undos.push(() => {
58
- // insertBefore() appends if next is null.
59
- measuredRef.current?.insertBefore(child, next);
60
- });
61
- continue;
62
- }
63
- const newWidth = targetWidths.get(child);
64
- if (newWidth === undefined) {
65
- continue;
66
- }
67
- const oldWidth = child.style.width;
68
- child.style.width = newWidth;
69
- undos.push(() => {
70
- child.style.width = oldWidth;
71
- });
72
- }
73
- // Measure the resulting width, then undo immediately. This is a
74
- // measure-after-render pass (DOM already committed), not a render-phase
75
- // adjustment, so the section can hold a fixed width during the animation.
76
- setWidth(getWidthInEm(measured));
77
- for (let i = undos.length - 1; i >= 0; i -= 1) {
78
- undos[i]?.();
89
+ const target = measureTargetWidth(section);
90
+ if (shouldReduceMotion === true) {
91
+ width.jump(target);
92
+ return;
79
93
  }
80
- }, [partsSignature]);
81
- return (_jsx(SectionContext.Provider, { value: context, children: _jsx(m.span, { layoutDependency: layoutDependency, ...rest, className: `number-section-${name}`, ref: ref, style: {
94
+ // A change mid-tween retargets from the width it has reached. Not stopped
95
+ // when the effect re-runs: a fresh transition object from the caller must
96
+ // not freeze the width part-way.
97
+ animate(width, target, widthTiming(transition));
98
+ }, [partsSignature, shouldReduceMotion, transition, width]);
99
+ return (_jsx(SectionContext.Provider, { value: context, children: _jsxs(m.span, { layoutDependency: layoutDependency, ...rest, className: `number-section-${name}`, ref: ref, style: {
82
100
  ...style,
83
101
  display: "inline-flex",
84
102
  justifyContent: justify,
103
+ // The containing block for glyphs AnimatePresence pops out of flow.
104
+ position: "relative",
85
105
  width,
86
- }, children: _jsxs("span", { ref: measuredRef, style: {
87
- display: "inline-flex",
88
- justifyContent: "inherit",
89
- position: "relative",
90
- }, children: ["\u200B", _jsx(AnimatePresence, { anchorX: justify, initial: false, mode: mode ?? "sync", children: parts.map((part) => isDigitPart(part) ? (_jsx(NumberDigit, { animate: { opacity: 1 }, exit: { opacity: 0 }, initial: { opacity: 0 }, layoutDependency: layoutDependency, trend: trend, value: part.value }, part.key)) : (_jsx(NumberSymbol, { animate: { opacity: 1 }, exit: { opacity: 0 }, initial: { opacity: 0 }, layoutDependency: layoutDependency, partKey: part.key, type: part.type, children: part.value }, part.type === "literal"
91
- ? `${part.key}:${part.value}`
92
- : part.key))) })] }) }) }));
106
+ }, children: ["\u200B", _jsx(AnimatePresence, { anchorX: justify, initial: false, mode: mode ?? "sync", children: parts.map((part) => isDigitPart(part) ? (_jsx(NumberDigit, { animate: { opacity: 1 }, exit: { opacity: 0 }, initial: { opacity: 0 }, layoutDependency: layoutDependency, trend: trend, value: part.value }, part.key)) : (_jsx(NumberSymbol, { animate: { opacity: 1 }, exit: { opacity: 0 }, initial: { opacity: 0 }, layoutDependency: layoutDependency, partKey: part.key, type: part.type, children: part.value }, part.type === "literal"
107
+ ? `${part.key}:${part.value}`
108
+ : part.key))) })] }) }));
93
109
  };
@@ -16,6 +16,8 @@ export const NumberSymbol = ({ partKey: _partKey, type: _type, children, layoutD
16
16
  const { justify } = use(SectionContext);
17
17
  return (_jsx(m.span, { ...rest, "data-state": isPresent ? undefined : "exiting", layout: "position", layoutDependency: layoutDependency, style: {
18
18
  display: "inline-flex",
19
+ // Holds its width while the section's width tweens past it.
20
+ flexShrink: 0,
19
21
  justifyContent: justify,
20
22
  padding: `calc(${maskHeight}/2) 0`,
21
23
  position: "relative",
@@ -1,3 +1,4 @@
1
+ import type { Transition } from "motion/react";
1
2
  /**
2
3
  * Per-digit target widths, written by {@link NumberDigit} the instant a digit's
3
4
  * width changes and read by {@link NumberSection} when it re-measures its fixed
@@ -5,3 +6,9 @@
5
6
  * element.
6
7
  */
7
8
  export declare const targetWidths: WeakMap<Element, string>;
9
+ /**
10
+ * The timing for a tweened width: the layout curve when the transition is
11
+ * keyed per value (the token default), otherwise the transition itself (a
12
+ * caller's bespoke override), so widths move in step with the roll.
13
+ */
14
+ export declare const widthTiming: (transition: Transition | undefined) => Transition;
@@ -1,4 +1,3 @@
1
- // Port of Motion+ AnimateNumber (motion-plus 2.0.0).
2
1
  /**
3
2
  * Per-digit target widths, written by {@link NumberDigit} the instant a digit's
4
3
  * width changes and read by {@link NumberSection} when it re-measures its fixed
@@ -6,3 +5,16 @@
6
5
  * element.
7
6
  */
8
7
  export const targetWidths = new WeakMap();
8
+ /**
9
+ * The timing for a tweened width: the layout curve when the transition is
10
+ * keyed per value (the token default), otherwise the transition itself (a
11
+ * caller's bespoke override), so widths move in step with the roll.
12
+ */
13
+ export const widthTiming = (transition) => {
14
+ if (transition !== undefined &&
15
+ "layout" in transition &&
16
+ transition.layout !== undefined) {
17
+ return transition.layout;
18
+ }
19
+ return transition ?? {};
20
+ };
@@ -114,18 +114,37 @@ const NEUTRAL_SOFT = "border-border bg-secondary text-secondary-foreground";
114
114
  const NEUTRAL_SOFT_GRADIENT = "before:bg-sheen-bright hover:before:bg-sheen-hover active:before:bg-none disabled:before:bg-none data-disabled:before:bg-none data-[hovered=true]:before:bg-sheen-hover data-[pressed=true]:before:bg-none dark:before:bg-sheen-dark";
115
115
  const NEUTRAL_OUTLINE = "border-border bg-background text-foreground hover:bg-muted hover:text-accent-foreground data-[hovered=true]:bg-muted data-[hovered=true]:text-accent-foreground";
116
116
  const NEUTRAL_GHOST = "border-transparent bg-transparent text-foreground shadow-none hover:bg-muted hover:text-accent-foreground data-[hovered=true]:bg-muted data-[hovered=true]:text-accent-foreground";
117
- const NEUTRAL_LINK = "border-transparent bg-transparent text-foreground underline-offset-4 shadow-none hover:underline data-[hovered=true]:underline";
118
- const NEUTRAL_LINK_FLAT = "shadow-none before:hidden active:scale-100 active:underline-offset-5 data-[pressed=true]:scale-100 data-[pressed=true]:underline-offset-5";
117
+ /* ── Link, shared by every tone: no fill at rest or on hover, so each tone
118
+ * only picks a text colour that reads on the page. ─────────────────── */
119
+ const LINK = "border-transparent bg-transparent underline-offset-4 shadow-none hover:bg-transparent hover:underline data-[hovered=true]:bg-transparent data-[hovered=true]:underline";
120
+ const LINK_FLAT = "shadow-none before:hidden active:scale-100 active:underline-offset-5 data-[pressed=true]:scale-100 data-[pressed=true]:underline-offset-5";
121
+ const NEUTRAL_LINK = `${LINK} text-foreground`;
119
122
  const NEUTRAL_TRANSPARENT = "transform-gpu border-transparent bg-primary/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-primary/60 data-[hovered=true]:bg-primary/60";
120
123
  /* ── Danger tone (was `variant="destructive"`) modifiers ────────────── */
124
+ // Built like neutral soft. The hover pins the fill, since the danger tone
125
+ // darkens its solid fill on hover.
126
+ const DANGER_SOFT = "border-error-border bg-error-soft text-error-foreground hover:bg-error-soft data-[hovered=true]:bg-error-soft";
121
127
  const DANGER_OUTLINE = "border-destructive text-destructive hover:bg-destructive/5 data-[hovered=true]:bg-destructive/5";
122
128
  const DANGER_GHOST = "text-destructive hover:bg-destructive/10 hover:text-destructive data-[hovered=true]:bg-destructive/10 data-[hovered=true]:text-destructive";
123
- const DANGER_TRANSPARENT = "transform-gpu border-transparent bg-destructive/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-destructive/60 data-[hovered=true]:bg-destructive/60";
129
+ const DANGER_LINK = `${LINK} text-destructive`;
130
+ // Half the danger fill over a light page is a pale red, so light mode takes
131
+ // dark text; over graphite it is mid-toned, so dark mode takes white, as
132
+ // Pill's status tones do.
133
+ const DANGER_TRANSPARENT = "transform-gpu border-transparent bg-destructive/50 text-foreground backdrop-blur-md will-change-[filter,background-color] hover:bg-destructive/60 data-[hovered=true]:bg-destructive/60 dark:text-white/90";
124
134
  /* ── Brand tone (was `variant="brand"`) modifiers ───────────────────── */
125
135
  const BRAND_SOLID_GRADIENT = "shadow-button-sheen before:bg-sheen-strong hover:before:bg-sheen-hover active:shadow-none active:before:bg-none disabled:shadow-none disabled:before:bg-none data-disabled:shadow-none data-disabled:before:bg-none data-[hovered=true]:before:bg-sheen-hover data-[pressed=true]:shadow-none data-[pressed=true]:before:bg-none";
126
- const BRAND_OUTLINE = "border-brand text-brand hover:bg-brand/5 data-[hovered=true]:bg-brand/5";
127
- const BRAND_GHOST = "text-brand hover:bg-brand/10 hover:text-brand data-[hovered=true]:bg-brand/10 data-[hovered=true]:text-brand";
128
- const BRAND_TRANSPARENT = "transform-gpu border-transparent bg-brand/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-brand/60 data-[hovered=true]:bg-brand/60";
136
+ // Built like neutral soft, with Pill's brand soft colours. Dark brand soft is
137
+ // graphite, so the text there takes the brand accent.
138
+ const BRAND_SOFT = "bg-brand-soft text-brand-foreground dark:text-brand-accent";
139
+ // Without a fill, brand yellow is unreadable on the page, so the text takes
140
+ // `brand-strong`; the outline border can stay yellow.
141
+ const BRAND_OUTLINE = "border-brand text-brand-strong hover:bg-brand/5 data-[hovered=true]:bg-brand/5";
142
+ const BRAND_GHOST = "text-brand-strong hover:bg-brand/10 hover:text-brand-strong data-[hovered=true]:bg-brand/10 data-[hovered=true]:text-brand-strong";
143
+ const BRAND_LINK = `${LINK} text-brand-strong`;
144
+ // Over graphite, half the brand fill turns olive and the brand foreground
145
+ // stops reading, so dark mode thickens the fill rather than swapping the text,
146
+ // which keeps the brand and its foreground overridable as a pair.
147
+ const BRAND_TRANSPARENT = "transform-gpu border-transparent bg-brand/50 backdrop-blur-md will-change-[filter,background-color] hover:bg-brand/60 data-[hovered=true]:bg-brand/60 dark:bg-brand/70 dark:hover:bg-brand/80 dark:data-[hovered=true]:bg-brand/80";
129
148
  /**
130
149
  * buttonVariants - CVA helper for Button styling.
131
150
  *
@@ -191,7 +210,7 @@ const buttonVariants = cva([
191
210
  },
192
211
  {
193
212
  asInput: false,
194
- class: `${NEUTRAL_LINK} ${SOLID_SURFACE} ${NEUTRAL_LINK_FLAT}`,
213
+ class: `${NEUTRAL_LINK} ${SOLID_SURFACE} ${LINK_FLAT}`,
195
214
  emphasis: "link",
196
215
  tone: "neutral",
197
216
  },
@@ -208,6 +227,12 @@ const buttonVariants = cva([
208
227
  emphasis: "solid",
209
228
  tone: "danger",
210
229
  },
230
+ {
231
+ asInput: false,
232
+ class: `${DANGER_SOFT} ${SOLID_SURFACE} ${SOLID_SHADOW} ${NEUTRAL_SOFT_GRADIENT}`,
233
+ emphasis: "soft",
234
+ tone: "danger",
235
+ },
211
236
  {
212
237
  asInput: false,
213
238
  class: `${SOLID_SHADOW} ${DANGER_OUTLINE}`,
@@ -226,6 +251,12 @@ const buttonVariants = cva([
226
251
  emphasis: "transparent",
227
252
  tone: "danger",
228
253
  },
254
+ {
255
+ asInput: false,
256
+ class: `${DANGER_LINK} ${LINK_FLAT}`,
257
+ emphasis: "link",
258
+ tone: "danger",
259
+ },
229
260
  /* ── Brand tone (base colours from variants.tone) ─────── */
230
261
  {
231
262
  asInput: false,
@@ -233,6 +264,12 @@ const buttonVariants = cva([
233
264
  emphasis: "solid",
234
265
  tone: "brand",
235
266
  },
267
+ {
268
+ asInput: false,
269
+ class: `${BRAND_SOFT} ${SOLID_SURFACE} ${SOLID_SHADOW} ${NEUTRAL_SOFT_GRADIENT}`,
270
+ emphasis: "soft",
271
+ tone: "brand",
272
+ },
236
273
  {
237
274
  asInput: false,
238
275
  class: `${SOLID_SHADOW} ${BRAND_OUTLINE}`,
@@ -251,6 +288,12 @@ const buttonVariants = cva([
251
288
  emphasis: "transparent",
252
289
  tone: "brand",
253
290
  },
291
+ {
292
+ asInput: false,
293
+ class: `${BRAND_LINK} ${LINK_FLAT}`,
294
+ emphasis: "link",
295
+ tone: "brand",
296
+ },
254
297
  // Input chips carry the solid drop-shadow (was `variant="default"`).
255
298
  // asInput replaces every tone/emphasis surface (gated `asInput: false`).
256
299
  { asInput: true, class: SOLID_SHADOW },
@@ -281,10 +324,12 @@ const buttonVariants = cva([
281
324
  // brand|danger>, emphasis is the full Emphasis plus Button's `link`
282
325
  // extension. The `satisfies Record<…>` fails to compile if these keys
283
326
  // drift from ButtonTone/ButtonEmphasis (declared below).
327
+ // Danger fades only to /95 on hover: a lighter red under the light
328
+ // foreground and the sheen drops below 4.5:1 at /90.
284
329
  tone: {
285
330
  neutral: "",
286
331
  brand: "border-brand-accent/40 bg-brand text-brand-foreground focus-visible:ring-brand/25 data-[focused=true]:ring-brand/25",
287
- danger: "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90 data-[hovered=true]:bg-destructive/90 focus-visible:ring-red-400/50 data-[focused=true]:ring-red-400/50",
332
+ danger: "border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/95 data-[hovered=true]:bg-destructive/95 focus-visible:ring-red-400/50 data-[focused=true]:ring-red-400/50",
288
333
  },
289
334
  emphasis: {
290
335
  solid: "",
@@ -40,16 +40,18 @@ const buttonGroupVariants = cva("flex w-fit items-stretch *:focus-visible:relati
40
40
  dividers: false,
41
41
  variant: "segmented",
42
42
  },
43
- // Remove overlapping borders when dividers are disabled (joined variant behaviour)
43
+ // A joined group keeps each child's own border and drops every trailing
44
+ // edge but the last, so neighbours share one line and an outline group
45
+ // reads as one continuous outline. Dividers recolour that shared edge.
44
46
  {
45
- class: "[&>[data-slot=button]]:border-0",
46
- dividers: false,
47
+ class: "[&>*:not(:last-child)]:border-r-0",
47
48
  orientation: "horizontal",
49
+ variant: "joined",
48
50
  },
49
51
  {
50
- class: "[&>[data-slot=button]]:border-0",
51
- dividers: false,
52
+ class: "[&>*:not(:last-child)]:border-b-0",
52
53
  orientation: "vertical",
54
+ variant: "joined",
53
55
  },
54
56
  // Optional radius overrides (applies to non-separator items)
55
57
  {
@@ -90,7 +92,7 @@ const buttonGroupVariants = cva("flex w-fit items-stretch *:focus-visible:relati
90
92
  variant: {
91
93
  /**
92
94
  * Default "joined" behaviour - relies on each child for borders/background,
93
- * and simply removes overlapping borders + inner radii.
95
+ * and collapses the borders and inner radii where neighbours meet.
94
96
  */
95
97
  joined: "",
96
98
  /**
@@ -2,16 +2,42 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "@instruments/materia-ui/utils/cn";
4
4
  import { ChevronLeft, ChevronRight } from "lucide-react";
5
- import { DayButton, DayPicker } from "react-day-picker";
5
+ import { Day, DayButton, DayPicker } from "react-day-picker";
6
6
  import { buttonVariants } from "../button.js";
7
7
  import { Icon } from "../icon.js";
8
8
  const CalendarChevron = ({ orientation }) => (_jsx(Icon, { icon: orientation === "left" ? ChevronLeft : ChevronRight, size: "xs" }));
9
9
  /**
10
- * DayPicker marks selection on the grid cell, not the button inside it, so the
11
- * button carries its own copy to paint the filled day. A range's middle days
12
- * are selected too, but sit on the cell's accent band instead of a fill.
10
+ * The accent band behind a selected range, painted on the grid cell because
11
+ * only the cells meet edge to edge. Middle days fill their cell, rounded where
12
+ * a week starts or ends; the first and last days fill the half that faces the
13
+ * range, so the band runs into their filled button. A range of one day has no
14
+ * band.
13
15
  */
14
- const CalendarDayButton = ({ modifiers, ...props }) => (_jsx(DayButton, { "data-selected": modifiers.selected === true && modifiers.range_middle !== true, modifiers: modifiers, ...props }));
16
+ const rangeBandClasses = (modifiers) => {
17
+ const start = modifiers.range_start === true;
18
+ const end = modifiers.range_end === true;
19
+ if (modifiers.range_middle === true) {
20
+ return "bg-accent text-accent-foreground first:rounded-l-md last:rounded-r-md";
21
+ }
22
+ if (start && !end) {
23
+ return "bg-linear-to-r from-transparent from-50% to-accent to-50% last:rounded-r-md";
24
+ }
25
+ if (end && !start) {
26
+ return "bg-linear-to-l from-transparent from-50% to-accent to-50% first:rounded-l-md";
27
+ }
28
+ return undefined;
29
+ };
30
+ const CalendarDay = ({ className, modifiers, ...props }) => (_jsx(Day, { className: cn(className, rangeBandClasses(modifiers)), modifiers: modifiers, ...props }));
31
+ /**
32
+ * DayPicker marks state on the grid cell, not the button inside it, so the
33
+ * button carries its own copy to paint the day. A range's middle days are
34
+ * selected too, but sit on the cell's band instead of a fill; today is tinted
35
+ * unless it is filled.
36
+ */
37
+ const CalendarDayButton = ({ modifiers, ...props }) => {
38
+ const filled = modifiers.selected === true && modifiers.range_middle !== true;
39
+ return (_jsx(DayButton, { "data-selected": filled, "data-today": modifiers.today === true && !filled, modifiers: modifiers, ...props }));
40
+ };
15
41
  /**
16
42
  * Date picker built on react-day-picker, styled with the shared button variants;
17
43
  * supports `single`, `multiple`, and `range` selection via the `mode` prop.
@@ -26,9 +52,8 @@ const Calendar = ({ className, classNames, showOutsideDays = true, ...props }) =
26
52
  button_next: cn(buttonVariants({ emphasis: "ghost", tone: "neutral" }), "text-muted-foreground hover:bg-accent hover:text-foreground size-8 p-0"),
27
53
  button_previous: cn(buttonVariants({ emphasis: "ghost", tone: "neutral" }), "text-muted-foreground hover:bg-accent hover:text-foreground size-8 p-0"),
28
54
  caption_label: "text-paragraph-sm font-medium",
29
- day: cn("text-paragraph-sm relative w-9 p-0 text-center focus-within:relative focus-within:z-20", props.mode === "range" &&
30
- "[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md [&:has([aria-selected].day-range-end)]:rounded-r-md"),
31
- day_button: cn(buttonVariants({ emphasis: "ghost" }), "data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground size-8 rounded-md p-0 font-normal"),
55
+ day: "text-paragraph-sm relative w-9 p-0 text-center focus-within:relative focus-within:z-20",
56
+ day_button: cn(buttonVariants({ emphasis: "ghost" }), "data-[today=true]:bg-accent data-[today=true]:text-accent-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground data-[selected=true]:hover:bg-primary data-[selected=true]:hover:text-primary-foreground size-8 rounded-md p-0 font-normal"),
32
57
  disabled: "text-muted-foreground opacity-50",
33
58
  hidden: "invisible",
34
59
  month: "flex flex-col gap-4",
@@ -36,18 +61,19 @@ const Calendar = ({ className, classNames, showOutsideDays = true, ...props }) =
36
61
  month_grid: "w-full border-collapse space-y-1",
37
62
  months: "relative flex flex-col gap-2 sm:flex-row",
38
63
  nav: "absolute inset-x-0 top-0 z-10 flex h-9 items-center justify-between px-1",
39
- outside: "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
40
- range_end: "day-range-end",
41
- range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
42
- range_start: "day-range-start",
64
+ outside: "text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
65
+ range_end: "",
66
+ range_middle: "",
67
+ range_start: "",
43
68
  selected: "",
44
- today: "bg-accent text-accent-foreground",
69
+ today: "",
45
70
  week: "mt-2 flex w-full",
46
71
  weekday: "w-9 rounded-md text-calendar font-normal text-muted-foreground",
47
72
  weekdays: "flex",
48
73
  ...classNames,
49
74
  }, components: {
50
75
  Chevron: CalendarChevron,
76
+ Day: CalendarDay,
51
77
  DayButton: CalendarDayButton,
52
78
  }, showOutsideDays: showOutsideDays, ...props }));
53
79
  Calendar.displayName = "Calendar";