@multiplatform.one/theme 7.34.4 → 7.36.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/package.json +20 -5
- package/src/audit/constraintAudit.spec.ts +274 -87
- package/src/audit/constraintAudit.ts +135 -120
- package/src/audit/constraintScope.spec.ts +1 -1
- package/src/audit/designTokens.ts +42 -0
- package/src/audit/themeMatrix.spec.ts +83 -2
- package/src/audit/themeMatrix.ts +33 -1
- package/src/figma/figmaTokens.spec.ts +26 -34
- package/src/figma/figmaTokens.ts +26 -50
- package/src/fonts/createFontLoader.ts +4 -1
- package/src/menuRow.ts +4 -1
- package/src/theme/FontKnobStyles.tsx +2 -1
- package/src/theme/Preset.stories.tsx +0 -1
- package/src/theme/Preset.tsx +6 -1
- package/src/theme/PresetContext.ts +4 -0
- package/src/theme/Surface.spec.tsx +114 -67
- package/src/theme/Surface.stories.tsx +8 -25
- package/src/theme/Surface.tsx +87 -43
- package/src/theme/accentFloor.spec.ts +37 -11
- package/src/theme/activateThemeConfig.ts +22 -6
- package/src/theme/animations/animations.spec.ts +62 -0
- package/src/theme/animations/css.ts +9 -35
- package/src/theme/animations/reactNative.ts +98 -25
- package/src/theme/boundaryFloor.spec.ts +57 -0
- package/src/theme/chartPalette.spec.ts +56 -27
- package/src/theme/chartPalette.ts +33 -28
- package/src/theme/colorRules.spec.ts +32 -72
- package/src/theme/cookies.spec.ts +49 -18
- package/src/theme/cookies.ts +36 -7
- package/src/theme/cookiesPersistence.spec.ts +5 -5
- package/src/theme/createDefaultThemeConfig.spec.ts +182 -20
- package/src/theme/createDefaultThemeConfig.ts +34 -28
- package/src/theme/createThemes.ts +169 -218
- package/src/theme/defaults/accent.ts +3 -35
- package/src/theme/defaults/base.ts +14 -53
- package/src/theme/defaults/builderOptions.ts +68 -85
- package/src/theme/defaults/categoryFonts.ts +10 -0
- package/src/theme/defaults/fonts.spec.ts +84 -4
- package/src/theme/defaults/fonts.ts +166 -124
- package/src/theme/defaults/house.ts +48 -0
- package/src/theme/defaults/index.ts +1 -0
- package/src/theme/defaults/neutralRamp.spec.ts +52 -47
- package/src/theme/devtools/ColorLineVisualizer.tsx +6 -6
- package/src/theme/devtools/ThemeDevtoolsPanel.setMarkers.spec.tsx +13 -6
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +14 -77
- package/src/theme/devtools/animationOptions.spec.ts +23 -37
- package/src/theme/devtools/animationOptions.ts +4 -6
- package/src/theme/devtools/buildSnapshot.ts +0 -3
- package/src/theme/devtools/knobIcons.tsx +4 -24
- package/src/theme/focusState.spec.ts +9 -30
- package/src/theme/index.ts +17 -0
- package/src/theme/intentFill.spec.ts +42 -0
- package/src/theme/knobs.ts +14 -42
- package/src/theme/layoutTokens.ts +15 -1
- package/src/theme/layoutTokensHooks.spec.tsx +8 -4
- package/src/theme/mergedTokens.ts +20 -0
- package/src/theme/motion.spec.ts +84 -0
- package/src/theme/motion.ts +53 -0
- package/src/theme/motionStops.ts +17 -0
- package/src/theme/motionTiming.ts +59 -0
- package/src/theme/mpo13Proof.spec.ts +43 -46
- package/src/theme/mpo19Proof.spec.ts +13 -14
- package/src/theme/namedInkFloor.spec.ts +60 -0
- package/src/theme/overlaySurfaces.tsx +330 -0
- package/src/theme/preset.types.ts +16 -0
- package/src/theme/presetPreview.ts +32 -0
- package/src/theme/presetRoot.spec.tsx +89 -0
- package/src/theme/presetStore.persistence.spec.ts +141 -0
- package/src/theme/presetStore.spec.ts +205 -0
- package/src/theme/presetStore.ts +286 -0
- package/src/theme/presetUserSync.spec.tsx +168 -0
- package/src/theme/presetUserSync.ts +47 -0
- package/src/theme/presets.spec.ts +22 -15
- package/src/theme/presets.ts +22 -10
- package/src/theme/radiusClass.spec.ts +60 -41
- package/src/theme/radiusClass.ts +17 -4
- package/src/theme/radiusClassTable.generated.ts +3 -3
- package/src/theme/readableColor.spec.ts +59 -152
- package/src/theme/readableColor.ts +34 -174
- package/src/theme/recipeInputs.ts +153 -295
- package/src/theme/recipes.ts +58 -18
- package/src/theme/resolveKnobs.spec.ts +528 -588
- package/src/theme/resolveKnobs.ts +250 -325
- package/src/theme/scale/ScaleStyles.native.tsx +19 -0
- package/src/theme/scale/ScaleStyles.tsx +48 -0
- package/src/theme/scale/activeScale.ts +30 -0
- package/src/theme/scale/deriveRecipeInputs.ts +110 -0
- package/src/theme/scale/generateScale.spec.ts +260 -0
- package/src/theme/scale/generateScale.ts +147 -0
- package/src/theme/scale/index.ts +54 -0
- package/src/theme/scale/installScaleConfig.native.spec.ts +77 -0
- package/src/theme/scale/installScaleConfig.native.ts +57 -0
- package/src/theme/scale/installScaleConfig.ts +7 -0
- package/src/theme/scale/scaleAttribute.ts +21 -0
- package/src/theme/scale/scaleCss.probe.spec.tsx +64 -0
- package/src/theme/scale/scaleCss.spec.ts +132 -0
- package/src/theme/scale/scaleCss.ts +95 -0
- package/src/theme/scale/scaleFonts.ts +106 -0
- package/src/theme/scale/scaleInputs.ts +322 -0
- package/src/theme/scale/themeScales.ts +181 -0
- package/src/theme/scale/useActiveScale.spec.tsx +129 -0
- package/src/theme/scale/useActiveScale.ts +95 -0
- package/src/theme/schemeStore.spec.ts +81 -0
- package/src/theme/schemeStore.ts +67 -0
- package/src/theme/shared.spec.ts +7 -6
- package/src/theme/shared.tsx +37 -3
- package/src/theme/sizeLadder.spec.ts +37 -36
- package/src/theme/sizeRecipes.spec.ts +179 -85
- package/src/theme/sizeRecipes.ts +48 -25
- package/src/theme/subsetThemes.spec.ts +9 -3
- package/src/theme/switchThumb.spec.ts +69 -0
- package/src/theme/syntaxFloor.spec.ts +11 -8
- package/src/theme/theme.hydration.native.spec.tsx +51 -0
- package/src/theme/theme.native.tsx +59 -21
- package/src/theme/theme.palette.spec.tsx +76 -0
- package/src/theme/theme.preset.native.spec.tsx +73 -0
- package/src/theme/theme.provider.spec.ts +143 -1
- package/src/theme/theme.tsx +61 -25
- package/src/theme/tileSizes.ts +6 -7
- package/src/theme/transitionProps.native.ts +25 -2
- package/src/theme/transitionProps.spec.ts +34 -0
- package/src/theme/transitionProps.ts +26 -2
- package/src/theme/usePresetSwitch.native.spec.tsx +68 -0
- package/src/theme/usePresetSwitch.native.ts +32 -0
- package/src/theme/usePresetSwitch.spec.tsx +103 -0
- package/src/theme/usePresetSwitch.ts +54 -0
- package/src/theme/useResolvedKnobs.hydration.spec.tsx +2 -2
- package/src/theme/useResolvedKnobs.ts +23 -90
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +25 -30
- package/src/theme/useSchemePreference.native.ts +33 -0
- package/src/theme/useSchemePreference.spec.tsx +51 -0
- package/src/theme/useSchemePreference.ts +32 -0
- package/src/theme/useSchemePreference.web.spec.tsx +32 -0
- package/src/theme/useTheme.native.spec.tsx +68 -0
- package/src/theme/useTheme.native.ts +49 -24
- package/src/theme/useTheme.ts +7 -4
- package/types/audit/constraintAudit.d.ts +6 -5
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/designTokens.d.ts +13 -0
- package/types/audit/designTokens.d.ts.map +1 -0
- package/types/audit/themeMatrix.d.ts +10 -0
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/figma/figmaTokens.d.ts +11 -28
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/fonts/createFontLoader.d.ts.map +1 -1
- package/types/menuRow.d.ts +4 -1
- package/types/menuRow.d.ts.map +1 -1
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/Preset.d.ts.map +1 -1
- package/types/theme/PresetContext.d.ts +4 -0
- package/types/theme/PresetContext.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +57 -26
- package/types/theme/Surface.d.ts.map +1 -1
- package/types/theme/activateThemeConfig.d.ts +5 -3
- package/types/theme/activateThemeConfig.d.ts.map +1 -1
- package/types/theme/animations/css.d.ts +27 -34
- package/types/theme/animations/css.d.ts.map +1 -1
- package/types/theme/animations/reactNative.d.ts +196 -45
- package/types/theme/animations/reactNative.d.ts.map +1 -1
- package/types/theme/chartPalette.d.ts +11 -3
- package/types/theme/chartPalette.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +1 -1
- package/types/theme/cookies.d.ts +12 -1
- package/types/theme/cookies.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +13 -13
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts +58 -2
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/defaults/accent.d.ts.map +1 -1
- package/types/theme/defaults/base.d.ts.map +1 -1
- package/types/theme/defaults/builderOptions.d.ts.map +1 -1
- package/types/theme/defaults/categoryFonts.d.ts +10 -0
- package/types/theme/defaults/categoryFonts.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +330 -30
- package/types/theme/defaults/fonts.d.ts.map +1 -1
- package/types/theme/defaults/house.d.ts +8 -0
- package/types/theme/defaults/house.d.ts.map +1 -0
- package/types/theme/defaults/index.d.ts +1 -0
- package/types/theme/defaults/index.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +3 -3
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/devtools/animationOptions.d.ts.map +1 -1
- package/types/theme/devtools/buildSnapshot.d.ts.map +1 -1
- package/types/theme/devtools/knobIcons.d.ts +0 -3
- package/types/theme/devtools/knobIcons.d.ts.map +1 -1
- package/types/theme/index.d.ts +11 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +8 -36
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +13 -1
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/mergedTokens.d.ts +11 -0
- package/types/theme/mergedTokens.d.ts.map +1 -0
- package/types/theme/motion.d.ts +17 -0
- package/types/theme/motion.d.ts.map +1 -0
- package/types/theme/motionStops.d.ts +3 -0
- package/types/theme/motionStops.d.ts.map +1 -0
- package/types/theme/motionTiming.d.ts +36 -0
- package/types/theme/motionTiming.d.ts.map +1 -0
- package/types/theme/overlaySurfaces.d.ts +70 -0
- package/types/theme/overlaySurfaces.d.ts.map +1 -0
- package/types/theme/preset.types.d.ts +16 -0
- package/types/theme/preset.types.d.ts.map +1 -1
- package/types/theme/presetPreview.d.ts +14 -0
- package/types/theme/presetPreview.d.ts.map +1 -0
- package/types/theme/presetStore.d.ts +40 -0
- package/types/theme/presetStore.d.ts.map +1 -0
- package/types/theme/presetUserSync.d.ts +7 -0
- package/types/theme/presetUserSync.d.ts.map +1 -0
- package/types/theme/presets.d.ts +12 -4
- package/types/theme/presets.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +5 -2
- package/types/theme/radiusClass.d.ts.map +1 -1
- package/types/theme/radiusClassTable.generated.d.ts +3 -3
- package/types/theme/readableColor.d.ts +11 -49
- package/types/theme/readableColor.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +39 -70
- package/types/theme/recipeInputs.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +79 -18
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +64 -32
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/scale/ScaleStyles.d.ts +18 -0
- package/types/theme/scale/ScaleStyles.d.ts.map +1 -0
- package/types/theme/scale/ScaleStyles.native.d.ts +10 -0
- package/types/theme/scale/ScaleStyles.native.d.ts.map +1 -0
- package/types/theme/scale/activeScale.d.ts +13 -0
- package/types/theme/scale/activeScale.d.ts.map +1 -0
- package/types/theme/scale/deriveRecipeInputs.d.ts +10 -0
- package/types/theme/scale/deriveRecipeInputs.d.ts.map +1 -0
- package/types/theme/scale/generateScale.d.ts +38 -0
- package/types/theme/scale/generateScale.d.ts.map +1 -0
- package/types/theme/scale/index.d.ts +17 -0
- package/types/theme/scale/index.d.ts.map +1 -0
- package/types/theme/scale/installScaleConfig.d.ts +4 -0
- package/types/theme/scale/installScaleConfig.d.ts.map +1 -0
- package/types/theme/scale/installScaleConfig.native.d.ts +13 -0
- package/types/theme/scale/installScaleConfig.native.d.ts.map +1 -0
- package/types/theme/scale/scaleAttribute.d.ts +14 -0
- package/types/theme/scale/scaleAttribute.d.ts.map +1 -0
- package/types/theme/scale/scaleCss.d.ts +25 -0
- package/types/theme/scale/scaleCss.d.ts.map +1 -0
- package/types/theme/scale/scaleFonts.d.ts +16 -0
- package/types/theme/scale/scaleFonts.d.ts.map +1 -0
- package/types/theme/scale/scaleInputs.d.ts +66 -0
- package/types/theme/scale/scaleInputs.d.ts.map +1 -0
- package/types/theme/scale/themeScales.d.ts +52 -0
- package/types/theme/scale/themeScales.d.ts.map +1 -0
- package/types/theme/scale/useActiveScale.d.ts +31 -0
- package/types/theme/scale/useActiveScale.d.ts.map +1 -0
- package/types/theme/schemeStore.d.ts +6 -0
- package/types/theme/schemeStore.d.ts.map +1 -0
- package/types/theme/shared.d.ts +18 -0
- package/types/theme/shared.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +20 -15
- package/types/theme/sizeRecipes.d.ts.map +1 -1
- package/types/theme/theme.d.ts +2 -1
- package/types/theme/theme.d.ts.map +1 -1
- package/types/theme/theme.native.d.ts +10 -1
- package/types/theme/theme.native.d.ts.map +1 -1
- package/types/theme/tileSizes.d.ts +6 -7
- package/types/theme/tileSizes.d.ts.map +1 -1
- package/types/theme/transitionProps.d.ts +3 -1
- package/types/theme/transitionProps.d.ts.map +1 -1
- package/types/theme/transitionProps.native.d.ts +3 -1
- package/types/theme/transitionProps.native.d.ts.map +1 -1
- package/types/theme/usePresetSwitch.d.ts +10 -0
- package/types/theme/usePresetSwitch.d.ts.map +1 -0
- package/types/theme/usePresetSwitch.native.d.ts +10 -0
- package/types/theme/usePresetSwitch.native.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts +13 -13
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/types/theme/useSchemePreference.d.ts +8 -0
- package/types/theme/useSchemePreference.d.ts.map +1 -0
- package/types/theme/useSchemePreference.native.d.ts +8 -0
- package/types/theme/useSchemePreference.native.d.ts.map +1 -0
- package/types/theme/useTheme.d.ts +4 -2
- package/types/theme/useTheme.d.ts.map +1 -1
- package/types/theme/useTheme.native.d.ts +10 -1
- package/types/theme/useTheme.native.d.ts.map +1 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { transitionProps } from "./transitionProps";
|
|
2
|
+
import { Surface } from "./Surface";
|
|
3
|
+
import { useResolvedKnobs } from "./useResolvedKnobs";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { createContext, forwardRef, useContext, useEffect } from "react";
|
|
6
|
+
import {
|
|
7
|
+
AlertDialog,
|
|
8
|
+
Dialog,
|
|
9
|
+
isWeb,
|
|
10
|
+
Popover,
|
|
11
|
+
useDialogContext,
|
|
12
|
+
type DialogContentProps as TamaguiDialogContentProps,
|
|
13
|
+
type DialogOverlayProps as TamaguiDialogOverlayProps,
|
|
14
|
+
type SizeTokens,
|
|
15
|
+
type TamaguiElement,
|
|
16
|
+
type ViewProps,
|
|
17
|
+
} from "tamagui";
|
|
18
|
+
|
|
19
|
+
// ── Overlay frame focus (LC-71) ──────────────────────────────────────────
|
|
20
|
+
// Radix Dialog.Content / Primer Dialog are tabIndex=-1 and auto-focused on
|
|
21
|
+
// open. The frame is not a control: UA :focus-visible must not ring the
|
|
22
|
+
// panel. Inner controls ring on keyboard-origin focus.
|
|
23
|
+
|
|
24
|
+
const overlayFrameFocus = {
|
|
25
|
+
outlineWidth: 0,
|
|
26
|
+
outlineStyle: "none" as any,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const overlayFrameFocusStyle = {
|
|
30
|
+
outlineWidth: 0,
|
|
31
|
+
outlineStyle: "none" as any,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
// ── Overlay open focus (MPO-452, MPO-466) ────────────────────────────────
|
|
35
|
+
// Tamagui 2.7's FocusScope decides "focus is not inside yet", waits for
|
|
36
|
+
// idle (up to 200 ms), then focuses the first tabbable that does not
|
|
37
|
+
// already hold focus: a click into a field during that wait had focus
|
|
38
|
+
// moved to another control, so the typing that followed went there. The
|
|
39
|
+
// house handler checks and focuses in one step. With no tabbable yet,
|
|
40
|
+
// tamagui's deferred path still runs. Dialog content sits in a native
|
|
41
|
+
// <dialog> whose show() puts focus inside before the check in browsers,
|
|
42
|
+
// so there FocusScope never calls the handler; useDialogShowFocus does
|
|
43
|
+
// when show() left focus on something that is not a tab stop (MPO-470).
|
|
44
|
+
|
|
45
|
+
function isTabbableWithin(node: HTMLElement, container: HTMLElement): boolean {
|
|
46
|
+
if (node.tabIndex < 0 || node.hidden || node.tagName === "A") return false;
|
|
47
|
+
if ((node as HTMLInputElement).disabled) return false;
|
|
48
|
+
if (node instanceof HTMLInputElement && node.type === "hidden") return false;
|
|
49
|
+
if (getComputedStyle(node).visibility === "hidden") return false;
|
|
50
|
+
for (let el: HTMLElement | null = node; el && el !== container; el = el.parentElement) {
|
|
51
|
+
if (getComputedStyle(el).display === "none") return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function focusFirstTabbableOnOpen(event: Event): void {
|
|
57
|
+
const container = event.currentTarget;
|
|
58
|
+
if (typeof HTMLElement === "undefined" || !(container instanceof HTMLElement)) return;
|
|
59
|
+
const first = Array.from(container.querySelectorAll<HTMLElement>("*")).find((node) =>
|
|
60
|
+
isTabbableWithin(node, container),
|
|
61
|
+
);
|
|
62
|
+
if (!first) return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
first.focus({ preventScroll: true });
|
|
65
|
+
if (first instanceof HTMLInputElement) first.select();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type OpenAutoFocus = (event: Event) => void;
|
|
69
|
+
|
|
70
|
+
/** The caller's handler runs first; its `preventDefault()` skips the house open focus. */
|
|
71
|
+
function withHouseOpenFocus(onOpenAutoFocus?: OpenAutoFocus): OpenAutoFocus {
|
|
72
|
+
return (event) => {
|
|
73
|
+
onOpenAutoFocus?.(event);
|
|
74
|
+
if (!event.defaultPrevented) focusFirstTabbableOnOpen(event);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DIALOG_SHOW_FOCUS_EVENT = "mp.dialogShowFocus";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Chromium and WebKit's `<dialog>.show()` focuses the first focusable
|
|
82
|
+
* descendant, so a tabIndex -1 frame (the house Input's group frame, a
|
|
83
|
+
* tabIndex -1 panel) takes focus; Firefox and the HTML spec take the first
|
|
84
|
+
* tab stop. When focus sits on something that is not a tab stop, run
|
|
85
|
+
* `onOpenAutoFocus` against the content the way FocusScope would. The
|
|
86
|
+
* content is found by its id because FocusScope clones it with its own ref,
|
|
87
|
+
* so a ref passed to `Dialog.Content` never attaches on web. Passive effects
|
|
88
|
+
* run after the portal's layout-effect `show()`.
|
|
89
|
+
*/
|
|
90
|
+
function useDialogShowFocus(onOpenAutoFocus: OpenAutoFocus): void {
|
|
91
|
+
const { open, contentId } = useDialogContext();
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!open || !contentId || !isWeb || typeof document === "undefined") return;
|
|
94
|
+
const content = document.getElementById(contentId);
|
|
95
|
+
const focused = document.activeElement;
|
|
96
|
+
if (!content || !(focused instanceof HTMLElement)) return;
|
|
97
|
+
if (!content.contains(focused) || focused.tabIndex >= 0) return;
|
|
98
|
+
content.addEventListener(DIALOG_SHOW_FOCUS_EVENT, onOpenAutoFocus, { once: true });
|
|
99
|
+
content.dispatchEvent(new CustomEvent(DIALOG_SHOW_FOCUS_EVENT, { cancelable: true }));
|
|
100
|
+
}, [open, contentId]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The house open focus for a raw `Dialog.Content`, which `DialogContent` and
|
|
105
|
+
* `AlertDialogContent` already run: render it inside the content. When
|
|
106
|
+
* `<dialog>.show()` left focus off a tab stop, the caller's `onOpenAutoFocus`
|
|
107
|
+
* runs, then the first tabbable takes focus (MPO-470).
|
|
108
|
+
*/
|
|
109
|
+
export function DialogShowFocus({ onOpenAutoFocus }: { onOpenAutoFocus?: OpenAutoFocus }): null {
|
|
110
|
+
useDialogShowFocus(withHouseOpenFocus(onOpenAutoFocus));
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Dialog region context (DG-OVL-04) ───────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export interface DialogRegionContextValue {
|
|
117
|
+
/** True inside a dialog surface — dialog action rules apply (DG-OVL-04). */
|
|
118
|
+
inDialogRegion: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `DialogContent` and `AlertDialogContent` provide this automatically. Wrap custom dialog-like
|
|
123
|
+
* surfaces (sheet-as-dialog, hand-rolled dialog chrome) so their action rows
|
|
124
|
+
* inherit dialog rules such as the max-2-actions cap (DG-OVL-04).
|
|
125
|
+
*/
|
|
126
|
+
export const DialogRegionContext = createContext<DialogRegionContextValue>({
|
|
127
|
+
inDialogRegion: false,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export function useDialogRegionContext(): DialogRegionContextValue {
|
|
131
|
+
return useContext(DialogRegionContext);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function useIsInDialogRegion(): boolean {
|
|
135
|
+
return useDialogRegionContext().inDialogRegion;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const IN_DIALOG_REGION: DialogRegionContextValue = { inDialogRegion: true };
|
|
139
|
+
|
|
140
|
+
// ── DialogContent ────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const overlayEnterExit = { opacity: 0, y: -4, scale: 0.97 } as const;
|
|
143
|
+
|
|
144
|
+
export type DialogContentProps = Omit<TamaguiDialogContentProps, "size"> & {
|
|
145
|
+
children?: ReactNode;
|
|
146
|
+
size?: SizeTokens;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const DialogContent = forwardRef<TamaguiElement, DialogContentProps>(function DialogContent(
|
|
150
|
+
{ children, size = "$true", ...props },
|
|
151
|
+
ref,
|
|
152
|
+
) {
|
|
153
|
+
return (
|
|
154
|
+
<Surface size={size} painted={false}>
|
|
155
|
+
<DialogContentInner ref={ref} {...props}>
|
|
156
|
+
{children}
|
|
157
|
+
</DialogContentInner>
|
|
158
|
+
</Surface>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const DialogContentInner = forwardRef<
|
|
163
|
+
TamaguiElement,
|
|
164
|
+
TamaguiDialogContentProps & { children?: ReactNode }
|
|
165
|
+
>(function DialogContentInner({ children, onOpenAutoFocus, ...props }, ref) {
|
|
166
|
+
const { knobProps } = useResolvedKnobs({ component: "DialogContent" });
|
|
167
|
+
const openFocus = withHouseOpenFocus(onOpenAutoFocus);
|
|
168
|
+
useDialogShowFocus(openFocus);
|
|
169
|
+
// ThemeableStack unstyled:false defaults `elevate: true`, which paints
|
|
170
|
+
// getElevation(size) — `$true` is a control stop. `elevate={false}` leaves
|
|
171
|
+
// the overlay ladder in `elevatedSurface` as the only shadow (MPO-99).
|
|
172
|
+
return (
|
|
173
|
+
<Dialog.Content
|
|
174
|
+
ref={ref}
|
|
175
|
+
{...knobProps.elevatedSurface}
|
|
176
|
+
{...knobProps.containerRadius}
|
|
177
|
+
{...knobProps.panelPadding}
|
|
178
|
+
{...knobProps.gap}
|
|
179
|
+
elevate={false}
|
|
180
|
+
{...transitionProps(knobProps.motion.overlay)}
|
|
181
|
+
enterStyle={overlayEnterExit}
|
|
182
|
+
exitStyle={overlayEnterExit}
|
|
183
|
+
{...overlayFrameFocus}
|
|
184
|
+
focusStyle={overlayFrameFocusStyle}
|
|
185
|
+
focusVisibleStyle={overlayFrameFocusStyle}
|
|
186
|
+
onOpenAutoFocus={openFocus}
|
|
187
|
+
{...props}
|
|
188
|
+
>
|
|
189
|
+
<DialogRegionContext.Provider value={IN_DIALOG_REGION}>
|
|
190
|
+
{children}
|
|
191
|
+
</DialogRegionContext.Provider>
|
|
192
|
+
</Dialog.Content>
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── DialogOverlay ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
// A scrim only fades — sliding or scaling a full-bleed backdrop reads as a
|
|
199
|
+
// glitch, so this stays opacity-only rather than reusing `overlayEnterExit`.
|
|
200
|
+
const scrimFade = { opacity: 0 } as const;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Knob-aware dialog scrim. Bare `Dialog.Overlay` carries no transition, so
|
|
204
|
+
* the backdrop popped in and out while the content faded (LC-24: enter/exit
|
|
205
|
+
* styles without a resolved token are dead code on the css driver). Pairs
|
|
206
|
+
* with `DialogContent`; `transition` drops to undefined at `animation=none`
|
|
207
|
+
* and under `prefers-reduced-motion`, which is the instant path.
|
|
208
|
+
*/
|
|
209
|
+
export const DialogOverlay = forwardRef<
|
|
210
|
+
TamaguiElement,
|
|
211
|
+
TamaguiDialogOverlayProps & { children?: ReactNode }
|
|
212
|
+
>(function DialogOverlay({ children, ...props }, ref) {
|
|
213
|
+
const { knobProps } = useResolvedKnobs();
|
|
214
|
+
return (
|
|
215
|
+
<Dialog.Overlay
|
|
216
|
+
ref={ref}
|
|
217
|
+
backgroundColor="$shadow6"
|
|
218
|
+
{...transitionProps(knobProps.motion.scrim)}
|
|
219
|
+
enterStyle={scrimFade}
|
|
220
|
+
exitStyle={scrimFade}
|
|
221
|
+
{...props}
|
|
222
|
+
>
|
|
223
|
+
{children}
|
|
224
|
+
</Dialog.Overlay>
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── AlertDialogContent (MPO-21 G6) ───────────────────────────────────────
|
|
229
|
+
// Tamagui's AlertDialog.Content is Dialog.Content under a different name:
|
|
230
|
+
// the styled frame bakes `elevate: true` (plus bg/border/radius/padding).
|
|
231
|
+
// `unstyled` drops that chrome so elevatedSurface is the only surface source.
|
|
232
|
+
|
|
233
|
+
export const AlertDialogContent = forwardRef<TamaguiElement, DialogContentProps>(
|
|
234
|
+
function AlertDialogContent({ children, size = "$true", ...props }, ref) {
|
|
235
|
+
return (
|
|
236
|
+
<Surface size={size} painted={false}>
|
|
237
|
+
<AlertDialogContentInner ref={ref} {...props}>
|
|
238
|
+
{children}
|
|
239
|
+
</AlertDialogContentInner>
|
|
240
|
+
</Surface>
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const AlertDialogContentInner = forwardRef<
|
|
246
|
+
TamaguiElement,
|
|
247
|
+
TamaguiDialogContentProps & { children?: ReactNode }
|
|
248
|
+
>(function AlertDialogContentInner({ children, ...props }, ref) {
|
|
249
|
+
const { knobProps } = useResolvedKnobs({ component: "AlertDialogContent" });
|
|
250
|
+
useDialogShowFocus(withHouseOpenFocus(props.onOpenAutoFocus));
|
|
251
|
+
return (
|
|
252
|
+
<AlertDialog.Content
|
|
253
|
+
ref={ref}
|
|
254
|
+
unstyled
|
|
255
|
+
{...knobProps.elevatedSurface}
|
|
256
|
+
{...knobProps.containerRadius}
|
|
257
|
+
{...knobProps.panelPadding}
|
|
258
|
+
{...knobProps.gap}
|
|
259
|
+
elevate={false}
|
|
260
|
+
{...transitionProps(knobProps.motion.overlay)}
|
|
261
|
+
enterStyle={overlayEnterExit}
|
|
262
|
+
exitStyle={overlayEnterExit}
|
|
263
|
+
{...overlayFrameFocus}
|
|
264
|
+
focusStyle={overlayFrameFocusStyle}
|
|
265
|
+
focusVisibleStyle={overlayFrameFocusStyle}
|
|
266
|
+
{...props}
|
|
267
|
+
>
|
|
268
|
+
<DialogRegionContext.Provider value={IN_DIALOG_REGION}>
|
|
269
|
+
{children}
|
|
270
|
+
</DialogRegionContext.Provider>
|
|
271
|
+
</AlertDialog.Content>
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
export const AlertDialogOverlay = forwardRef<
|
|
276
|
+
TamaguiElement,
|
|
277
|
+
TamaguiDialogOverlayProps & { children?: ReactNode }
|
|
278
|
+
>(function AlertDialogOverlay({ children, ...props }, ref) {
|
|
279
|
+
const { knobProps } = useResolvedKnobs();
|
|
280
|
+
return (
|
|
281
|
+
<AlertDialog.Overlay
|
|
282
|
+
ref={ref}
|
|
283
|
+
backgroundColor="$shadow6"
|
|
284
|
+
{...transitionProps(knobProps.motion.scrim)}
|
|
285
|
+
enterStyle={scrimFade}
|
|
286
|
+
exitStyle={scrimFade}
|
|
287
|
+
{...props}
|
|
288
|
+
>
|
|
289
|
+
{children}
|
|
290
|
+
</AlertDialog.Overlay>
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── PopoverContent ───────────────────────────────────────────────────────
|
|
295
|
+
// OVERLAY_ANCHOR_GAP rule (theme/layoutTokens): free popovers size to
|
|
296
|
+
// content, start-aligned to their anchor with the shared gap (the caller's
|
|
297
|
+
// tamagui <Popover offset={OVERLAY_ANCHOR_GAP}>), flipping/shifting at
|
|
298
|
+
// viewport edges. This frame is the overlay's single surface owner —
|
|
299
|
+
// border comes from the elevatedSurface recipe and radius from the
|
|
300
|
+
// containerRadius cap spread over it; children must not draw their own
|
|
301
|
+
// outer border.
|
|
302
|
+
|
|
303
|
+
type PopoverContentProps = ViewProps & {
|
|
304
|
+
children?: ReactNode;
|
|
305
|
+
/** Runs first; `preventDefault()` skips the house open focus. */
|
|
306
|
+
onOpenAutoFocus?: OpenAutoFocus;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export const PopoverContent = forwardRef<TamaguiElement, PopoverContentProps>(
|
|
310
|
+
function PopoverContent({ children, onOpenAutoFocus, ...props }, ref) {
|
|
311
|
+
const { knobProps } = useResolvedKnobs({ component: "PopoverContent", painted: false });
|
|
312
|
+
return (
|
|
313
|
+
<Popover.Content
|
|
314
|
+
ref={ref}
|
|
315
|
+
{...knobProps.elevatedSurface}
|
|
316
|
+
{...knobProps.containerRadius}
|
|
317
|
+
{...transitionProps(knobProps.motion.overlay)}
|
|
318
|
+
enterStyle={overlayEnterExit}
|
|
319
|
+
exitStyle={overlayEnterExit}
|
|
320
|
+
{...overlayFrameFocus}
|
|
321
|
+
focusStyle={overlayFrameFocusStyle}
|
|
322
|
+
focusVisibleStyle={overlayFrameFocusStyle}
|
|
323
|
+
onOpenAutoFocus={withHouseOpenFocus(onOpenAutoFocus)}
|
|
324
|
+
{...props}
|
|
325
|
+
>
|
|
326
|
+
<Surface painted={false}>{children}</Surface>
|
|
327
|
+
</Popover.Content>
|
|
328
|
+
);
|
|
329
|
+
},
|
|
330
|
+
);
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ThemeName } from "@tamagui/web";
|
|
2
2
|
import type { Knobs } from "./knobs";
|
|
3
|
+
import type { ScaleInputs } from "./scale/scaleInputs";
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A preset is the whole composition a surface renders with: a Tamagui color
|
|
7
|
+
* theme, the knobs, the intent overrides, the tint sequence and the token
|
|
8
|
+
* scale. Names, fixed: `Preset` is this composition; `ThemeConfig` is the
|
|
9
|
+
* built Tamagui config (tokens + Tamagui color themes) it renders against;
|
|
10
|
+
* "theme" alone means a Tamagui color theme. UI copy calls a preset a Theme.
|
|
11
|
+
*/
|
|
4
12
|
export interface Preset {
|
|
5
13
|
/** Tamagui color theme (accent color). */
|
|
6
14
|
theme: ThemeName;
|
|
@@ -15,6 +23,14 @@ export interface Preset {
|
|
|
15
23
|
};
|
|
16
24
|
/** Tint sequence for color cycling across nested regions. */
|
|
17
25
|
tints: ThemeName[];
|
|
26
|
+
/**
|
|
27
|
+
* The token scale this preset renders on: `$size`, `$space`, `$radius`
|
|
28
|
+
* and the type tables are generated from these inputs, so a preset that
|
|
29
|
+
* needs more or less room says so here, never through knobs. Omitted =
|
|
30
|
+
* the default scale. Applies where the preset is the provider's active
|
|
31
|
+
* one; a nested `<Preset>` changes knobs, not tokens.
|
|
32
|
+
*/
|
|
33
|
+
scale?: ScaleInputs;
|
|
18
34
|
}
|
|
19
35
|
|
|
20
36
|
export type IntentOverride = Partial<Knobs> & {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getConfig, getVariableValue } from "@tamagui/web";
|
|
2
|
+
|
|
3
|
+
export interface PresetPreviewColors {
|
|
4
|
+
background: string;
|
|
5
|
+
border: string;
|
|
6
|
+
ink: string;
|
|
7
|
+
accent: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A preset's page, edge, ink and accent colours in `scheme`, read from the
|
|
12
|
+
* built themes. A preview cannot sit inside `<Theme name={scheme}>` instead:
|
|
13
|
+
* Tamagui swaps only the scheme of a coloured ancestor (`dark_blue` stays
|
|
14
|
+
* blue), so a neutral preset would pick up the active preset's hue.
|
|
15
|
+
*/
|
|
16
|
+
export function getPresetPreviewColors(
|
|
17
|
+
hue: string | undefined,
|
|
18
|
+
scheme: "light" | "dark",
|
|
19
|
+
): PresetPreviewColors {
|
|
20
|
+
const themes = getConfig().themes as Record<string, Record<string, unknown> | undefined>;
|
|
21
|
+
const theme = (hue ? themes[`${scheme}_${hue}`] : undefined) ?? themes[scheme] ?? {};
|
|
22
|
+
const read = (key: string): string => {
|
|
23
|
+
const value = getVariableValue(theme[key] as Parameters<typeof getVariableValue>[0]);
|
|
24
|
+
return typeof value === "string" ? value : "";
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
background: read("background"),
|
|
28
|
+
border: read("borderColor"),
|
|
29
|
+
ink: read("color12"),
|
|
30
|
+
accent: read(hue && themes[`${scheme}_${hue}`] ? "color9" : "color12"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { Cookies, CookiesProvider } from "react-cookie";
|
|
3
|
+
import { useThemeName } from "tamagui";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
|
|
6
|
+
import { PresetContext } from "./PresetContext";
|
|
7
|
+
import { defaultPreset } from "./presets";
|
|
8
|
+
import { setStoredPreset } from "./presetStore";
|
|
9
|
+
import { registerPreset } from "./shared";
|
|
10
|
+
import { ThemeProvider } from "./theme";
|
|
11
|
+
import { ThemeProvider as NativeThemeProvider } from "./theme.native";
|
|
12
|
+
|
|
13
|
+
const themeConfig = createDefaultThemeConfig();
|
|
14
|
+
const bluePreset = { ...defaultPreset, theme: "blue" as const };
|
|
15
|
+
const schemePreset = { ...defaultPreset, theme: "" as const };
|
|
16
|
+
|
|
17
|
+
function ThemeNameProbe() {
|
|
18
|
+
return <span data-testid="theme-name">{useThemeName()}</span>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
registerPreset("root-blue", bluePreset);
|
|
23
|
+
registerPreset("root-scheme", schemePreset);
|
|
24
|
+
setStoredPreset(undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
cleanup();
|
|
29
|
+
new Cookies().remove("mp.preset", { path: "/" });
|
|
30
|
+
setStoredPreset(undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const [platform, Provider] of [
|
|
34
|
+
["web", ThemeProvider],
|
|
35
|
+
["native", NativeThemeProvider],
|
|
36
|
+
] as const) {
|
|
37
|
+
describe(`${platform} preset root theme`, () => {
|
|
38
|
+
it("applies persisted presets over the configured default and follows changes", () => {
|
|
39
|
+
const cookies = new Cookies();
|
|
40
|
+
const selectPreset = (name: string) => {
|
|
41
|
+
if (platform === "web") cookies.set("mp.preset", name, { path: "/" });
|
|
42
|
+
else setStoredPreset(name);
|
|
43
|
+
};
|
|
44
|
+
selectPreset("root-blue");
|
|
45
|
+
render(
|
|
46
|
+
<CookiesProvider cookies={cookies}>
|
|
47
|
+
<Provider config={{ ...themeConfig, defaultPreset: "root-scheme" }} systemTheme="light">
|
|
48
|
+
<ThemeNameProbe />
|
|
49
|
+
</Provider>
|
|
50
|
+
</CookiesProvider>,
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByTestId("theme-name").textContent).toBe("light_blue");
|
|
53
|
+
act(() => selectPreset("root-scheme"));
|
|
54
|
+
expect(screen.getByTestId("theme-name").textContent).toBe("light");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
58
|
+
it.each([
|
|
59
|
+
["root-blue", `${scheme}_blue`],
|
|
60
|
+
["root-scheme", scheme],
|
|
61
|
+
])("applies registered preset %s under " + scheme, (presetName, expected) => {
|
|
62
|
+
render(
|
|
63
|
+
<CookiesProvider cookies={new Cookies()}>
|
|
64
|
+
<Provider config={{ ...themeConfig, defaultPreset: presetName }} systemTheme={scheme}>
|
|
65
|
+
<ThemeNameProbe />
|
|
66
|
+
</Provider>
|
|
67
|
+
</CookiesProvider>,
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByTestId("theme-name").textContent).toBe(expected);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
["blue", bluePreset, "root-scheme", "light_blue"],
|
|
75
|
+
["scheme", schemePreset, "root-blue", "light"],
|
|
76
|
+
])("uses the ancestor preset's %s theme", (_, preset, presetName, expected) => {
|
|
77
|
+
render(
|
|
78
|
+
<CookiesProvider cookies={new Cookies()}>
|
|
79
|
+
<PresetContext.Provider value={{ preset, presetName }}>
|
|
80
|
+
<Provider config={themeConfig} systemTheme="light">
|
|
81
|
+
<ThemeNameProbe />
|
|
82
|
+
</Provider>
|
|
83
|
+
</PresetContext.Provider>
|
|
84
|
+
</CookiesProvider>,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByTestId("theme-name").textContent).toBe(expected);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const backend = vi.hoisted(() => ({
|
|
4
|
+
data: new Map<string, string>(),
|
|
5
|
+
getItem: vi.fn(),
|
|
6
|
+
setItem: vi.fn(),
|
|
7
|
+
removeItem: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock("@multiplatform.one/store", () => ({ storage: backend }));
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
backend.data.clear();
|
|
14
|
+
backend.getItem.mockReset().mockImplementation((key) => backend.data.get(key) ?? null);
|
|
15
|
+
backend.setItem.mockReset().mockImplementation((key, value) => {
|
|
16
|
+
backend.data.set(key, value);
|
|
17
|
+
});
|
|
18
|
+
backend.removeItem.mockReset().mockImplementation((key) => {
|
|
19
|
+
backend.data.delete(key);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => vi.useRealTimers());
|
|
23
|
+
|
|
24
|
+
describe("native preset persistence", () => {
|
|
25
|
+
it("hydrates synchronous storage immediately and sanitizes corrupt overrides", async () => {
|
|
26
|
+
backend.data.set("mp.preset", "bold");
|
|
27
|
+
backend.data.set(
|
|
28
|
+
"mp.ov",
|
|
29
|
+
JSON.stringify({ borderRadius: "large", hover: { fillStyle: "filled", bad: [] } }),
|
|
30
|
+
);
|
|
31
|
+
const store = await import("./presetStore");
|
|
32
|
+
const hydration = store.hydrateStoredPreset();
|
|
33
|
+
expect(store.getStoredPreset()).toEqual({
|
|
34
|
+
presetName: "bold",
|
|
35
|
+
overrides: { borderRadius: "large", hover: { fillStyle: "filled" } },
|
|
36
|
+
hydrated: true,
|
|
37
|
+
});
|
|
38
|
+
await hydration;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("never overwrites a selection made before asynchronous hydration", async () => {
|
|
42
|
+
let finish!: (value: string) => void;
|
|
43
|
+
backend.getItem.mockImplementation((key) =>
|
|
44
|
+
key === "mp.preset"
|
|
45
|
+
? new Promise((resolve) => {
|
|
46
|
+
finish = resolve;
|
|
47
|
+
})
|
|
48
|
+
: Promise.resolve(null),
|
|
49
|
+
);
|
|
50
|
+
const store = await import("./presetStore");
|
|
51
|
+
const hydration = store.hydrateStoredPreset();
|
|
52
|
+
store.setStoredPreset("hero");
|
|
53
|
+
finish("bold");
|
|
54
|
+
await hydration;
|
|
55
|
+
expect(store.getStoredPreset()).toEqual({ presetName: "hero", overrides: {}, hydrated: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("releases a hung backend after one second", async () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
backend.getItem.mockImplementation(() => new Promise(() => {}));
|
|
61
|
+
const store = await import("./presetStore");
|
|
62
|
+
const hydration = store.hydrateStoredPreset();
|
|
63
|
+
expect(store.getStoredPreset().hydrated).toBe(false);
|
|
64
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
65
|
+
await hydration;
|
|
66
|
+
expect(store.getStoredPreset().hydrated).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("serializes writes so a slow older selection cannot win on relaunch", async () => {
|
|
70
|
+
const releases: (() => void)[] = [];
|
|
71
|
+
backend.setItem.mockImplementation(
|
|
72
|
+
(key, value) =>
|
|
73
|
+
new Promise<void>((resolve) => {
|
|
74
|
+
releases.push(() => {
|
|
75
|
+
backend.data.set(key, value);
|
|
76
|
+
resolve();
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const store = await import("./presetStore");
|
|
81
|
+
await store.hydrateStoredPreset();
|
|
82
|
+
store.setStoredPreset("bold");
|
|
83
|
+
store.setStoredPreset("hero");
|
|
84
|
+
await vi.waitFor(() => expect(releases).toHaveLength(1));
|
|
85
|
+
releases[0]();
|
|
86
|
+
await vi.waitFor(() => expect(releases).toHaveLength(2));
|
|
87
|
+
releases[1]();
|
|
88
|
+
await vi.waitFor(() => expect(backend.data.get("mp.preset")).toBe("hero"));
|
|
89
|
+
vi.resetModules();
|
|
90
|
+
const relaunched = await import("./presetStore");
|
|
91
|
+
await relaunched.hydrateStoredPreset();
|
|
92
|
+
expect(relaunched.getStoredPreset().presetName).toBe("hero");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps live state if storage throws, then continues queued writes", async () => {
|
|
96
|
+
backend.getItem.mockImplementation(() => {
|
|
97
|
+
throw new Error("unavailable");
|
|
98
|
+
});
|
|
99
|
+
backend.setItem.mockRejectedValueOnce(new Error("unavailable"));
|
|
100
|
+
const store = await import("./presetStore");
|
|
101
|
+
await store.hydrateStoredPreset();
|
|
102
|
+
store.setStoredPreset("bold");
|
|
103
|
+
store.setStoredPreset("hero", { borderRadius: "large" });
|
|
104
|
+
expect(store.getStoredPreset().presetName).toBe("hero");
|
|
105
|
+
await vi.waitFor(() => expect(backend.data.get("mp.preset")).toBe("hero"));
|
|
106
|
+
await vi.waitFor(() =>
|
|
107
|
+
expect(JSON.parse(backend.data.get("mp.ov")!)).toEqual({ borderRadius: "large" }),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("cannot read or write native preset state during web SSR", async () => {
|
|
113
|
+
const store = await import("./presetStore");
|
|
114
|
+
vi.stubGlobal("window", undefined);
|
|
115
|
+
try {
|
|
116
|
+
store.setStoredPreset("bold");
|
|
117
|
+
await store.hydrateStoredPreset();
|
|
118
|
+
expect(store.getStoredPreset()).toEqual({ hydrated: true, overrides: {} });
|
|
119
|
+
expect(backend.getItem).not.toHaveBeenCalled();
|
|
120
|
+
expect(backend.setItem).not.toHaveBeenCalled();
|
|
121
|
+
} finally {
|
|
122
|
+
vi.unstubAllGlobals();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("restores saved overrides on relaunch and clears them when switching presets", async () => {
|
|
127
|
+
let store = await import("./presetStore");
|
|
128
|
+
store.setStoredPreset("bold");
|
|
129
|
+
store.setStoredOverrides({ borderRadius: "large" });
|
|
130
|
+
await vi.waitFor(() => expect(backend.data.get("mp.ov")).toBe('{"borderRadius":"large"}'));
|
|
131
|
+
vi.resetModules();
|
|
132
|
+
store = await import("./presetStore");
|
|
133
|
+
await store.hydrateStoredPreset();
|
|
134
|
+
expect(store.getStoredPreset()).toEqual({
|
|
135
|
+
presetName: "bold",
|
|
136
|
+
overrides: { borderRadius: "large" },
|
|
137
|
+
hydrated: true,
|
|
138
|
+
});
|
|
139
|
+
store.setStoredPreset("default");
|
|
140
|
+
await vi.waitFor(() => expect(backend.data.has("mp.ov")).toBe(false));
|
|
141
|
+
});
|