@recursica/mui-adapter 0.37.0 → 0.38.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.
- package/CHANGELOG.md +15 -0
- package/dist/index.d.ts +10 -0
- package/dist/mui-adapter.cjs +32 -32
- package/dist/mui-adapter.cjs.map +1 -1
- package/dist/mui-adapter.css +1 -1
- package/dist/mui-adapter.js +1381 -1357
- package/dist/mui-adapter.js.map +1 -1
- package/package.json +4 -2
- package/src/components/Label/Label.stories.tsx +63 -54
- package/src/components/Loader/Loader.animate.dom.test.tsx +72 -0
- package/src/components/Loader/Loader.module.css +10 -0
- package/src/components/Loader/Loader.stories.tsx +20 -0
- package/src/components/Loader/Loader.tsx +8 -1
- package/src/components/Table/TABLE_IMPLEMENTATION_NOTES.md +11 -0
- package/src/components/Table/Table.module.css +14 -0
- package/src/components/Table/Table.stories.tsx +1 -1
- package/src/components/Table/USAGE.md +2 -2
- package/src/components/TimePicker/TIMEPICKER_IMPLEMENTATION_NOTES.md +8 -1
- package/src/components/TimePicker/TimePicker.module.css +43 -0
- package/src/components/TimePicker/TimePicker.stories.tsx +22 -0
- package/src/components/TimePicker/TimePicker.tsx +24 -6
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "git+https://github.com/borderux/recursica.git",
|
|
14
14
|
"directory": "packages/mui-adapter"
|
|
15
15
|
},
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.38.0",
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
"prebuild": "npm run analyze-tokens",
|
|
67
67
|
"adapter-tester": "adapter-tester --serve",
|
|
68
68
|
"adapter-tester:automated": "adapter-tester",
|
|
69
|
+
"adapter-tester:update-golden": "adapter-tester --update-golden",
|
|
70
|
+
"adapter-tester:source-of-truth": "adapter-tester --divergence-only",
|
|
69
71
|
"test": "vitest run --project unit",
|
|
70
72
|
"test:dom": "vitest run --project dom"
|
|
71
73
|
},
|
|
@@ -111,7 +113,7 @@
|
|
|
111
113
|
"vitest": "^3.2.4"
|
|
112
114
|
},
|
|
113
115
|
"dependencies": {
|
|
114
|
-
"@recursica/adapter-common": "^0.
|
|
116
|
+
"@recursica/adapter-common": "^0.27.0",
|
|
115
117
|
"dayjs": "^1.11.21"
|
|
116
118
|
},
|
|
117
119
|
"peerDependencies": {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
1
|
import React from "react";
|
|
3
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
4
3
|
import { Label } from "./Label";
|
|
5
|
-
import {
|
|
6
|
-
import { formControlArgTypes } from "../../../.storybook/commonArgTypes";
|
|
4
|
+
import { Button } from "../Button/Button";
|
|
7
5
|
|
|
8
6
|
type LabelStoryProps = React.ComponentProps<typeof Label>;
|
|
9
7
|
|
|
@@ -15,12 +13,43 @@ const meta: Meta<LabelStoryProps> = {
|
|
|
15
13
|
docs: {
|
|
16
14
|
description: {
|
|
17
15
|
component:
|
|
18
|
-
"The `Label` component is a strict Recursica-styled wrapper around Mantine's native `Input.Label`. It serves as the primary compositional primitive for all form fields, preserving Mantine's accessibility associations and context while strictly enforcing the Recursica atomic design system.\n\n### Usage with Form Inputs\
|
|
16
|
+
"The `Label` component is a strict Recursica-styled wrapper around Mantine's native `Input.Label`. It serves as the primary compositional primitive for all form fields, preserving Mantine's accessibility associations and context while strictly enforcing the Recursica atomic design system.\n\n### Usage with Form Inputs\nThis component only renders the label itself — layout concerns like `stacked` vs `side-by-side` positioning relative to an input live on `FormControlLayout`/`FormControlWrapper`, not here. Render this `Label` in isolation to verify its own states, or see `UI-Kit/FormControlLayout` for how it composes into a full form field.",
|
|
19
17
|
},
|
|
20
18
|
},
|
|
21
19
|
},
|
|
22
20
|
argTypes: {
|
|
23
|
-
|
|
21
|
+
labelSize: {
|
|
22
|
+
control: "inline-radio",
|
|
23
|
+
options: ["default", "small", "md"],
|
|
24
|
+
description:
|
|
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
|
+
},
|
|
27
|
+
labelAlignment: {
|
|
28
|
+
control: "inline-radio",
|
|
29
|
+
options: ["left", "right"],
|
|
30
|
+
description: "Text alignment of the label content.",
|
|
31
|
+
},
|
|
32
|
+
required: {
|
|
33
|
+
control: "boolean",
|
|
34
|
+
description:
|
|
35
|
+
"Renders the required asterisk (suppressed automatically when `labelWithEditIcon` is set, and mutually exclusive with `labelOptionalText`).",
|
|
36
|
+
},
|
|
37
|
+
labelOptionalText: {
|
|
38
|
+
control: "text",
|
|
39
|
+
description:
|
|
40
|
+
"Secondary text rendered beneath the label. Pass `true` for the default '(Optional)' string, or a custom node/string. Suppressed when `required` is true.",
|
|
41
|
+
},
|
|
42
|
+
labelWithEditIcon: {
|
|
43
|
+
control: "boolean",
|
|
44
|
+
description:
|
|
45
|
+
"Replaces the default edit icon slot with an interactive edit affordance; replaces the required asterisk visually when both are set.",
|
|
46
|
+
},
|
|
47
|
+
labelActionArea: {
|
|
48
|
+
table: { disable: true },
|
|
49
|
+
},
|
|
50
|
+
onLabelEditClick: {
|
|
51
|
+
table: { disable: true },
|
|
52
|
+
},
|
|
24
53
|
},
|
|
25
54
|
};
|
|
26
55
|
|
|
@@ -28,95 +57,75 @@ export default meta;
|
|
|
28
57
|
|
|
29
58
|
type Story = StoryObj<LabelStoryProps>;
|
|
30
59
|
|
|
31
|
-
// Utility mapping to pipe raw Label args structurally into TextField accurately
|
|
32
|
-
const renderWithTextField = ({ children, ...args }: LabelStoryProps) => (
|
|
33
|
-
<TextField
|
|
34
|
-
label={children as React.ReactNode}
|
|
35
|
-
placeholder="Form Control primitive mapped..."
|
|
36
|
-
{...(args as any)}
|
|
37
|
-
/>
|
|
38
|
-
);
|
|
39
|
-
|
|
40
60
|
export const Default: Story = {
|
|
41
61
|
args: {
|
|
42
|
-
children: "
|
|
43
|
-
|
|
62
|
+
children: "Label",
|
|
44
63
|
labelSize: "default",
|
|
45
64
|
labelAlignment: "left",
|
|
46
65
|
required: false,
|
|
47
66
|
labelOptionalText: "",
|
|
48
67
|
labelWithEditIcon: false,
|
|
49
68
|
},
|
|
50
|
-
render: renderWithTextField,
|
|
51
69
|
};
|
|
52
70
|
|
|
53
|
-
export const
|
|
71
|
+
export const Required: Story = {
|
|
54
72
|
args: {
|
|
55
|
-
children: "
|
|
73
|
+
children: "Required Field",
|
|
74
|
+
required: true,
|
|
56
75
|
},
|
|
57
|
-
render: renderWithTextField,
|
|
58
76
|
};
|
|
59
77
|
|
|
60
|
-
export const
|
|
78
|
+
export const RequiredSuppressesOptionalText: Story = {
|
|
61
79
|
args: {
|
|
62
|
-
children: "
|
|
63
|
-
|
|
80
|
+
children: "Full Name",
|
|
64
81
|
required: true,
|
|
82
|
+
labelOptionalText: "This should not render",
|
|
65
83
|
},
|
|
66
|
-
render: renderWithTextField,
|
|
67
84
|
};
|
|
68
85
|
|
|
69
|
-
export const
|
|
86
|
+
export const WithOptionalText: Story = {
|
|
70
87
|
args: {
|
|
71
|
-
children: "
|
|
72
|
-
|
|
73
|
-
labelWithEditIcon: true,
|
|
88
|
+
children: "Bio",
|
|
89
|
+
labelOptionalText: "Max 100 characters",
|
|
74
90
|
},
|
|
75
|
-
render: renderWithTextField,
|
|
76
91
|
};
|
|
77
92
|
|
|
78
|
-
export const
|
|
93
|
+
export const BooleanOptionalText: Story = {
|
|
79
94
|
args: {
|
|
80
|
-
children: "
|
|
81
|
-
|
|
82
|
-
labelSize: "default",
|
|
95
|
+
children: "Middle Initial",
|
|
96
|
+
labelOptionalText: true,
|
|
83
97
|
},
|
|
84
|
-
render: renderWithTextField,
|
|
85
98
|
};
|
|
86
99
|
|
|
87
|
-
export const
|
|
100
|
+
export const WithEditIcon: Story = {
|
|
88
101
|
args: {
|
|
89
|
-
children: "
|
|
90
|
-
|
|
91
|
-
required: true,
|
|
92
|
-
labelOptionalText: "This should not render",
|
|
102
|
+
children: "Shipping Address",
|
|
103
|
+
labelWithEditIcon: true,
|
|
93
104
|
},
|
|
94
|
-
render: renderWithTextField,
|
|
95
105
|
};
|
|
96
106
|
|
|
97
|
-
export const
|
|
107
|
+
export const RequiredWithEditIcon: Story = {
|
|
98
108
|
args: {
|
|
99
|
-
children: "
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
children: "Primary Network Node",
|
|
110
|
+
required: true,
|
|
111
|
+
labelWithEditIcon: true,
|
|
102
112
|
},
|
|
103
|
-
render: renderWithTextField,
|
|
104
113
|
};
|
|
105
114
|
|
|
106
|
-
export const
|
|
115
|
+
export const RightAligned: Story = {
|
|
107
116
|
args: {
|
|
108
|
-
children: "
|
|
109
|
-
|
|
110
|
-
labelWithEditIcon: true,
|
|
117
|
+
children: "Status",
|
|
118
|
+
labelAlignment: "right",
|
|
111
119
|
},
|
|
112
|
-
render: renderWithTextField,
|
|
113
120
|
};
|
|
114
121
|
|
|
115
|
-
export const
|
|
122
|
+
export const WithActionArea: Story = {
|
|
116
123
|
args: {
|
|
117
124
|
children: "Configuration",
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
labelActionArea: (
|
|
126
|
+
<Button variant="text" size="small">
|
|
127
|
+
Edit
|
|
128
|
+
</Button>
|
|
129
|
+
),
|
|
120
130
|
},
|
|
121
|
-
render: renderWithTextField,
|
|
122
131
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { flushSync } from "react-dom";
|
|
5
|
+
import { Loader } from "./Loader";
|
|
6
|
+
|
|
7
|
+
function mount(node: React.ReactElement): {
|
|
8
|
+
container: HTMLElement;
|
|
9
|
+
root: Root;
|
|
10
|
+
} {
|
|
11
|
+
const container = document.createElement("div");
|
|
12
|
+
document.body.appendChild(container);
|
|
13
|
+
const root = createRoot(container);
|
|
14
|
+
flushSync(() => root.render(node));
|
|
15
|
+
return { container, root };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function unmount({ container, root }: { container: HTMLElement; root: Root }) {
|
|
19
|
+
root.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `animate={false}` must deterministically freeze every variant — including
|
|
25
|
+
* the bars'/dots' individually-animated child spans, which the freeze rule
|
|
26
|
+
* can only reach via a descendant selector, not the `data-variant`-scoped
|
|
27
|
+
* rules the size/thickness CSS uses.
|
|
28
|
+
*/
|
|
29
|
+
describe("Loader animate prop", () => {
|
|
30
|
+
it.each(["oval", "bars", "dots"] as const)(
|
|
31
|
+
"freezes every animated element for variant=%s when animate is false, and animates by default",
|
|
32
|
+
(variant) => {
|
|
33
|
+
const animated = mount(<Loader variant={variant} />);
|
|
34
|
+
const frozen = mount(<Loader variant={variant} animate={false} />);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const animatedRoot =
|
|
38
|
+
animated.container.querySelector("[data-variant]")!;
|
|
39
|
+
const frozenRoot = frozen.container.querySelector("[data-variant]")!;
|
|
40
|
+
|
|
41
|
+
const animatedTargets = [
|
|
42
|
+
animatedRoot,
|
|
43
|
+
...Array.from(animatedRoot.querySelectorAll("*")),
|
|
44
|
+
];
|
|
45
|
+
const frozenTargets = [
|
|
46
|
+
frozenRoot,
|
|
47
|
+
...Array.from(frozenRoot.querySelectorAll("*")),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// At least one element (or the root's own ::after, for oval) actually
|
|
51
|
+
// animates by default — otherwise this test would trivially pass.
|
|
52
|
+
const hasAnimation = (el: Element, pseudo?: string) =>
|
|
53
|
+
getComputedStyle(el, pseudo).animationName !== "none";
|
|
54
|
+
const animatedHasMotion =
|
|
55
|
+
animatedTargets.some((el) => hasAnimation(el)) ||
|
|
56
|
+
hasAnimation(animatedRoot, "::after");
|
|
57
|
+
expect(animatedHasMotion).toBe(true);
|
|
58
|
+
|
|
59
|
+
// Frozen: nothing animates, root included, pseudo-element included.
|
|
60
|
+
for (const el of frozenTargets) {
|
|
61
|
+
expect(getComputedStyle(el).animationName).toBe("none");
|
|
62
|
+
}
|
|
63
|
+
expect(getComputedStyle(frozenRoot, "::after").animationName).toBe(
|
|
64
|
+
"none",
|
|
65
|
+
);
|
|
66
|
+
} finally {
|
|
67
|
+
unmount(animated);
|
|
68
|
+
unmount(frozen);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
});
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
box-sizing: border-box;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/* `animate={false}` — freezes every variant's animation (the oval's own
|
|
13
|
+
* spinning ::after, and the bars'/dots' individually-animated child spans)
|
|
14
|
+
* so the loader renders deterministically, e.g. for a visual-regression
|
|
15
|
+
* snapshot that would otherwise diff differently every run. */
|
|
16
|
+
.root[data-animate="false"],
|
|
17
|
+
.root[data-animate="false"] *,
|
|
18
|
+
.root[data-animate="false"]::after {
|
|
19
|
+
animation: none !important;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
/* data-size mapping */
|
|
13
23
|
|
|
14
24
|
/* SMALL */
|
|
@@ -41,6 +41,11 @@ const meta: Meta<LoaderStoryArgs> = {
|
|
|
41
41
|
description:
|
|
42
42
|
"Applies a wrapping context to observe rendering logic externally",
|
|
43
43
|
},
|
|
44
|
+
animate: {
|
|
45
|
+
control: "boolean",
|
|
46
|
+
description:
|
|
47
|
+
"Freezes the CSS animation when false — deterministic, for visual regression",
|
|
48
|
+
},
|
|
44
49
|
},
|
|
45
50
|
};
|
|
46
51
|
|
|
@@ -48,6 +53,11 @@ export default meta;
|
|
|
48
53
|
|
|
49
54
|
type Story = StoryObj<LoaderStoryArgs>;
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Animated — excluded from visual regression (`adapter-tester.config.json`),
|
|
58
|
+
* since a moving animation diffs differently every run. See the `Static*`
|
|
59
|
+
* stories below for the deterministic, visual-regression-covered equivalents.
|
|
60
|
+
*/
|
|
51
61
|
export const Default: Story = {
|
|
52
62
|
args: {
|
|
53
63
|
variant: "oval",
|
|
@@ -62,33 +72,43 @@ export const Default: Story = {
|
|
|
62
72
|
),
|
|
63
73
|
};
|
|
64
74
|
|
|
75
|
+
/** `animate: false` freezes the spin — deterministic for visual regression. */
|
|
65
76
|
export const StaticOvalDefault: Story = {
|
|
66
77
|
args: {
|
|
67
78
|
variant: "oval",
|
|
68
79
|
size: "default",
|
|
80
|
+
animate: false,
|
|
69
81
|
},
|
|
70
82
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
|
|
71
83
|
render: ({ withLayer, layer, ...args }: any) => <Loader {...args} />,
|
|
72
84
|
};
|
|
73
85
|
|
|
86
|
+
/** `animate: false` freezes the bars — deterministic for visual regression. */
|
|
74
87
|
export const StaticBarsLarge: Story = {
|
|
75
88
|
args: {
|
|
76
89
|
variant: "bars",
|
|
77
90
|
size: "large",
|
|
91
|
+
animate: false,
|
|
78
92
|
},
|
|
79
93
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
|
|
80
94
|
render: ({ withLayer, layer, ...args }: any) => <Loader {...args} />,
|
|
81
95
|
};
|
|
82
96
|
|
|
97
|
+
/** `animate: false` freezes the dots — deterministic for visual regression. */
|
|
83
98
|
export const StaticDotsSmall: Story = {
|
|
84
99
|
args: {
|
|
85
100
|
variant: "dots",
|
|
86
101
|
size: "sm",
|
|
102
|
+
animate: false,
|
|
87
103
|
},
|
|
88
104
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
|
|
89
105
|
render: ({ withLayer, layer, ...args }: any) => <Loader {...args} />,
|
|
90
106
|
};
|
|
91
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Animated — excluded from visual regression, same as `Default`; this one
|
|
110
|
+
* additionally demonstrates rendering inside a `layer={2}` context.
|
|
111
|
+
*/
|
|
92
112
|
export const LayerTwoOval: Story = {
|
|
93
113
|
args: {
|
|
94
114
|
variant: "oval",
|
|
@@ -12,7 +12,13 @@ export type LoaderProps = RecursicaOverStyled<
|
|
|
12
12
|
>;
|
|
13
13
|
|
|
14
14
|
export const Loader = forwardRef<HTMLSpanElement, LoaderProps>(function Loader(
|
|
15
|
-
{
|
|
15
|
+
{
|
|
16
|
+
variant = "oval",
|
|
17
|
+
size = "default",
|
|
18
|
+
animate = true,
|
|
19
|
+
overStyled = false,
|
|
20
|
+
...rest
|
|
21
|
+
},
|
|
16
22
|
ref,
|
|
17
23
|
) {
|
|
18
24
|
const mapSize = {
|
|
@@ -54,6 +60,7 @@ export const Loader = forwardRef<HTMLSpanElement, LoaderProps>(function Loader(
|
|
|
54
60
|
ref={ref}
|
|
55
61
|
data-variant={variant}
|
|
56
62
|
data-size={resolvedSize}
|
|
63
|
+
data-animate={animate}
|
|
57
64
|
{...sanitizedProps}
|
|
58
65
|
className={finalClass}
|
|
59
66
|
>
|
|
@@ -55,6 +55,17 @@ than Mantine's. `Table.module.css` resets `.row:global(.Mui-selected)` to
|
|
|
55
55
|
`background-color: transparent` so only the Recursica token color shows — same
|
|
56
56
|
override-the-library's-own-styling rule the canonical guide documents for hover.
|
|
57
57
|
|
|
58
|
+
## Currency alignment on header/footer reuses the table-cell token
|
|
59
|
+
|
|
60
|
+
`--recursica_ui-kit_components_table-cell_properties_currency-style_text-align` (`right`) is the
|
|
61
|
+
only currency-style text-align token the UI Kit exports — there's no
|
|
62
|
+
`table-header_properties_currency-style_*` block at all, and the
|
|
63
|
+
`table-footer_properties_currency-style_*` block skips `text-align` specifically.
|
|
64
|
+
`Table.module.css` reuses the table-cell token for both `thead .cell[data-currency="true"]` and
|
|
65
|
+
`tfoot .cell[data-currency="true"]` so header/footer currency cells stay right-aligned in step
|
|
66
|
+
with the body. `Table.Cell` already threads `variant`/`data-currency` through regardless of
|
|
67
|
+
context, so no `Table.tsx` change was needed here (unlike `mantine-adapter`'s `Table.Th`).
|
|
68
|
+
|
|
58
69
|
## Selectable rows (checkboxes)
|
|
59
70
|
|
|
60
71
|
See `mantine-adapter`'s `TABLE_IMPLEMENTATION_NOTES.md` — confirmed 2026-08-22 that neither
|
|
@@ -225,6 +225,15 @@
|
|
|
225
225
|
);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/* Currency column header alignment override. No dedicated table-header currency-style token
|
|
229
|
+
exists yet, so this reuses the table-cell currency-style text-align token to match the
|
|
230
|
+
currency value cells below it. */
|
|
231
|
+
.root thead .cell[data-currency="true"] {
|
|
232
|
+
text-align: var(
|
|
233
|
+
--recursica_ui-kit_components_table-cell_properties_currency-style_text-align
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
228
237
|
/* Sort label — MUI's TableSortLabel renders its own arrow icon; re-color it to inherit the
|
|
229
238
|
cell's own text color (instead of MUI's default sort-label color) and size/space it with the
|
|
230
239
|
same tokens Mantine's own inline chevron uses. */
|
|
@@ -425,6 +434,11 @@
|
|
|
425
434
|
line-height: var(
|
|
426
435
|
--recursica_ui-kit_components_table-footer_properties_currency-style_line-height
|
|
427
436
|
);
|
|
437
|
+
/* No table-footer currency-style text-align token exists yet; reuse the table-cell one so
|
|
438
|
+
footer currency cells match the body's right alignment. */
|
|
439
|
+
text-align: var(
|
|
440
|
+
--recursica_ui-kit_components_table-cell_properties_currency-style_text-align
|
|
441
|
+
);
|
|
428
442
|
text-decoration: var(
|
|
429
443
|
--recursica_ui-kit_components_table-footer_properties_currency-style_text-decoration
|
|
430
444
|
);
|
|
@@ -128,7 +128,7 @@ export const CurrencyColumnWithFooter: Story = {
|
|
|
128
128
|
<Table.Head>
|
|
129
129
|
<Table.Row>
|
|
130
130
|
<Table.Cell>Item</Table.Cell>
|
|
131
|
-
<Table.Cell>Price</Table.Cell>
|
|
131
|
+
<Table.Cell variant="currency">Price</Table.Cell>
|
|
132
132
|
</Table.Row>
|
|
133
133
|
</Table.Head>
|
|
134
134
|
<Table.Body>
|
|
@@ -45,14 +45,14 @@ export default function Demo() {
|
|
|
45
45
|
## 3. Row and Cell States
|
|
46
46
|
|
|
47
47
|
- **`Table.Row`**: `selected` applies the selected-row background (and MUI's own `selected`/`aria-selected`); `disabled` dims the row and applies the disabled cell colors to every cell in it.
|
|
48
|
-
- **`Table.Cell`**: `sorted="asc" | "desc"` applies the sorted header style (pair with `Table.SortLabel` for the actual sort icon — see below); `variant="currency"` applies the currency text style; `disabled` dims the cell.
|
|
48
|
+
- **`Table.Cell`**: `sorted="asc" | "desc"` applies the sorted header style (pair with `Table.SortLabel` for the actual sort icon — see below); `variant="currency"` applies the currency text style, right-aligning it in any context (header, body, or footer); `disabled` dims the cell.
|
|
49
49
|
- **`Table.SortLabel`** (wraps MUI's `TableSortLabel`): renders the actual sort arrow. Compose it inside a `Table.Cell` the same way MUI's own docs do.
|
|
50
50
|
|
|
51
51
|
```tsx
|
|
52
52
|
<Table.Head>
|
|
53
53
|
<Table.Row>
|
|
54
54
|
<Table.Cell>Name</Table.Cell>
|
|
55
|
-
<Table.Cell sorted="asc">
|
|
55
|
+
<Table.Cell sorted="asc" variant="currency">
|
|
56
56
|
<Table.SortLabel active direction="asc">
|
|
57
57
|
Balance
|
|
58
58
|
</Table.SortLabel>
|
|
@@ -32,7 +32,8 @@ Lowercase `hh` renders 12-hour digits (1–12) with no `a` (meridiem) token, so
|
|
|
32
32
|
## Design tokens
|
|
33
33
|
|
|
34
34
|
- No dedicated `min-height` token exists for `time-picker` (unlike `text-field`/`date-picker`) — the field's height is derived from its own padding + line-height instead of a fixed token.
|
|
35
|
-
- `icon-size`/`icon-color`/`icon-text-gap
|
|
35
|
+
- `icon-size`/`icon-color`/`icon-text-gap` (plus the disabled/error `icon-color` variants) are wired via `leftSection` — see "Leading icon" below.
|
|
36
|
+
- `placeholder-opacity` remains exempted (`recursica-ignore`) — MUI X's field renders its empty-state "hh"/"mm" placeholder via its own internal `isFieldValueEmpty` styled-component variant (an inline opacity applied through emotion), not a native `::placeholder` pseudo-element this CSS module can target. Unlike mantine-adapter's `SpinInput` (a real `<input placeholder="--">`), there's no stable selector here to hook a token to without depending on MUI X's internal class names.
|
|
36
37
|
- The AM/PM `BareDropdown` draws its own border/background/padding from `Dropdown`'s own tokens via `Dropdown.module.css` — it does not reuse any `time-picker` tokens.
|
|
37
38
|
|
|
38
39
|
## Known limitation — the popup clock/list view is unstyled
|
|
@@ -43,6 +44,12 @@ This pass covers the closed-state field and the AM/PM `BareDropdown` only. The o
|
|
|
43
44
|
|
|
44
45
|
`readOnlyType="text"`, matching `DatePicker`'s convention.
|
|
45
46
|
|
|
47
|
+
## Leading icon (Matt Massey, 2026-08-30)
|
|
48
|
+
|
|
49
|
+
Added `leftSection` (`RecursicaTimePickerProps`), matching `TextField`'s naming/convention: purely decorative, consumer-supplied, no default (unlike `DatePicker`'s fixed `CalendarIcon` — there's no single icon that fits every `TimePicker` use).
|
|
50
|
+
|
|
51
|
+
MUI X's `TimePicker` has no `leftSection` concept — wired via `slotProps.textField.slotProps.input.startAdornment` (the `textField`/`input` slots are exposed at the top level of `TimePicker`'s own `slotProps`, sibling to `field`, via `PickerFieldUISlotPropsFromContext` — confirmed by reading `useDesktopPicker.types.d.ts`/`PickerFieldUI.d.ts` directly, not just the top-level `TimePickerProps` surface). `startAdornment` renders as a plain sibling of `.MuiPickersInputBase-sectionsContainer` inside `PickersInputBase` (confirmed via `PickersInputBase.js`), so it's wrapped in the same `<div className={styles.section} data-position="left">` shape `TextField.tsx` already uses, rather than depending on MUI's own adornment styling. Also added `data-with-left-section` on `.root` so `.MuiPickersInputBase-sectionsContainer`'s own hardcoded `padding-left` (previously always the field's full `horizontal-padding`, structured for the no-icon case) collapses to just `icon-text-gap` once the icon itself is doing the border-inset job instead.
|
|
52
|
+
|
|
46
53
|
## Visual review fix (Matt Massey, 2026-08-07)
|
|
47
54
|
|
|
48
55
|
**`BareDropdown`'s border color didn't match Mantine's version, despite both reading the same `Dropdown` tokens**: a real bug in `BareDropdown.tsx` — `className={styles.root}` was set explicitly on `<MuiSelect>`, then `{...sanitizedProps}` was spread _after_ it. Any caller passing its own `className` (like `TimePicker.tsx`'s `styles.amPmSelect`) silently overwrote `styles.root` entirely via that later spread, so `Dropdown.module.css`'s border-color/background/`width: 100%` never actually applied — MUI's own default border rendered instead. Fixed by extracting `className` explicitly and merging it (`` `${styles.root} ${className}` ``) before it reaches `<MuiSelect>`, the same pattern mantine-adapter's `BareDropdown` already used. This also explains why the AM/PM box's width had looked accidentally "correct" before: the competing `width: 100%` rule from `.root` was never actually being applied either.
|
|
@@ -128,6 +128,37 @@
|
|
|
128
128
|
color: inherit;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/* Leading icon (leftSection), rendered via slotProps.textField.slotProps.input.startAdornment —
|
|
132
|
+
a sibling of .MuiPickersInputBase-sectionsContainer inside .field, same shape as TextField's own
|
|
133
|
+
startAdornment wrapping. Only one icon-color token exists for time-picker (unlike text-field's
|
|
134
|
+
separate leading/trailing) — this field has no right-side icon slot. */
|
|
135
|
+
.section {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
padding-left: var(
|
|
139
|
+
--recursica_ui-kit_components_time-picker_properties_horizontal-padding
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.section :global(svg) {
|
|
144
|
+
width: var(--recursica_ui-kit_components_time-picker_properties_icon-size);
|
|
145
|
+
height: var(--recursica_ui-kit_components_time-picker_properties_icon-size);
|
|
146
|
+
color: var(
|
|
147
|
+
--recursica_ui-kit_components_time-picker_properties_colors_icon-color
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* With a leftSection present, the icon (not .field's own border) now owns the inset from the left
|
|
152
|
+
edge — sectionsContainer's own padding-left collapses to just the icon-text-gap between icon and
|
|
153
|
+
digits. */
|
|
154
|
+
.root[data-with-left-section="true"]
|
|
155
|
+
.field
|
|
156
|
+
:global(.MuiPickersInputBase-sectionsContainer) {
|
|
157
|
+
padding-left: var(
|
|
158
|
+
--recursica_ui-kit_components_time-picker_properties_icon-text-gap
|
|
159
|
+
) !important;
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
/* The AM/PM BareDropdown reuses Dropdown.module.css's own border/background/padding entirely —
|
|
132
163
|
this only controls its size/alignment within the flex row. */
|
|
133
164
|
.amPmSelect {
|
|
@@ -191,6 +222,12 @@
|
|
|
191
222
|
) !important;
|
|
192
223
|
}
|
|
193
224
|
|
|
225
|
+
.root[data-error="true"] .section :global(svg) {
|
|
226
|
+
color: var(
|
|
227
|
+
--recursica_ui-kit_components_time-picker_variants_states_error_properties_colors_icon-color
|
|
228
|
+
) !important;
|
|
229
|
+
}
|
|
230
|
+
|
|
194
231
|
/* Disabled State Mapping */
|
|
195
232
|
.field:has(:global(.Mui-disabled)) {
|
|
196
233
|
border-color: var(
|
|
@@ -207,3 +244,9 @@
|
|
|
207
244
|
) !important;
|
|
208
245
|
cursor: not-allowed;
|
|
209
246
|
}
|
|
247
|
+
|
|
248
|
+
.field:has(:global(.Mui-disabled)) .section :global(svg) {
|
|
249
|
+
color: var(
|
|
250
|
+
--recursica_ui-kit_components_time-picker_variants_states_disabled_properties_colors_icon-color
|
|
251
|
+
) !important;
|
|
252
|
+
}
|
|
@@ -115,6 +115,28 @@ export const ErrorState: Story = {
|
|
|
115
115
|
},
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
export const WithLeadingIcon: Story = {
|
|
119
|
+
args: {
|
|
120
|
+
label: "Meeting Time",
|
|
121
|
+
assistiveText: "Choose the start time in your local timezone.",
|
|
122
|
+
leftSection: (
|
|
123
|
+
<svg
|
|
124
|
+
width="24"
|
|
125
|
+
height="24"
|
|
126
|
+
viewBox="0 0 24 24"
|
|
127
|
+
fill="none"
|
|
128
|
+
stroke="currentColor"
|
|
129
|
+
strokeWidth="2"
|
|
130
|
+
strokeLinecap="round"
|
|
131
|
+
strokeLinejoin="round"
|
|
132
|
+
>
|
|
133
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
134
|
+
<polyline points="12 6 12 12 16 14"></polyline>
|
|
135
|
+
</svg>
|
|
136
|
+
),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
118
140
|
export const StaticReadOnly: Story = {
|
|
119
141
|
args: {
|
|
120
142
|
label: "Static ReadOnly Review",
|
|
@@ -124,6 +124,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
|
124
124
|
withSeconds,
|
|
125
125
|
minTime,
|
|
126
126
|
maxTime,
|
|
127
|
+
leftSection,
|
|
127
128
|
...rest
|
|
128
129
|
} = props;
|
|
129
130
|
|
|
@@ -148,6 +149,12 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
|
148
149
|
? `${styles.layoutOverride} ${className}`
|
|
149
150
|
: styles.layoutOverride;
|
|
150
151
|
|
|
152
|
+
const startAdornment = leftSection ? (
|
|
153
|
+
<div className={styles.section} data-position="left">
|
|
154
|
+
{leftSection}
|
|
155
|
+
</div>
|
|
156
|
+
) : undefined;
|
|
157
|
+
|
|
151
158
|
const emitChange = (next: Dayjs | null) => {
|
|
152
159
|
setInternalValue(next);
|
|
153
160
|
onChange?.(
|
|
@@ -206,7 +213,11 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
|
206
213
|
format="hh:mm" is always on (12-hour digits, no native meridiem section) — this is the
|
|
207
214
|
only way this component operates, not a user choice. The AM/PM BareDropdown next to it
|
|
208
215
|
is the only AM/PM control; see TIMEPICKER_IMPLEMENTATION_NOTES.md. */
|
|
209
|
-
<div
|
|
216
|
+
<div
|
|
217
|
+
className={styles.root}
|
|
218
|
+
data-error={error ? "true" : undefined}
|
|
219
|
+
data-with-left-section={leftSection ? "true" : undefined}
|
|
220
|
+
>
|
|
210
221
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
211
222
|
<MuiTimePicker
|
|
212
223
|
{...(sanitizedProps as unknown as Partial<MuiTimePickerProps>)}
|
|
@@ -221,16 +232,23 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|
|
221
232
|
}
|
|
222
233
|
minTime={toDayjs(minTime)}
|
|
223
234
|
maxTime={toDayjs(maxTime)}
|
|
224
|
-
// No
|
|
225
|
-
//
|
|
226
|
-
// to
|
|
227
|
-
//
|
|
228
|
-
//
|
|
235
|
+
// No open-picker button: the popup clock/list view it opens isn't styled to
|
|
236
|
+
// Recursica tokens (see TIMEPICKER_IMPLEMENTATION_NOTES.md) — showing an affordance
|
|
237
|
+
// to open an unstyled popup would be worse than not showing one. Typing directly
|
|
238
|
+
// into the field's masked hour/minute segments is the only interaction. leftSection
|
|
239
|
+
// (below) is purely decorative, unrelated to this button.
|
|
229
240
|
slots={{ openPickerButton: () => null }}
|
|
230
241
|
slotProps={{
|
|
231
242
|
field: {
|
|
232
243
|
className: styles.field,
|
|
233
244
|
},
|
|
245
|
+
textField: {
|
|
246
|
+
slotProps: {
|
|
247
|
+
input: {
|
|
248
|
+
startAdornment,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
234
252
|
}}
|
|
235
253
|
/>
|
|
236
254
|
</LocalizationProvider>
|