@recursica/mantine-adapter 0.55.0 → 0.55.2

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 (38) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/index.d.ts +34 -9
  3. package/dist/mantine-adapter.cjs +2 -2
  4. package/dist/mantine-adapter.cjs.map +1 -1
  5. package/dist/mantine-adapter.css +1 -1
  6. package/dist/mantine-adapter.js +1767 -1728
  7. package/dist/mantine-adapter.js.map +1 -1
  8. package/package.json +2 -2
  9. package/src/components/Accordion/Accordion.stories.tsx +0 -24
  10. package/src/components/AssistiveElement/AssistiveElement.stories.tsx +3 -9
  11. package/src/components/Avatar/Avatar.stories.tsx +0 -15
  12. package/src/components/Breadcrumb/BREADCRUMB_IMPLEMENTATION_NOTES.md +33 -0
  13. package/src/components/Breadcrumb/Breadcrumb.module.css +20 -0
  14. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +55 -5
  15. package/src/components/Breadcrumb/Breadcrumb.tsx +17 -3
  16. package/src/components/Breadcrumb/markCurrentPageItem.test.ts +49 -0
  17. package/src/components/Button/Button.module.css +16 -0
  18. package/src/components/Button/Button.tsx +14 -1
  19. package/src/components/Button/IMPLEMENTATION_NOTES.md +31 -5
  20. package/src/components/DatePicker/DATEPICKER_IMPLEMENTATION_NOTES.md +2 -1
  21. package/src/components/DatePicker/DatePicker.tsx +22 -8
  22. package/src/components/Dropdown/Dropdown.stories.tsx +2 -8
  23. package/src/components/Dropdown/Dropdown.tsx +2 -1
  24. package/src/components/FormControlLayout/FormControlLayout.stories.tsx +1 -1
  25. package/src/components/Grid/GRID_IMPLEMENTATION_NOTES.md +9 -0
  26. package/src/components/Grid/Grid.stories.tsx +24 -0
  27. package/src/components/Label/Label.stories.tsx +1 -1
  28. package/src/components/Link/IMPLEMENTATION_NOTES.md +1 -1
  29. package/src/components/Link/Link.module.css +20 -0
  30. package/src/components/NumberInput/NUMBER_INPUT_IMPLEMENTATION_NOTES.md +6 -0
  31. package/src/components/NumberInput/NumberInput.module.css +11 -0
  32. package/src/components/Panel/Panel.tsx +1 -1
  33. package/src/components/Stepper/IMPLEMENTATION_NOTES.md +28 -0
  34. package/src/components/Stepper/Stepper.module.css +6 -6
  35. package/src/components/Tabs/IMPLEMENTATION_NOTES.md +27 -0
  36. package/src/components/Tabs/Tabs.module.css +30 -9
  37. package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +31 -0
  38. package/src/components/TimePicker/TimePicker.module.css +30 -3
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "git+https://github.com/borderux/recursica.git",
14
14
  "directory": "packages/mantine-adapter"
15
15
  },
16
- "version": "0.55.0",
16
+ "version": "0.55.2",
17
17
  "type": "module",
18
18
  "main": "./dist/mantine-adapter.cjs",
19
19
  "module": "./dist/mantine-adapter.js",
@@ -103,7 +103,7 @@
103
103
  "vitest": "^3.2.4"
104
104
  },
105
105
  "dependencies": {
106
- "@recursica/adapter-common": "^0.28.0"
106
+ "@recursica/adapter-common": "^0.28.2"
107
107
  },
108
108
  "peerDependencies": {
109
109
  "@mantine/core": "^8.0.0",
@@ -1,6 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Accordion } from "./Accordion";
3
- import { Layer } from "@recursica/adapter-common";
4
3
 
5
4
  const SVGIcon = () => (
6
5
  <svg
@@ -188,26 +187,3 @@ export const Disabled: StoryObj<typeof Accordion> = {
188
187
  );
189
188
  },
190
189
  };
191
-
192
- /** Demonstrates the component nested inside a non-default layer — the one case where an
193
- * explicit `<Layer>` wrap belongs in a story (see COMPONENT_STORYBOOK_GUIDE.md §9). */
194
- export const LayerOne: StoryObj<typeof Accordion> = {
195
- render: () => {
196
- return (
197
- <Layer layer={1} style={{ padding: "24px" }}>
198
- <Accordion defaultValue="demo" chevron={<ChevronIcon />}>
199
- <Accordion.Item value="demo">
200
- <Accordion.Control leftIcon={<SVGIcon />}>
201
- Layer 1 Render Engine
202
- </Accordion.Control>
203
- <Accordion.Panel>
204
- This Accordion dynamically updates its colors, borders, and
205
- typography variables because it is wrapped securely by the
206
- simulated `data-recursica-layer="1"`.
207
- </Accordion.Panel>
208
- </Accordion.Item>
209
- </Accordion>
210
- </Layer>
211
- );
212
- },
213
- };
@@ -38,9 +38,7 @@ export const DefaultHelp: Story = {
38
38
  },
