@multiplatform.one/forms 7.28.0 → 7.29.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/dist/cjs/AdaptivePopup/index.cjs +19 -22
- package/dist/cjs/AdaptivePopup/index.native.js +19 -22
- package/dist/cjs/AdaptivePopup/index.native.js.map +1 -1
- package/dist/cjs/Button/index.cjs +32 -22
- package/dist/cjs/Button/index.native.js +40 -23
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/cjs/Chip/index.cjs +3 -2
- package/dist/cjs/Chip/index.native.js +4 -3
- package/dist/cjs/Chip/index.native.js.map +1 -1
- package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.cjs +14 -0
- package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +14 -0
- package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/cjs/fields/Geolocation/index.cjs +1 -0
- package/dist/cjs/fields/Geolocation/index.native.js +1 -0
- package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
- package/dist/cjs/shared/focusManagement.cjs +28 -0
- package/dist/cjs/shared/focusManagement.native.js +38 -0
- package/dist/cjs/shared/focusManagement.native.js.map +1 -1
- package/dist/esm/AdaptivePopup/index.mjs +20 -23
- package/dist/esm/AdaptivePopup/index.mjs.map +1 -1
- package/dist/esm/AdaptivePopup/index.native.js +20 -23
- package/dist/esm/AdaptivePopup/index.native.js.map +1 -1
- package/dist/esm/Button/index.mjs +33 -23
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +41 -24
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/esm/Chip/index.mjs +4 -3
- package/dist/esm/Chip/index.mjs.map +1 -1
- package/dist/esm/Chip/index.native.js +5 -4
- package/dist/esm/Chip/index.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs +16 -2
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js +16 -2
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/index.mjs +1 -0
- package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
- package/dist/esm/fields/Geolocation/index.native.js +1 -0
- package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
- package/dist/esm/shared/focusManagement.mjs +28 -2
- package/dist/esm/shared/focusManagement.mjs.map +1 -1
- package/dist/esm/shared/focusManagement.native.js +38 -2
- package/dist/esm/shared/focusManagement.native.js.map +1 -1
- package/dist/jsx/AdaptivePopup/index.mjs +20 -23
- package/dist/jsx/AdaptivePopup/index.mjs.map +1 -1
- package/dist/jsx/AdaptivePopup/index.native.js +19 -22
- package/dist/jsx/AdaptivePopup/index.native.js.map +1 -1
- package/dist/jsx/Button/index.mjs +33 -23
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +40 -23
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/dist/jsx/Chip/index.mjs +4 -3
- package/dist/jsx/Chip/index.mjs.map +1 -1
- package/dist/jsx/Chip/index.native.js +4 -3
- package/dist/jsx/Chip/index.native.js.map +1 -1
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs +16 -2
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +14 -0
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/jsx/fields/Geolocation/index.mjs +1 -0
- package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
- package/dist/jsx/fields/Geolocation/index.native.js +1 -0
- package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
- package/dist/jsx/shared/focusManagement.mjs +28 -2
- package/dist/jsx/shared/focusManagement.mjs.map +1 -1
- package/dist/jsx/shared/focusManagement.native.js +38 -0
- package/dist/jsx/shared/focusManagement.native.js.map +1 -1
- package/package.json +8 -8
- package/src/AdaptivePopup/AdaptivePopup.spec.tsx +44 -1
- package/src/AdaptivePopup/index.tsx +19 -19
- package/src/Button/Button.disabledReasonInk.spec.tsx +134 -0
- package/src/Button/Button.disabledWash.spec.tsx +206 -0
- package/src/Button/Button.outlinedAccentInk.spec.tsx +152 -0
- package/src/Button/Button.stories.tsx +22 -6
- package/src/Button/index.tsx +70 -31
- package/src/Chip/index.tsx +9 -4
- package/src/ContextualSaveBar/UnsavedChangesDialog.spec.tsx +50 -3
- package/src/ContextualSaveBar/UnsavedChangesDialog.tsx +26 -2
- package/src/fields/Geolocation/index.tsx +3 -1
- package/src/shared/focusManagement.ts +40 -1
- package/types/AdaptivePopup/index.d.ts.map +1 -1
- package/types/Button/index.d.ts.map +1 -1
- package/types/Chip/index.d.ts +1 -1
- package/types/Chip/index.d.ts.map +1 -1
- package/types/ContextualSaveBar/UnsavedChangesDialog.d.ts.map +1 -1
- package/types/fields/Checkbox/index.d.ts +3 -3
- package/types/fields/Geolocation/index.d.ts.map +1 -1
- package/types/fields/RadioGroup/index.d.ts +2 -2
- package/types/fields/ToggleGroup/index.d.ts +1 -1
- package/types/shared/focusManagement.d.ts +19 -0
- package/types/shared/focusManagement.d.ts.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/forms",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.29.0",
|
|
4
4
|
"description": "Cross-platform form components with URL state management",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cross-platform",
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@floating-ui/react": "^0.27.19",
|
|
60
|
-
"@multiplatform.one/platform": "7.
|
|
61
|
-
"@multiplatform.one/router": "7.
|
|
62
|
-
"@multiplatform.one/table-primitives": "7.
|
|
63
|
-
"@multiplatform.one/theme": "7.
|
|
60
|
+
"@multiplatform.one/platform": "7.29.0",
|
|
61
|
+
"@multiplatform.one/router": "7.29.0",
|
|
62
|
+
"@multiplatform.one/table-primitives": "7.29.0",
|
|
63
|
+
"@multiplatform.one/theme": "7.29.0",
|
|
64
64
|
"@phosphor-icons/react": "^2.1.10",
|
|
65
65
|
"@tamagui/get-font-sized": "2.7.6",
|
|
66
66
|
"@tamagui/get-token": "2.7.6",
|
|
@@ -79,9 +79,9 @@
|
|
|
79
79
|
"tamagui": "2.7.6"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@multiplatform.one/config": "7.
|
|
83
|
-
"@multiplatform.one/storybook": "7.
|
|
84
|
-
"@multiplatform.one/test-utils": "7.
|
|
82
|
+
"@multiplatform.one/config": "7.29.0",
|
|
83
|
+
"@multiplatform.one/storybook": "7.29.0",
|
|
84
|
+
"@multiplatform.one/test-utils": "7.29.0",
|
|
85
85
|
"@tamagui/build": "2.7.6",
|
|
86
86
|
"@tamagui/config": "2.7.6",
|
|
87
87
|
"@tamagui/vite-plugin": "2.7.6",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { fireEvent, waitFor } from "@testing-library/react";
|
|
3
|
-
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
3
|
+
import { modelFirstFocusableShow, renderWithProviders } from "@multiplatform.one/test-utils";
|
|
4
4
|
import { OVERLAY_BREAKPOINT } from "@multiplatform.one/theme";
|
|
5
5
|
import { useState, type ReactNode } from "react";
|
|
6
6
|
import { Text } from "tamagui";
|
|
7
7
|
import { Button } from "../Button";
|
|
8
|
+
import { Input } from "../fields/Input";
|
|
8
9
|
import { AdaptivePopup, pickAdaptivePopupFace } from "./index";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -418,3 +419,45 @@ describe("AdaptivePopup drawer contract (LC-48 / DG-OVL-01 / overlay ground)", (
|
|
|
418
419
|
expect(surface()?.getAttribute("data-drawer-attached-radius")).toBe("0");
|
|
419
420
|
});
|
|
420
421
|
});
|
|
422
|
+
|
|
423
|
+
// MPO-470: in Chromium and WebKit, <dialog>.show() focuses the first focusable
|
|
424
|
+
// descendant, which here is the tabindex=-1 surface itself (Firefox and the HTML
|
|
425
|
+
// spec take the first tab stop). FocusScope then saw focus inside and never ran
|
|
426
|
+
// the popup's field focus, so typing right after open went nowhere. jsdom has
|
|
427
|
+
// no show(), so modelFirstFocusableShow stands in for Chromium's.
|
|
428
|
+
|
|
429
|
+
describe("AdaptivePopup open focus behind <dialog>.show() (MPO-470)", () => {
|
|
430
|
+
let restoreShow = () => {};
|
|
431
|
+
beforeEach(() => {
|
|
432
|
+
setViewportWidth(1440);
|
|
433
|
+
restoreShow = modelFirstFocusableShow();
|
|
434
|
+
});
|
|
435
|
+
afterEach(() => restoreShow());
|
|
436
|
+
|
|
437
|
+
function FieldPopup({ face }: { face: "dialog" | "drawer" }) {
|
|
438
|
+
const [open, setOpen] = useState(false);
|
|
439
|
+
return (
|
|
440
|
+
<AdaptivePopup
|
|
441
|
+
open={open}
|
|
442
|
+
onOpenChange={setOpen}
|
|
443
|
+
face={face}
|
|
444
|
+
title="Edit Profile"
|
|
445
|
+
trigger={<Button>Open Popup</Button>}
|
|
446
|
+
>
|
|
447
|
+
<Input inputProps={{ "aria-label": "Name" }} />
|
|
448
|
+
</AdaptivePopup>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
it.each(["dialog", "drawer"] as const)(
|
|
453
|
+
"%s: moves focus off the surface onto the first field",
|
|
454
|
+
async (face) => {
|
|
455
|
+
const result = renderWithProviders(<FieldPopup face={face} />);
|
|
456
|
+
press(result.container.querySelector("[role='button']") as Element);
|
|
457
|
+
await waitFor(() => expect(surface()?.getAttribute("data-adaptive-popup-mode")).toBe(face));
|
|
458
|
+
const field = document.querySelector("input[aria-label='Name']");
|
|
459
|
+
expect(field).not.toBeNull();
|
|
460
|
+
await waitFor(() => expect(document.activeElement).toBe(field));
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
});
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
} from "tamagui";
|
|
18
18
|
import { X } from "@tamagui/lucide-icons-2";
|
|
19
19
|
import { formCommonColors } from "../shared/colorRamps";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
DialogShowFocus,
|
|
22
|
+
useReturnFocusOnClose,
|
|
23
|
+
useSheetModalFocus,
|
|
24
|
+
} from "../shared/focusManagement";
|
|
21
25
|
import { useSheetEscapeDismiss } from "../shared/useSheetEscapeDismiss";
|
|
22
26
|
import { zIndex } from "../shared/zIndex";
|
|
23
27
|
import { useViewportGtSm } from "../FloatingPanel";
|
|
@@ -158,6 +162,16 @@ function PopupCloseButton({ onPress }: { onPress: () => void }) {
|
|
|
158
162
|
);
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
function focusFirstField(event: Event) {
|
|
166
|
+
const root = event.currentTarget as HTMLElement | null;
|
|
167
|
+
const field = root?.querySelector<HTMLElement>(
|
|
168
|
+
"input, textarea, select, [contenteditable='true']",
|
|
169
|
+
);
|
|
170
|
+
if (!field) return;
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
field.focus({ preventScroll: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
export function AdaptivePopup({
|
|
162
176
|
open,
|
|
163
177
|
onOpenChange,
|
|
@@ -398,15 +412,7 @@ export function AdaptivePopup({
|
|
|
398
412
|
enterStyle={{ opacity: 0, x: slideFromEnd }}
|
|
399
413
|
exitStyle={{ opacity: 0, x: slideFromEnd }}
|
|
400
414
|
{...knobProps.panelPadding}
|
|
401
|
-
onOpenAutoFocus={
|
|
402
|
-
const root = event.currentTarget as unknown as HTMLElement | null;
|
|
403
|
-
const field = root?.querySelector<HTMLElement>(
|
|
404
|
-
"input, textarea, select, [contenteditable='true']",
|
|
405
|
-
);
|
|
406
|
-
if (!field) return;
|
|
407
|
-
event.preventDefault();
|
|
408
|
-
field.focus({ preventScroll: true });
|
|
409
|
-
}}
|
|
415
|
+
onOpenAutoFocus={focusFirstField}
|
|
410
416
|
onCloseAutoFocus={(event) => {
|
|
411
417
|
// LC-48: Dialog.Trigger is the wrapper View (tabIndex -1), not
|
|
412
418
|
// the painted Button. Prevent Radix restoring there so
|
|
@@ -415,6 +421,7 @@ export function AdaptivePopup({
|
|
|
415
421
|
}}
|
|
416
422
|
>
|
|
417
423
|
{body}
|
|
424
|
+
<DialogShowFocus onOpenAutoFocus={focusFirstField} />
|
|
418
425
|
</Dialog.Content>
|
|
419
426
|
</Dialog.Portal>
|
|
420
427
|
</Dialog>
|
|
@@ -459,15 +466,7 @@ export function AdaptivePopup({
|
|
|
459
466
|
maxHeight={maxHeight}
|
|
460
467
|
width="90vw"
|
|
461
468
|
{...knobProps.panelPadding}
|
|
462
|
-
onOpenAutoFocus={
|
|
463
|
-
const root = event.currentTarget as unknown as HTMLElement | null;
|
|
464
|
-
const field = root?.querySelector<HTMLElement>(
|
|
465
|
-
"input, textarea, select, [contenteditable='true']",
|
|
466
|
-
);
|
|
467
|
-
if (!field) return;
|
|
468
|
-
event.preventDefault();
|
|
469
|
-
field.focus({ preventScroll: true });
|
|
470
|
-
}}
|
|
469
|
+
onOpenAutoFocus={focusFirstField}
|
|
471
470
|
onCloseAutoFocus={(event) => {
|
|
472
471
|
// LC-48: Dialog.Trigger is the wrapper View (tabIndex -1), not
|
|
473
472
|
// the painted Button. Prevent Radix restoring there so
|
|
@@ -476,6 +475,7 @@ export function AdaptivePopup({
|
|
|
476
475
|
}}
|
|
477
476
|
>
|
|
478
477
|
{body}
|
|
478
|
+
<DialogShowFocus onOpenAutoFocus={focusFirstField} />
|
|
479
479
|
</Dialog.Content>
|
|
480
480
|
</Dialog.Portal>
|
|
481
481
|
</Dialog>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* MPO-431 / T-HELPER: a disabled Button's reason is copy on the host surface,
|
|
5
|
+
* not button chrome. Rendered inside the Button's intent <Theme>, its
|
|
6
|
+
* `$color11` resolved on the intent's on-fill ramp and "Enter a title first"
|
|
7
|
+
* painted 1.17:1 on the white page (Screens/Todos, light).
|
|
8
|
+
*
|
|
9
|
+
* jsdom does not cascade Tamagui CSS, so ink is resolved the way the browser
|
|
10
|
+
* resolves it: the token on the text node's `_col-*` class, looked up in the
|
|
11
|
+
* theme scope that encloses that node. Moving the reason back inside the
|
|
12
|
+
* intent scope changes the looked-up ramp and fails the contrast arm.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { render, cleanup, screen } from "@testing-library/react";
|
|
16
|
+
import {
|
|
17
|
+
aaTextContrastRatio,
|
|
18
|
+
createDefaultThemeConfig,
|
|
19
|
+
createThemesBuilder,
|
|
20
|
+
defaultAccentTheme,
|
|
21
|
+
defaultBaseTheme,
|
|
22
|
+
defaultBuilderOptions,
|
|
23
|
+
measureContrast,
|
|
24
|
+
} from "@multiplatform.one/theme";
|
|
25
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
26
|
+
import { TamaguiProvider, YStack } from "tamagui";
|
|
27
|
+
import { Button, type ButtonProps } from "./index";
|
|
28
|
+
|
|
29
|
+
afterEach(cleanup);
|
|
30
|
+
|
|
31
|
+
const houseConfig = createDefaultThemeConfig();
|
|
32
|
+
const houseThemes = createThemesBuilder(
|
|
33
|
+
defaultBaseTheme,
|
|
34
|
+
defaultAccentTheme,
|
|
35
|
+
defaultBuilderOptions,
|
|
36
|
+
).themes() as Record<string, Record<string, string>>;
|
|
37
|
+
|
|
38
|
+
const SCHEMES = ["light", "dark"] as const;
|
|
39
|
+
const PLACEMENTS = ["below", "inline"] as const;
|
|
40
|
+
const PAGE_SURFACES = ["background", "color1", "color2"] as const;
|
|
41
|
+
|
|
42
|
+
/** Every channel that re-themes the frame: the four intent props, and `theme`. */
|
|
43
|
+
const CHANNELS: { name: string; themeName: string; props: Partial<ButtonProps> }[] = [
|
|
44
|
+
{ name: "accent", themeName: "accent", props: { accent: true } },
|
|
45
|
+
{ name: "error", themeName: "error", props: { error: true } },
|
|
46
|
+
{ name: "warning", themeName: "warning", props: { warning: true } },
|
|
47
|
+
{ name: "success", themeName: "success", props: { success: true } },
|
|
48
|
+
{ name: 'theme="accent"', themeName: "accent", props: { theme: "accent" } },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function houseRender(ui: React.ReactElement, scheme: "light" | "dark") {
|
|
52
|
+
return render(
|
|
53
|
+
<TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
|
|
54
|
+
<YStack backgroundColor="$background">{ui}</YStack>
|
|
55
|
+
</TamaguiProvider>,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scopeThemeKey(node: HTMLElement, scheme: string): string {
|
|
60
|
+
const names: string[] = [];
|
|
61
|
+
for (let el: HTMLElement | null = node; el; el = el.parentElement) {
|
|
62
|
+
if (!el.classList.contains("is_Theme")) continue;
|
|
63
|
+
for (const cls of Array.from(el.classList)) {
|
|
64
|
+
if (!cls.startsWith("t_") || cls === "t_sub_theme") continue;
|
|
65
|
+
const name = cls.slice(2);
|
|
66
|
+
if (name !== "light" && name !== "dark") names.unshift(name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [scheme, ...names].join("_");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolvedInk(
|
|
73
|
+
node: HTMLElement,
|
|
74
|
+
scheme: string,
|
|
75
|
+
): { key: string; token: string; ink: string } {
|
|
76
|
+
const token = node.className.match(/(?:^|\s)_col-(\w+)(?:\s|$)/)?.[1];
|
|
77
|
+
expect(token, `reason carries a colour token class: ${node.className}`).toBeTruthy();
|
|
78
|
+
const key = scopeThemeKey(node, scheme);
|
|
79
|
+
const ink = houseThemes[key]?.[token!];
|
|
80
|
+
expect(ink, `${key}.${token} resolves in the house themes`).toBeTruthy();
|
|
81
|
+
return { key, token: token!, ink: ink! };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("Button disabledReason ink (MPO-431 / T-HELPER)", () => {
|
|
85
|
+
for (const scheme of SCHEMES) {
|
|
86
|
+
for (const placement of PLACEMENTS) {
|
|
87
|
+
it.each(CHANNELS)(
|
|
88
|
+
`${scheme} ${placement}: the $name reason clears 4.5:1 on every page surface`,
|
|
89
|
+
({ name, props }) => {
|
|
90
|
+
const reason = `Reason ${name}`;
|
|
91
|
+
houseRender(
|
|
92
|
+
<Button {...props} disabled disabledReason={reason} disabledReasonPlacement={placement}>
|
|
93
|
+
Add
|
|
94
|
+
</Button>,
|
|
95
|
+
scheme,
|
|
96
|
+
);
|
|
97
|
+
const { key, token, ink } = resolvedInk(screen.getByText(reason), scheme);
|
|
98
|
+
for (const surface of PAGE_SURFACES) {
|
|
99
|
+
const ground = houseThemes[scheme][surface];
|
|
100
|
+
const label = `${key}.${token} ${ink} on ${scheme}.${surface} ${ground}`;
|
|
101
|
+
expect(
|
|
102
|
+
measureContrast({ foreground: ink, background: ground, label }).ratio,
|
|
103
|
+
label,
|
|
104
|
+
).toBeGreaterThanOrEqual(aaTextContrastRatio);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it.each(CHANNELS)(
|
|
110
|
+
`${scheme} ${placement}: the $name theme scopes the button, never its reason`,
|
|
111
|
+
({ name, themeName, props }) => {
|
|
112
|
+
const reason = `Reason ${name}`;
|
|
113
|
+
houseRender(
|
|
114
|
+
<Button {...props} disabled disabledReason={reason} disabledReasonPlacement={placement}>
|
|
115
|
+
Add
|
|
116
|
+
</Button>,
|
|
117
|
+
scheme,
|
|
118
|
+
);
|
|
119
|
+
const button = screen.getByRole("button");
|
|
120
|
+
expect(button.getAttribute("aria-disabled")).toBe("true");
|
|
121
|
+
expect(
|
|
122
|
+
button.closest(`.t_${themeName}`),
|
|
123
|
+
"the frame keeps its intent theme",
|
|
124
|
+
).toBeTruthy();
|
|
125
|
+
expect(
|
|
126
|
+
screen.getByText(reason).closest(`.t_${themeName}`),
|
|
127
|
+
"the reason resolves on the host surface",
|
|
128
|
+
).toBeNull();
|
|
129
|
+
expect(scopeThemeKey(screen.getByText(reason), scheme)).toBe(scheme);
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* MPO-433 / LC-40 DISABLED-VISIBLE: every intent Button reads disabled.
|
|
5
|
+
*
|
|
6
|
+
* The keepLabel wash paints the ACTIVE ramp's `$color3`/`$color6` with the
|
|
7
|
+
* label on `$color11`. Error, warning and success parents are Radix ramps, so
|
|
8
|
+
* step 3 is a pale wash far from their step-11 Button fill. The accent slot is
|
|
9
|
+
* solid by palette shape (defaults/accent.ts): step 3 sat one lightness step
|
|
10
|
+
* off the step-4 fill, measured ΔE 8.2 light and 2.5 dark, and the button
|
|
11
|
+
* never read disabled. The sweep also caught light warning and success labels
|
|
12
|
+
* at 4.48 and 4.27:1 on their own wash, since step 11 is only AA on steps 1-2.
|
|
13
|
+
*
|
|
14
|
+
* jsdom cascades no Tamagui CSS, so this reads what the frame asks for: the
|
|
15
|
+
* atomic class, the injected rule behind it, and for a `var(--token)` rule the
|
|
16
|
+
* house theme table of the scope that paints it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
20
|
+
import {
|
|
21
|
+
aaTextContrastRatio,
|
|
22
|
+
createDefaultThemeConfig,
|
|
23
|
+
createThemesBuilder,
|
|
24
|
+
defaultAccentTheme,
|
|
25
|
+
defaultBaseTheme,
|
|
26
|
+
defaultBuilderOptions,
|
|
27
|
+
measureContrast,
|
|
28
|
+
normalizeToHex,
|
|
29
|
+
Preset,
|
|
30
|
+
} from "@multiplatform.one/theme";
|
|
31
|
+
import type { ReactElement } from "react";
|
|
32
|
+
import { TamaguiProvider, YStack } from "tamagui";
|
|
33
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
34
|
+
import { Button, type ButtonProps } from "./index";
|
|
35
|
+
|
|
36
|
+
afterEach(cleanup);
|
|
37
|
+
|
|
38
|
+
const houseConfig = createDefaultThemeConfig();
|
|
39
|
+
const houseThemes = createThemesBuilder(
|
|
40
|
+
defaultBaseTheme,
|
|
41
|
+
defaultAccentTheme,
|
|
42
|
+
defaultBuilderOptions,
|
|
43
|
+
).themes() as Record<string, Record<string, string>>;
|
|
44
|
+
|
|
45
|
+
const SCHEMES = ["light", "dark"] as const;
|
|
46
|
+
const DISABLED_STYLES = ["keepLabel", "dimWhole"] as const;
|
|
47
|
+
const VARIANTS: { name: string; props: Partial<ButtonProps> }[] = [
|
|
48
|
+
{ name: "accent", props: { accent: true } },
|
|
49
|
+
{ name: 'theme="accent"', props: { theme: "accent" } },
|
|
50
|
+
{ name: "error", props: { error: true } },
|
|
51
|
+
{ name: "warning", props: { warning: true } },
|
|
52
|
+
{ name: "success", props: { success: true } },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* LC-40's fill threshold (a HYBRID dial). CIE76 ΔE between the enabled fill
|
|
57
|
+
* and the disabled fill; the house intents land 57-92 once washed, the
|
|
58
|
+
* accent measured 2.5-8.2 before the fix.
|
|
59
|
+
*/
|
|
60
|
+
const MIN_FILL_DELTA_E = 20;
|
|
61
|
+
const MIN_OPACITY_DELTA = 0.3;
|
|
62
|
+
|
|
63
|
+
type Scheme = (typeof SCHEMES)[number];
|
|
64
|
+
|
|
65
|
+
function houseRender(ui: ReactElement, scheme: Scheme, disabledStyle: string) {
|
|
66
|
+
return render(
|
|
67
|
+
<TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
|
|
68
|
+
<Preset overrides={{ disabledStyle: disabledStyle as "keepLabel" | "dimWhole" }}>
|
|
69
|
+
<YStack backgroundColor="$background">{ui}</YStack>
|
|
70
|
+
</Preset>
|
|
71
|
+
</TamaguiProvider>,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function injectedRuleValue(className: string, property: string): string {
|
|
76
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
77
|
+
for (const rule of Array.from(sheet.cssRules)) {
|
|
78
|
+
if (!(rule instanceof CSSStyleRule)) continue;
|
|
79
|
+
if (!rule.selectorText.endsWith(`.${className}`)) continue;
|
|
80
|
+
const value = rule.style.getPropertyValue(property);
|
|
81
|
+
if (value) return value.trim();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`no injected ${property} rule for .${className}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The innermost Tamagui theme scope painting `el`, e.g. `accent_Button`. */
|
|
88
|
+
function scopeOf(el: Element): string | null {
|
|
89
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
90
|
+
const names = Array.from(node.classList)
|
|
91
|
+
.filter((c) => c.startsWith("t_") && c !== "t_sub_theme")
|
|
92
|
+
.map((c) => c.slice(2))
|
|
93
|
+
.filter((c) => c !== "light" && c !== "dark");
|
|
94
|
+
if (names.length) return names.sort((a, b) => b.length - a.length)[0];
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveInScope(value: string, scheme: Scheme, scope: string | null): string {
|
|
100
|
+
const token = value.match(/^var\(--([\w-]+)\)$/)?.[1];
|
|
101
|
+
if (!token) return value;
|
|
102
|
+
const chain: string[] = [];
|
|
103
|
+
if (scope) {
|
|
104
|
+
const parts = scope.split("_");
|
|
105
|
+
for (let i = parts.length; i > 0; i--) chain.push(`${scheme}_${parts.slice(0, i).join("_")}`);
|
|
106
|
+
}
|
|
107
|
+
chain.push(scheme);
|
|
108
|
+
for (const name of chain) {
|
|
109
|
+
const hit = houseThemes[name]?.[token];
|
|
110
|
+
if (hit) return hit;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`--${token} resolves in none of ${chain.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What `el` paints for one atomic property: `_bg-` → background-color. */
|
|
116
|
+
function painted(el: Element, prefix: string, property: string, scheme: Scheme): string {
|
|
117
|
+
const cls = Array.from(el.classList).find(
|
|
118
|
+
(c) => c.startsWith(prefix) && !/^_[a-z]+-0(hover|active|focus|press)/.test(c),
|
|
119
|
+
);
|
|
120
|
+
if (!cls) throw new Error(`${el.tagName} carries no ${prefix} class`);
|
|
121
|
+
return resolveInScope(injectedRuleValue(cls, property), scheme, scopeOf(el));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function opacityOf(el: Element): number {
|
|
125
|
+
const cls = Array.from(el.classList).find((c) => c.startsWith("_o-"));
|
|
126
|
+
return cls ? Number(injectedRuleValue(cls, "opacity")) : 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toLab(color: string): [number, number, number] {
|
|
130
|
+
const hex = normalizeToHex(color);
|
|
131
|
+
if (!hex) throw new Error(`not an opaque color: ${color}`);
|
|
132
|
+
const lin = (i: number) => {
|
|
133
|
+
const v = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
134
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
135
|
+
};
|
|
136
|
+
const [r, g, b] = [lin(1), lin(3), lin(5)];
|
|
137
|
+
const f = (t: number) => (t > 216 / 24389 ? Math.cbrt(t) : ((24389 / 27) * t + 16) / 116);
|
|
138
|
+
const x = f((0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047);
|
|
139
|
+
const y = f(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
|
140
|
+
const z = f((0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883);
|
|
141
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function deltaE(a: string, b: string): number {
|
|
145
|
+
const [p, q] = [toLab(a), toLab(b)];
|
|
146
|
+
return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function measure(scheme: Scheme, disabledStyle: string, props: Partial<ButtonProps>) {
|
|
150
|
+
houseRender(
|
|
151
|
+
<>
|
|
152
|
+
<Button {...props}>Enabled</Button>
|
|
153
|
+
<Button {...props} disabled>
|
|
154
|
+
Disabled
|
|
155
|
+
</Button>
|
|
156
|
+
</>,
|
|
157
|
+
scheme,
|
|
158
|
+
disabledStyle,
|
|
159
|
+
);
|
|
160
|
+
const enabledLabel = screen.getByText("Enabled");
|
|
161
|
+
const disabledLabel = screen.getByText("Disabled");
|
|
162
|
+
const enabled = enabledLabel.closest("[role=button]") as Element;
|
|
163
|
+
const disabled = disabledLabel.closest("[role=button]") as Element;
|
|
164
|
+
const enabledFill = painted(enabled, "_bg-", "background-color", scheme);
|
|
165
|
+
const disabledFill = painted(disabled, "_bg-", "background-color", scheme);
|
|
166
|
+
const labelInk = painted(disabledLabel, "_col-", "color", scheme);
|
|
167
|
+
return {
|
|
168
|
+
enabledFill,
|
|
169
|
+
disabledFill,
|
|
170
|
+
labelInk,
|
|
171
|
+
fillDeltaE: deltaE(enabledFill, disabledFill),
|
|
172
|
+
opacityDelta: opacityOf(enabled) - opacityOf(disabled),
|
|
173
|
+
labelContrast: measureContrast({ foreground: labelInk, background: disabledFill }).ratio,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("MPO-433 LC-40: a disabled intent Button differs from its enabled twin", () => {
|
|
178
|
+
for (const scheme of SCHEMES) {
|
|
179
|
+
for (const disabledStyle of DISABLED_STYLES) {
|
|
180
|
+
for (const variant of VARIANTS) {
|
|
181
|
+
it(`${scheme} ${disabledStyle} ${variant.name}: fill ΔE ≥ ${MIN_FILL_DELTA_E} or opacity delta ≥ ${MIN_OPACITY_DELTA}`, () => {
|
|
182
|
+
const m = measure(scheme, disabledStyle, variant.props);
|
|
183
|
+
const detail = `${m.enabledFill} -> ${m.disabledFill}: ΔE ${m.fillDeltaE.toFixed(1)}, opacity delta ${m.opacityDelta}`;
|
|
184
|
+
expect(
|
|
185
|
+
m.fillDeltaE >= MIN_FILL_DELTA_E || m.opacityDelta >= MIN_OPACITY_DELTA,
|
|
186
|
+
detail,
|
|
187
|
+
).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("MPO-433 LC-40: keepLabel keeps the disabled label ≥ 4.5:1 on its own fill", () => {
|
|
195
|
+
for (const scheme of SCHEMES) {
|
|
196
|
+
for (const variant of VARIANTS) {
|
|
197
|
+
it(`${scheme} ${variant.name}`, () => {
|
|
198
|
+
const m = measure(scheme, "keepLabel", variant.props);
|
|
199
|
+
expect(
|
|
200
|
+
m.labelContrast,
|
|
201
|
+
`${m.labelInk} on ${m.disabledFill}: ${m.labelContrast.toFixed(2)}:1`,
|
|
202
|
+
).toBeGreaterThanOrEqual(aaTextContrastRatio);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* MPO-447: under fillStyle outlined a `theme="accent"` Button (the portal's
|
|
5
|
+
* Sign in) sits on its host with no fill, and its label kept the accent
|
|
6
|
+
* sub-theme's on-fill `$color`, 1.06:1 on the light page. The accent slot is
|
|
7
|
+
* solid by palette shape (defaults/accent.ts), so no step of its own ramp
|
|
8
|
+
* reads on the page in both schemes; MPO-40 re-anchored only the solid
|
|
9
|
+
* intents (error, warning, success) to `$color11`. The label and the frame's
|
|
10
|
+
* `color` (currentColor glyphs) now take the host's readable accent ink.
|
|
11
|
+
*
|
|
12
|
+
* The `accent` intent stays filled under an outlined preset (SB-E-01,
|
|
13
|
+
* intents.ts), so the knob channel only reaches `theme="accent"`; an explicit
|
|
14
|
+
* `outlined` prop reaches both.
|
|
15
|
+
*
|
|
16
|
+
* jsdom cascades no Tamagui CSS, so this reads what the nodes ask for: the
|
|
17
|
+
* atomic `_col-` class, the injected rule behind it, and for a `var(--token)`
|
|
18
|
+
* rule the house theme table of the scope that paints it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
22
|
+
import {
|
|
23
|
+
aaTextContrastRatio,
|
|
24
|
+
createDefaultThemeConfig,
|
|
25
|
+
createThemesBuilder,
|
|
26
|
+
defaultAccentTheme,
|
|
27
|
+
defaultBaseTheme,
|
|
28
|
+
defaultBuilderOptions,
|
|
29
|
+
measureContrast,
|
|
30
|
+
Preset,
|
|
31
|
+
} from "@multiplatform.one/theme";
|
|
32
|
+
import type { ReactElement } from "react";
|
|
33
|
+
import { TamaguiProvider, YStack } from "tamagui";
|
|
34
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
35
|
+
import { Button, type ButtonProps } from "./index";
|
|
36
|
+
|
|
37
|
+
afterEach(cleanup);
|
|
38
|
+
|
|
39
|
+
const houseConfig = createDefaultThemeConfig();
|
|
40
|
+
const houseThemes = createThemesBuilder(
|
|
41
|
+
defaultBaseTheme,
|
|
42
|
+
defaultAccentTheme,
|
|
43
|
+
defaultBuilderOptions,
|
|
44
|
+
).themes() as Record<string, Record<string, string>>;
|
|
45
|
+
|
|
46
|
+
const SCHEMES = ["light", "dark"] as const;
|
|
47
|
+
type Scheme = (typeof SCHEMES)[number];
|
|
48
|
+
const HOST_SURFACES = ["background", "color1", "color2"] as const;
|
|
49
|
+
const CASES: { name: string; props: Partial<ButtonProps>; knob: boolean }[] = [
|
|
50
|
+
{
|
|
51
|
+
name: 'theme="accent" under the fillStyle outlined knob',
|
|
52
|
+
props: { theme: "accent" },
|
|
53
|
+
knob: true,
|
|
54
|
+
},
|
|
55
|
+
{ name: 'theme="accent" outlined', props: { theme: "accent", outlined: true }, knob: false },
|
|
56
|
+
{ name: "accent outlined", props: { accent: true, outlined: true }, knob: false },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function houseRender(ui: ReactElement, scheme: Scheme, knob: boolean) {
|
|
60
|
+
const body = <YStack backgroundColor="$background">{ui}</YStack>;
|
|
61
|
+
return render(
|
|
62
|
+
<TamaguiProvider config={houseConfig.tamagui} defaultTheme={scheme} disableInjectCSS>
|
|
63
|
+
{knob ? <Preset overrides={{ fillStyle: "outlined" }}>{body}</Preset> : body}
|
|
64
|
+
</TamaguiProvider>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function injectedRuleValue(className: string, property: string): string {
|
|
69
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
70
|
+
for (const rule of Array.from(sheet.cssRules)) {
|
|
71
|
+
if (!(rule instanceof CSSStyleRule)) continue;
|
|
72
|
+
if (!rule.selectorText.endsWith(`.${className}`)) continue;
|
|
73
|
+
const value = rule.style.getPropertyValue(property);
|
|
74
|
+
if (value) return value.trim();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`no injected ${property} rule for .${className}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The innermost Tamagui theme scope painting `el`, e.g. `accent_Button`. */
|
|
81
|
+
function scopeOf(el: Element): string | null {
|
|
82
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
83
|
+
const names = Array.from(node.classList)
|
|
84
|
+
.filter((c) => c.startsWith("t_") && c !== "t_sub_theme")
|
|
85
|
+
.map((c) => c.slice(2))
|
|
86
|
+
.filter((c) => c !== "light" && c !== "dark");
|
|
87
|
+
if (names.length) return names.sort((a, b) => b.length - a.length)[0];
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveInScope(value: string, scheme: Scheme, scope: string | null): string {
|
|
93
|
+
const token = value.match(/^var\(--([\w-]+)\)$/)?.[1];
|
|
94
|
+
if (!token) return value;
|
|
95
|
+
const chain: string[] = [];
|
|
96
|
+
if (scope) {
|
|
97
|
+
const parts = scope.split("_");
|
|
98
|
+
for (let i = parts.length; i > 0; i--) chain.push(`${scheme}_${parts.slice(0, i).join("_")}`);
|
|
99
|
+
}
|
|
100
|
+
chain.push(scheme);
|
|
101
|
+
for (const name of chain) {
|
|
102
|
+
const hit = houseThemes[name]?.[token];
|
|
103
|
+
if (hit) return hit;
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`--${token} resolves in none of ${chain.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function paintedColor(el: Element, scheme: Scheme): string {
|
|
109
|
+
const cls = Array.from(el.classList).find(
|
|
110
|
+
(c) => c.startsWith("_col-") && !/^_col-0(hover|active|focus|press)/.test(c),
|
|
111
|
+
);
|
|
112
|
+
if (!cls) throw new Error(`${el.tagName} carries no _col- class`);
|
|
113
|
+
return resolveInScope(injectedRuleValue(cls, "color"), scheme, scopeOf(el));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("MPO-447: an outlined accent Button's label reads on its host", () => {
|
|
117
|
+
for (const scheme of SCHEMES) {
|
|
118
|
+
for (const c of CASES) {
|
|
119
|
+
it(`${scheme} ${c.name}: label and glyph ink clear 4.5:1 on every host surface`, () => {
|
|
120
|
+
houseRender(<Button {...c.props}>Sign in</Button>, scheme, c.knob);
|
|
121
|
+
const label = screen.getByText("Sign in");
|
|
122
|
+
const frame = label.closest("[role=button]") as Element;
|
|
123
|
+
for (const [part, el] of [
|
|
124
|
+
["label", label],
|
|
125
|
+
["frame", frame],
|
|
126
|
+
] as const) {
|
|
127
|
+
const ink = paintedColor(el, scheme);
|
|
128
|
+
for (const surface of HOST_SURFACES) {
|
|
129
|
+
const ground = houseThemes[scheme][surface];
|
|
130
|
+
const ratio = measureContrast({ foreground: ink, background: ground }).ratio;
|
|
131
|
+
expect(
|
|
132
|
+
ratio,
|
|
133
|
+
`${part} ${ink} on ${scheme}.${surface} ${ground}: ${ratio.toFixed(2)}:1`,
|
|
134
|
+
).toBeGreaterThanOrEqual(aaTextContrastRatio);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const scheme of SCHEMES) {
|
|
142
|
+
for (const c of [
|
|
143
|
+
{ name: 'theme="accent"', props: { theme: "accent" }, knob: false },
|
|
144
|
+
{ name: "accent under the fillStyle outlined knob", props: { accent: true }, knob: true },
|
|
145
|
+
] as const) {
|
|
146
|
+
it(`${scheme}: a filled ${c.name} Button keeps its on-fill label`, () => {
|
|
147
|
+
houseRender(<Button {...c.props}>Filled</Button>, scheme, c.knob);
|
|
148
|
+
expect(screen.getByText("Filled").className).toMatch(/_col-color\b/);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|