39
39
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
40
40
  render: ({ withLayer, layer, ...args }: any) => (
41
- <Layer layer={0} style={{ padding: "48px" }}>
42
- <AssistiveElement {...args} />
43
- </Layer>
41
+ <AssistiveElement {...args} />
44
42
  ),
45
43
  };
46
44
 
@@ -53,9 +51,7 @@ export const ErrorState: Story = {
53
51
  },
54
52
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
55
53
  render: ({ withLayer, layer, ...args }: any) => (
56
- <Layer layer={0} style={{ padding: "48px" }}>
57
- <AssistiveElement {...args} />
58
- </Layer>
54
+ <AssistiveElement {...args} />
59
55
  ),
60
56
  };
61
57
 
@@ -68,8 +64,6 @@ export const NoIconHelp: Story = {
68
64
  },
69
65
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
70
66
  render: ({ withLayer, layer, ...args }: any) => (
71
- <Layer layer={0} style={{ padding: "48px" }}>
72
- <AssistiveElement {...args} />
73
- </Layer>
67
+ <AssistiveElement {...args} />
74
68
  ),
75
69
  };
@@ -1,7 +1,6 @@
1
1
  import React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { Avatar } from "./Avatar";
4
- import { Layer } from "@recursica/adapter-common";
5
4
 
6
5
  type AvatarStoryProps = React.ComponentProps<typeof Avatar>;
7
6
 
@@ -91,17 +90,3 @@ export const IconSmallGhost: Story = {
91
90
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
92
91
  render: ({ withLayer, layer, ...args }: any) => <Avatar {...args} />,
93
92
  };
94
-
95
- export const LayerOneOutline: Story = {
96
- args: {
97
- children: "L1",
98
- variant: "outline",
99
- size: "default",
100
- },
101
- // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
102
- render: ({ withLayer, layer, ...args }: any) => (
103
- <Layer layer={1} style={{ padding: "24px", display: "inline-block" }}>
104
- <Avatar {...args} />
105
- </Layer>
106
- ),
107
- };
@@ -12,3 +12,36 @@ We attach `gap` to `.root` directly within `.module.css`. Mantine's inner `separ
12
12
 
13
13
  We preserve standard hierarchical composition based on Mantine's defaults. The component natively accepts typical anchor tags as children without wrapping them in structural spans, allowing developers to utilize `Link` wrappers as needed contextually.
14
14
  The separator character is inherited natively from Mantine's default configuration (`/`) unless overridden by passing `separator={<Icon />}` to the component props.
15
+
16
+ ## Current-page Crumb: Best-effort Enforcement, Not a Guarantee
17
+
18
+ **The last crumb should always be plain, non-interactive text — a `<span>`, not a `Link`.** It
19
+ represents the current page; per standard breadcrumb UX and the ARIA breadcrumb pattern, it
20
+ shouldn't be clickable. Passing a `Link` (or an anchor) for the last item is a misuse of the
21
+ component, even though `Breadcrumb.tsx` tries to correct for it — see below.
22
+
23
+ Breadcrumb intentionally keeps the flexible Mantine `children`-as-anchors interface (unlike BEAM's
24
+ `Breadcrumb.Item`, which renders its own `Link` internally and can't accept a caller-supplied
25
+ router-aware Link or `onClick`). That flexibility means Breadcrumb.tsx can't fully own what the
26
+ last item renders as, so it does what it can via `markCurrentPageItem` (`@recursica/adapter-common`):
27
+
28
+ - Adds `aria-current="page"` to the last child, always.
29
+ - If that child looks interactive (has `href` or `onClick`), strips both and sets `tabIndex={-1}`.
30
+ - In dev, warns to the console when it had to strip anything — a signal the caller should switch
31
+ that item to plain text instead of relying on this fallback.
32
+
33
+ Backed up by a CSS rule targeting `[aria-current="page"]` (Breadcrumb.module.css) that resets
34
+ color/text-decoration and sets `pointer-events: none`, and sets `font-family`/`font-size` to Link's own tokens
35
+ (per product decision: the current item should typographically match Link, just without Link's
36
+ color/decoration/interactivity).
37
+
38
+ **Why this isn't a real guarantee:** `cloneElement` can only override props an element actually
39
+ reads. A custom Link component (e.g. React Router's `<Link to="...">`) that navigates from its own
40
+ internal handler, independent of a passed-through `onClick`, will still navigate — stripping
41
+ `onClick`/`href` here doesn't touch that internal logic, and `pointer-events: none` only blocks
42
+ mouse activation, not keyboard/Enter on a focusable custom element. Treat all of the above as a
43
+ safety net for the common case (plain anchors, our own `Link`), not a substitute for callers doing
44
+ the right thing and passing plain text for the last crumb.
45
+
46
+ The story's `items` convenience prop demonstrates the correct pattern: every crumb except the last
47
+ is wrapped in `Link`, and the last is a plain `<span>`.
@@ -26,3 +26,23 @@
26
26
  /* Neutralize Mantine's built-in margin-inline (--mantine-spacing-xs default) so .root's gap alone controls spacing */
27
27
  margin-inline: 0;
28
28
  }
29
+
30
+ /* Current-page item (last crumb): Breadcrumb.tsx (via markCurrentPageItem) marks it
31
+ aria-current="page". This is a style-layer backstop in case that item still renders as a Link
32
+ despite having its href/onClick stripped — see BREADCRUMB_IMPLEMENTATION_NOTES.md. Two classes'
33
+ worth of specificity beats Link's single .root color/text-decoration rule regardless of module
34
+ load order. pointer-events only blocks mouse activation, not a router Link's own internal
35
+ handler — the last crumb should really just be plain text. */
36
+ .root [aria-current="page"] {
37
+ color: inherit;
38
+ text-decoration: none;
39
+ pointer-events: none;
40
+
41
+ /* Per product decision: the current-page item should match Link's font-family/size (it's
42
+ typically rendered as plain text, with no font styling of its own — otherwise it falls back
43
+ to Mantine's default font-size) but not any of Link's other styling. */
44
+ font-family: var(
45
+ --recursica_ui-kit_components_link_properties_text_font-family
46
+ );
47
+ font-size: var(--recursica_ui-kit_components_link_properties_text_font-size);
48
+ }
@@ -11,6 +11,18 @@ const meta: Meta<BreadcrumbStoryProps> = {
11
11
  title: "UI-Kit/Breadcrumb",
12
12
  component: Breadcrumb,
13
13
  tags: ["autodocs"],
14
+ parameters: {
15
+ docs: {
16
+ description: {
17
+ component:
18
+ "**Proper usage** (see `Default`): every crumb except the last is a `Link`; the last " +
19
+ "crumb — the current page — is plain text (e.g. a `<span>`), not a link. `Breadcrumb` " +
20
+ "does its best to neutralize a `Link` passed as the last item anyway (see " +
21
+ "`LastItemAsLink`), but that's a safety net, not something to rely on — see " +
22
+ "BREADCRUMB_IMPLEMENTATION_NOTES.md.",
23
+ },
24
+ },
25
+ },
14
26
  argTypes: {
15
27
  children: {
16
28
  table: {
@@ -35,11 +47,17 @@ const meta: Meta<BreadcrumbStoryProps> = {
35
47
  },
36
48
  render: ({ items, children, ...args }) => {
37
49
  const mappedChildren = items
38
- ? items.map((label, index) => (
39
- <Link href="#" key={index}>
40
- {label}
41
- </Link>
42
- ))
50
+ ? items.map((label, index) =>
51
+ // The last crumb represents the current page, so it's plain text,
52
+ // not a link — matches standard breadcrumb UX.
53
+ index === items.length - 1 ? (
54
+ <span key={index}>{label}</span>
55
+ ) : (
56
+ <Link href="#" key={index}>
57
+ {label}
58
+ </Link>
59
+ ),
60
+ )
43
61
  : children;
44
62
 
45
63
  return <Breadcrumb children={mappedChildren} {...args} />;
@@ -62,3 +80,35 @@ export const CustomSeparator: Story = {
62
80
  separator: "→",
63
81
  },
64
82
  };
83
+
84
+ export const LastItemAsLink: Story = {
85
+ name: "Last Item As Link (Incorrect Usage)",
86
+ args: {
87
+ // Bypass the `items` convenience prop (which always renders the last crumb as a span) to
88
+ // demonstrate what happens if a caller wraps every crumb, including the last, in a `Link`.
89
+ items: undefined,
90
+ children: [
91
+ <Link href="#" key="dashboard">
92
+ Dashboard
93
+ </Link>,
94
+ <Link href="#" key="settings">
95
+ Settings
96
+ </Link>,
97
+ <Link href="#" key="security">
98
+ Security
99
+ </Link>,
100
+ ],
101
+ },
102
+ parameters: {
103
+ docs: {
104
+ description: {
105
+ story:
106
+ "Incorrect usage, kept as a story to prove Breadcrumb still handles it: the last " +
107
+ 'crumb is a `Link`, but Breadcrumb adds `aria-current="page"`, strips its `href`/' +
108
+ "`onClick`, drops it from the tab order, and the CSS reset removes Link's color/" +
109
+ "underline — it renders identically to `Default`'s plain-text current item. Don't " +
110
+ "rely on this; pass plain text for the last crumb instead.",
111
+ },
112
+ },
113
+ },
114
+ };
@@ -12,16 +12,30 @@ import styles from "./Breadcrumb.module.css";
12
12
 
13
13
  // Currently there are no Recursica prop additions specific to Breadcrumbs (like size or variant)
14
14
  // according to recursica_ui-kit.json
15
- import { type RecursicaBreadcrumbProps } from "@recursica/adapter-common";
15
+ import {
16
+ markCurrentPageItem,
17
+ type RecursicaBreadcrumbProps,
18
+ } from "@recursica/adapter-common";
16
19
 
17
20
  export type BreadcrumbProps = RecursicaOverStyled<
18
21
  Omit<MantineBreadcrumbsProps, "variant" | "size"> & RecursicaBreadcrumbProps
19
22
  >;
20
23
 
24
+ /**
25
+ * The last child should always be plain, non-interactive text (e.g. a `<span>`) — it represents
26
+ * the current page and shouldn't be a link. Breadcrumb tries to correct for a Link/anchor passed
27
+ * there anyway via `markCurrentPageItem` (aria-current, stripped href/onClick, CSS reset in
28
+ * Breadcrumb.module.css), but that's a best-effort safety net, not a guarantee — it can't stop a
29
+ * custom Link component (e.g. a router Link) that navigates from its own internal handler. See
30
+ * BREADCRUMB_IMPLEMENTATION_NOTES.md.
31
+ */
21
32
  export const Breadcrumb = forwardRef<HTMLDivElement, BreadcrumbProps>(
22
- function Breadcrumb({ overStyled = false, separator = ">", ...rest }, ref) {
33
+ function Breadcrumb(
34
+ { overStyled = false, separator = ">", children, ...rest },
35
+ ref,
36
+ ) {
23
37
  const sanitizedProps = filterStylingProps(
24
- { separator, ...rest },
38
+ { separator, children: markCurrentPageItem(children), ...rest },
25
39
  overStyled,
26
40
  );
27
41
 
@@ -0,0 +1,49 @@
1
+ import { createElement, type ReactElement } from "react";
2
+ import { describe, it, expect } from "vitest";
3
+ import { markCurrentPageItem } from "@recursica/adapter-common";
4
+
5
+ type Props = Record<string, unknown>;
6
+
7
+ describe("markCurrentPageItem", () => {
8
+ it("marks the last item aria-current=page and leaves earlier items untouched", () => {
9
+ const items = markCurrentPageItem([
10
+ createElement("a", { href: "/one" }, "One"),
11
+ createElement("a", { href: "/two" }, "Two"),
12
+ createElement("span", {}, "Three"),
13
+ ]) as ReactElement<Props>[];
14
+
15
+ expect(items[0].props.href).toBe("/one");
16
+ expect(items[0].props["aria-current"]).toBeUndefined();
17
+ expect(items[1].props.href).toBe("/two");
18
+ expect(items[1].props["aria-current"]).toBeUndefined();
19
+ expect(items[2].props["aria-current"]).toBe("page");
20
+ });
21
+
22
+ it("strips href/onClick and drops tab order on an interactive last item", () => {
23
+ const onClick = () => {};
24
+ const [last] = markCurrentPageItem([
25
+ createElement("a", { href: "/current", onClick }, "Current"),
26
+ ]) as ReactElement<Props>[];
27
+
28
+ expect(last.props.href).toBeUndefined();
29
+ expect(last.props.onClick).toBeUndefined();
30
+ expect(last.props.tabIndex).toBe(-1);
31
+ expect(last.props["aria-current"]).toBe("page");
32
+ });
33
+
34
+ it("leaves a non-interactive last item's other props untouched", () => {
35
+ const [last] = markCurrentPageItem([
36
+ createElement("span", { className: "current" }, "Current"),
37
+ ]) as ReactElement<Props>[];
38
+
39
+ expect(last.props.className).toBe("current");
40
+ expect(last.props.tabIndex).toBeUndefined();
41
+ expect(last.props["aria-current"]).toBe("page");
42
+ });
43
+
44
+ it("passes through non-element children (e.g. plain text) unchanged", () => {
45
+ const items = markCurrentPageItem(["First", "Last"]);
46
+
47
+ expect(items).toEqual(["First", "Last"]);
48
+ });
49
+ });
@@ -334,6 +334,14 @@
334
334
  color: var(
335
335
  --recursica_ui-kit_components_button_variants_styles_solid_properties_colors_text-color
336
336
  );
337
+ /* Exposed so the loading-state Loader (color="var(--button-color)" in Button.tsx) matches
338
+ this exact text-color token instead of Mantine's own unrelated --button-color default.
339
+ !important: Mantine's own Button sets --button-color via an inline `style` attribute on
340
+ this same root element, which normal-weight stylesheet rules can never outrank regardless
341
+ of selector specificity (see IMPLEMENTATION_NOTES.md). */
342
+ --button-color: var(
343
+ --recursica_ui-kit_components_button_variants_styles_solid_properties_colors_text-color
344
+ ) !important;
337
345
 
338
346
  .iconWrapper {
339
347
  color: var(
@@ -367,6 +375,10 @@
367
375
  color: var(
368
376
  --recursica_ui-kit_components_button_variants_styles_outline_properties_colors_text-color
369
377
  );
378
+ /* !important: see solid variant's identical comment above. */
379
+ --button-color: var(
380
+ --recursica_ui-kit_components_button_variants_styles_outline_properties_colors_text-color
381
+ ) !important;
370
382
 
371
383
  .iconWrapper {
372
384
  color: var(
@@ -400,6 +412,10 @@
400
412
  color: var(
401
413
  --recursica_ui-kit_components_button_variants_styles_text_properties_colors_text-color
402
414
  );
415
+ /* !important: see solid variant's identical comment above. */
416
+ --button-color: var(
417
+ --recursica_ui-kit_components_button_variants_styles_text_properties_colors_text-color
418
+ ) !important;
403
419
 
404
420
  .iconWrapper {
405
421
  color: var(
@@ -117,8 +117,21 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
117
117
 
118
118
  let mergedLoaderProps = userLoaderProps;
119
119
  if (useRecursicaLoader) {
120
+ // color="var(--button-color)" makes the loader ring match this button's own text color
121
+ // (Mantine's Button sets --button-color per variant/state) instead of Loader's own
122
+ // standalone indicator-color token, which Loader.module.css's `.root` would otherwise
123
+ // apply unconditionally, ignoring this nesting context. overStyled is required to pass
124
+ // `color` at all (blocked by default) — fully internal composition, no prop threading to
125
+ // callers, so it doesn't widen Loader's public API. See Button's IMPLEMENTATION_NOTES.md.
120
126
  mergedLoaderProps = {
121
- children: <Loader variant={loaderVariant} size={resolvedLoaderSize} />,
127
+ children: (
128
+ <Loader
129
+ variant={loaderVariant}
130
+ size={resolvedLoaderSize}
131
+ overStyled
132
+ color="var(--button-color)"
133
+ />
134
+ ),
122
135
  ...userLoaderProps,
123
136
  };
124
137
  }
@@ -48,11 +48,37 @@ Mantine's `.mantine-Button-label` flex centering breaks primitive truncation log
48
48
 
49
49
  ---
50
50
 
51
- ## Loader color contrast
52
-
53
- **Decision:** When a Button is in a loading state, the `Recursica Loader` component is injected. The `Loader` component strictly defines its own colors and styles per variant, meaning it does not inherit the text color (`currentColor`) from the Button.
54
-
55
- **Constraint:** This can lead to contrast issues (e.g., a blue dots loader inside a solid blue button). Design has explicitly decided not to address this at the moment. As such, developers using the `loading` prop must be aware that the loader's color is fixed by its internal tokens, not by the button's context.
51
+ ## Loader color contrast — fixed (source-of-truth audit, 2026-08-30)
52
+
53
+ **Superseded:** the entry below originally documented an explicit decision _not_ to fix this.
54
+ Re-verified during the mantine-vs-mui source-of-truth audit by measuring actual computed styles
55
+ in the `Loading` story: the loader ring rendered `#c21b43` (Loader's own standalone
56
+ `indicator-color` token) against a label text color of `rgb(249, 249, 249)` — a real, confirmed
57
+ mismatch on the `solid` variant, not a false report.
58
+
59
+ **Root cause:** `Loader.module.css`'s `.root` unconditionally sets `--loader-color:
60
+ var(--recursica_..._loader_properties_indicator-color)` directly on the Loader's own root
61
+ `<span>`. Mantine's own Button internally wraps `loaderProps.children` in a generic `<Loader
62
+ size color>` that sets `--loader-color: var(--button-color)` as an _inline_ style on an outer
63
+ wrapper div — but our own nested `<Loader>` (rendered as `loaderProps.children`) has an identical
64
+ class (`mantine-Loader-root`) on its own root, so its own stylesheet rule locally re-declares
65
+ `--loader-color`, shadowing the inherited value from that outer wrapper for anything computed on
66
+ or under our own element (CSS custom properties resolve against the nearest self-or-ancestor
67
+ declaration — a local re-declaration always wins over what an ancestor tried to pass down,
68
+ regardless of whether the ancestor's value came from an inline style or not).
69
+
70
+ **Fix:** pass `color="var(--button-color)"` to the Button-owned `<Loader>` — Mantine's own
71
+ `Loader` factory writes any `color` prop straight through `getThemeColor()`/`parseThemeColor()`
72
+ (safely falls through unresolved CSS values like a `var()` reference verbatim, per
73
+ `@mantine/core`'s own `parse-theme-color.cjs`) into an _inline_ `--loader-color` style on that
74
+ same root span — which, being inline, now wins over our own `.root` stylesheet declaration.
75
+ Also added `--button-color` (`!important`) to each variant's own block in `Button.module.css`,
76
+ set to that variant's own `colors_text-color` token — needed because Mantine's Button sets its
77
+ own generic `--button-color: var(--mantine-color-white)` via an inline style on the button root
78
+ itself, which our external stylesheet couldn't otherwise outrank at equal/lower priority
79
+ regardless of selector specificity. Verified live: loader ring color now matches the label text
80
+ color exactly (`rgb(249, 249, 249)`) across solid/outline/text variants, no visual regression on
81
+ non-loading stories.
56
82
 
57
83
  ---
58
84
 
@@ -7,7 +7,7 @@ The `DatePicker` component is a wrapper around the `@mantine/dates` `DatePickerI
7
7
  ## Structural Constraints
8
8
 
9
9
  1. **Naked Input Usage**: We intentionally pass `label={undefined}`, `description={undefined}`, and `error={undefined}` to the Mantine `DatePickerInput` component. This suppresses Mantine's internal macro form wrapping and ensures that only our `WithReadOnlyWrapper` > `FormControlWrapper` orchestrates labels, description text, and ARIA state error boundaries.
10
- 2. **Read-Only Implementation**: Since the value type for `DatePickerInput` can be a date object, string, or array, the `WithReadOnlyWrapper` attempts to safely cast the output value using standard `String(value)`. For production apps utilizing heavy date formatting logic, developers can pass a custom `readOnlyComponent` explicitly to bypass this default cast.
10
+ 2. **Read-Only Implementation**: `readOnlyValue` is formatted via `@mantine/dates`' own `getFormattedDate` helper using the same `valueFormat` the editable `DatePickerInput` renders with so a read-only date reads identically to that same value while editable (`05/20/26`, not a raw JS `Date.toString()`). Developers can still pass a custom `readOnlyComponent` to fully replace this rendering.
11
11
  3. **Calendar Portal/Dropdown (Figma Token Issue)**: The Recursica UI Kit's `date-picker` component in Figma fails to export any explicit structural or color properties for the calendar popover itself (e.g. elevation, surface background, selected day colors, hover, today, in-range). To solve this organically within the framework without breaking strict token adherence, we manually map every calendar state to the closest existing Recursica token instead of leaving any of it on Mantine's own un-tokenized defaults:
12
12
  - **Surface** (`.dropdown`) & **selected day** (`.day[data-selected]`): `--recursica_ui-kit_components_hover-card-popover` tokens for elevation/padding/surface, `--recursica_ui-kit_components_button_variants_styles_solid` tokens for the selected day fill (unchanged from before).
13
13
  - **Header content, weekday row** (`.calendarHeader`, `.weekday`): the hover-card-popover content-color token — previously unmapped, so these leaked Mantine's own text colors.
@@ -28,3 +28,4 @@ The `DatePicker` component is a wrapper around the `@mantine/dates` `DatePickerI
28
28
  - **Default placeholder**: `placeholder="MM / DD / YY"`, set the same way (before `sanitizedProps`, so consumers can override).
29
29
  - **Single-line enforcement (bug fix)**: `DatePickerInput` renders its value/placeholder as a `<button>`'s text content, not a native `<input>` — it wraps like any other block text by default. A long placeholder (e.g. the original story's `"Select a deadline..."`) wrapped to two lines, inflating the field's height well past its `min-height` token and making the field visually look narrower than its actual `width` token value even though the box itself was always the right size. Fixed with `white-space: nowrap`/`text-overflow: ellipsis`/`overflow-x: clip`/`overflow-y: visible` on `.input`, the same convention `FileInput.module.css`'s `.value` uses.
30
30
  - **Day focus ring (bug fix)**: Mantine's `Day` ships no built-in `:focus-visible` style of its own (unlike `.calendarHeaderControl`/`.calendarHeaderLevel`, which are styled as `Button` and inherited a focus ring from that treatment), so a focused day — e.g. the auto-focused selected/today day when the calendar opens — fell through to the browser's native blue outline instead of a Recursica token. Fixed by adding `.day:focus-visible` with the same generic `--recursica_brand_states_focus_*` box-shadow ring already used by `.input` and `.calendarHeaderControl`/`.calendarHeaderLevel`. mui-adapter's `.MuiPickerDay-root.Mui-focusVisible` already had this — only the mantine-adapter side was missing it.
31
+ - **Read-only value format (bug fix, 2026-08-30)**: `readOnlyValue` used to be a bare `String(value)`/`String(defaultValue)` cast — `Date`'s own `toString()`, e.g. `"Wed May 20 2026 17:00:00 GMT-0700 (Pacific Daylight Time)"` — completely ignoring `valueFormat`, so `ui-kit-datepicker--static-read-only` rendered a totally different (and far less usable) date string than the same value shown editable, or than mui-adapter's equivalent (which already formats its read-only text via dayjs). Fixed by running the value through `@mantine/dates`' exported `getFormattedDate({ type: "default", date, locale: "en", format: valueFormat, labelSeparator: "" })` — the same formatter `DatePickerInput` uses internally — so read-only and editable rendering of the same value always agree.
@@ -1,5 +1,9 @@
1
1
  import React, { forwardRef } from "react";
2
- import { DatePickerInput, type DatePickerInputProps } from "@mantine/dates";
2
+ import {
3
+ DatePickerInput,
4
+ type DatePickerInputProps,
5
+ getFormattedDate,
6
+ } from "@mantine/dates";
3
7
  import { type ReadOnlyControlProps } from "@recursica/adapter-common";
4
8
  import {
5
9
  filterStylingProps,
@@ -161,13 +165,23 @@ export const DatePicker = forwardRef<HTMLButtonElement, DatePickerProps>(
161
165
  readOnlyComponent={readOnlyComponent}
162
166
  emptyValueComponent={emptyValueComponent}
163
167
  readOnlyType="text"
164
- readOnlyValue={
165
- value !== undefined
166
- ? String(value)
167
- : defaultValue
168
- ? String(defaultValue)
169
- : undefined
170
- }
168
+ readOnlyValue={(() => {
169
+ const readOnlyDate = value !== undefined ? value : defaultValue;
170
+ // `DatePickerInput` formats its own displayed value via `valueFormat` internally
171
+ // (using this same `getFormattedDate` helper) — the read-only text swap-in bypassed
172
+ // that entirely and rendered `String(date)` instead (a raw JS `Date.toString()`, e.g.
173
+ // "Wed May 20 2026 17:00:00 GMT-0700 (Pacific Daylight Time)"), so a read-only
174
+ // DatePicker never matched what the same value looks like while editable.
175
+ return readOnlyDate
176
+ ? getFormattedDate({
177
+ type: "default",
178
+ date: readOnlyDate,
179
+ locale: "en",
180
+ format: valueFormat,
181
+ labelSeparator: "",
182
+ })
183
+ : undefined;
184
+ })()}
171
185
  readOnlyNativeProps={props}
172
186
  activeComponent={
173
187
  /* Naked Input execution safely decoupled from Mantine's macro Input.Wrapper DOM hooks */
@@ -27,7 +27,6 @@ const meta: Meta<DropdownStoryProps> = {
27
27
  disabled: false,
28
28
  required: false,
29
29
  readOnly: false,
30
- searchable: false,
31
30
  clearable: false,
32
31
  },
33
32
  argTypes: {
@@ -38,9 +37,6 @@ const meta: Meta<DropdownStoryProps> = {
38
37
  readOnly: {
39
38
  control: "boolean",
40
39
  },
41
- searchable: {
42
- control: "boolean",
43
- },
44
40
  clearable: {
45
41
  control: "boolean",
46
42
  },
@@ -63,12 +59,10 @@ export const Default: Story = {
63
59
  args: {},
64
60
  };
65
61
 
66
- export const SearchableClearable: Story = {
62
+ export const Clearable: Story = {
67
63
  args: {
68
- label: "Search & Clear Options",
69
- searchable: true,
64
+ label: "Clearable Options",
70
65
  clearable: true,
71
- placeholder: "Start typing...",
72
66
  },
73
67
  };
74
68
 
@@ -23,7 +23,7 @@ import { type RecursicaDropdownProps as BaseRecursicaDropdownProps } from "@recu
23
23
  export interface RecursicaDropdownProps
24
24
  extends Omit<
25
25
  MantineSelectProps,
26
- "size" | "variant" | "radius" | "wrapperProps" | "data"
26
+ "size" | "variant" | "radius" | "wrapperProps" | "data" | "searchable"
27
27
  >,
28
28
  Omit<
29
29
  RecursicaFormControlWrapperProps,
@@ -40,6 +40,7 @@ const UNSUPPORTED_PROPS = [
40
40
  "size", // Recursica controls sizing via the `size` variant + design tokens, not raw dimensions.
41
41
  "variant", // Colors/variants are token-driven; the library's native palette isn't exposed.
42
42
  "radius", // Recursica does not expose the library's native corner-radius system.
43
+ "searchable", // Not a supported Dropdown feature — that's what makes it an AutoComplete.
43
44
  ] as const satisfies readonly (keyof MantineSelectProps)[];
44
45
 
45
46
  export const Dropdown = forwardRef<HTMLInputElement, DropdownProps>(
@@ -23,7 +23,7 @@ const meta: Meta<typeof FormControlLayout> = {
23
23
  },
24
24
  labelSize: {
25
25
  control: "radio",
26
- options: ["default", "small", "md"],
26
+ options: ["default", "small"],
27
27
  description: "Dictates the physical width of the left column.",
28
28
  },
29
29
  children: {
@@ -7,3 +7,12 @@ Per the layout-components rule (no formal Recursica common props definition —
7
7
  `Grid.Col`'s `span`, `offset`, `order`, `visibleFrom`, and `hiddenFrom` props are passed straight through to Mantine's `Grid.Col` unchanged, using Mantine's own breakpoint scale (`xs`/`sm`/`md`/`lg`/`xl` — there is no `base` breakpoint in Mantine; `xs` is the smallest).
8
8
 
9
9
  Like the other primitive layout components, `Grid` and `Grid.Col` use `WithRecursicaSpacing<T>` rather than `RecursicaOverStyled<T>` and are listed in the "Primitive Layout Components Exemption" section of `OVERSTYLING.md`.
10
+
11
+ ## Added `ResponsiveSizes` story (2026-08-30, source-of-truth audit)
12
+
13
+ mui-adapter has a `ui-kit-grid--responsive-sizes` story with no counterpart here — its `story
14
+ parity with source of truth` check would otherwise fail for mui-adapter. Added an equivalent
15
+ `ResponsiveSizes` story using Mantine's own `span` prop (Mantine's `Grid.Col` has no `size` prop
16
+ — see the naming-divergence note above and mui-adapter's own `IMPLEMENTATION_NOTES.md`). This is
17
+ functionally identical to the existing `ResponsiveSpans` story above; it exists purely so
18
+ mui-adapter's differently-named story id has a source-of-truth golden to diff against.
@@ -113,6 +113,30 @@ export const ResponsiveSpans: Story = {
113
113
  ),
114
114
  };
115
115
 
116
+ // Mirrors mui-adapter's `ResponsiveSizes` story (same breakpoints/content) under mantine's own
117
+ // prop name — Mantine's Grid.Col has no `size` prop, only `span`, which is what `ResponsiveSpans`
118
+ // above already exercises. This one exists purely so mui-adapter's `responsive-sizes` story id
119
+ // has a source-of-truth counterpart to diff against (see Grid's IMPLEMENTATION_NOTES.md).
120
+ export const ResponsiveSizes: Story = {
121
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
122
+ render: ({ withLayer, layer, ...args }: any) => (
123
+ <Grid {...args}>
124
+ <Grid.Col span={{ xs: 12, sm: 6, md: 3 }}>
125
+ <Swatch>xs 12 / sm 6 / md 3</Swatch>
126
+ </Grid.Col>
127
+ <Grid.Col span={{ xs: 12, sm: 6, md: 3 }}>
128
+ <Swatch>xs 12 / sm 6 / md 3</Swatch>
129
+ </Grid.Col>
130
+ <Grid.Col span={{ xs: 12, sm: 6, md: 3 }}>
131
+ <Swatch>xs 12 / sm 6 / md 3</Swatch>
132
+ </Grid.Col>
133
+ <Grid.Col span={{ xs: 12, sm: 6, md: 3 }}>
134
+ <Swatch>xs 12 / sm 6 / md 3</Swatch>
135
+ </Grid.Col>
136
+ </Grid>
137
+ ),
138
+ };
139
+
116
140
  export const Offset: Story = {
117
141
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
118
142
  render: ({ withLayer, layer, ...args }: any) => (
@@ -20,7 +20,7 @@ const meta: Meta<LabelStoryProps> = {
20
20
  argTypes: {
21
21
  labelSize: {
22
22
  control: "inline-radio",
23
- options: ["default", "small", "md"],
23
+ options: ["default", "small"],
24
24
  description:
25
25
  "Sizing metrics for the Label. Only visually distinguishable once composed inside a `side-by-side` FormControlLayout, which is where the resulting width constraint applies.",
26
26
  },
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## States in Design Tokens
4
4
 
5
- The design tokens (`recursica_variables_scoped.css`) provide the base link styling directly on `--recursica_ui-kit_components_link_properties_*` (no distinct `default` state), plus a `visited` variant that overrides only `colors_text-color`/`colors_icon-color`. Hover is not a per-component `link_variants_states_hover_*` token; it's driven by the brand-level `--recursica_brand_states_link_decoration` token (resolves to `underline`), applied on `.root:hover`. Earlier this token was exported as an invalid quoted string (`"underline"`), which silently failed and was mistaken for the token not existing at all — now fixed upstream to a bare keyword. There are no tokens for `active` or `focus` either; the component relies on the browser's default focus outline for accessibility unless overridden by a global reset.
5
+ The design tokens (`recursica_variables_scoped.css`) provide the base link styling directly on `--recursica_ui-kit_components_link_properties_*` (no distinct `default` state), plus a `visited` variant that overrides only `colors_text-color`/`colors_icon-color`. Hover is not a per-component `link_variants_states_hover_*` token; it's driven by the brand-level `--recursica_brand_states_link_decoration` token (resolves to `underline`), applied on `.root:hover`. Earlier this token was exported as an invalid quoted string (`"underline"`), which silently failed and was mistaken for the token not existing at all — now fixed upstream to a bare keyword. There is no token for `active`. There isn't a per-component `focus` token either, but focus isn't left to the browser default: like every other interactive component (see Button), `.root:focus-visible` replaces the native outline with the global `--recursica_brand_states_focus_*` ring. This was previously missing here — Link (and anything composing it, e.g. Breadcrumb) fell back to the browser's native focus outline instead.
6
6
 
7
7
  ## Overriding Mantine's underline Prop
8
8