@multiplatform.one/theme 7.46.0 → 7.47.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/README.md +33 -0
- package/expo.cjs +3 -0
- package/package.json +22 -6
- package/src/audit/constraintAudit.ts +2 -1
- package/src/audit/themeMatrix.ts +1 -0
- package/src/expo.spec.ts +83 -0
- package/src/expo.ts +57 -0
- package/src/expoConfig.spec.ts +67 -0
- package/src/figma/figmaTokens.spec.ts +4 -3
- package/src/theme/FontKnobStyles.tsx +13 -1
- package/src/theme/Heading.tsx +70 -0
- package/src/theme/Intent.stories.tsx +2 -2
- package/src/theme/Preset.stories.tsx +2 -2
- package/src/theme/Preset.tsx +4 -2
- package/src/theme/Surface.stories.tsx +2 -1
- package/src/theme/Surface.tsx +4 -2
- package/src/theme/Text.tsx +152 -0
- package/src/theme/ThemeProvider.stories.tsx +2 -1
- package/src/theme/Tint.stories.tsx +2 -1
- package/src/theme/accentFloor.spec.ts +5 -3
- package/src/theme/accentOnSurface.palette.spec.tsx +75 -0
- package/src/theme/accentOnSurface.spec.tsx +149 -0
- package/src/theme/colorThemeAccent.spec.ts +5 -2
- package/src/theme/cookies.spec.ts +2 -2
- package/src/theme/createDefaultThemeConfig.spec.ts +2 -2
- package/src/theme/createThemes.ts +91 -11
- package/src/theme/defaults/builderOptions.ts +18 -0
- package/src/theme/devtools/ColorLineVisualizer.tsx +3 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +2 -0
- package/src/theme/hairline.native.ts +3 -0
- package/src/theme/hairline.ts +9 -0
- package/src/theme/hairlineToken.spec.ts +110 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/knobs.ts +2 -2
- package/src/theme/mpo19Proof.spec.ts +4 -1
- package/src/theme/overlaySurfaces.tsx +9 -3
- package/src/theme/pageGutter.tsx +61 -0
- package/src/theme/preset.spec.tsx +29 -1
- package/src/theme/presets.spec.ts +1 -1
- package/src/theme/presets.ts +2 -2
- package/src/theme/ramps.spec.ts +53 -0
- package/src/theme/ramps.ts +75 -0
- package/src/theme/readableColor.ts +111 -70
- package/src/theme/recipes.ts +51 -2
- package/src/theme/resolveFontTokenFamily.spec.ts +44 -0
- package/src/theme/resolveKnobs.spec.ts +30 -20
- package/src/theme/resolveKnobs.ts +87 -20
- package/src/theme/selectedState.spec.ts +127 -0
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/expo.d.ts +29 -0
- package/types/expo.d.ts.map +1 -0
- package/types/theme/FontKnobStyles.d.ts +7 -0
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/Heading.d.ts +10 -0
- package/types/theme/Heading.d.ts.map +1 -0
- package/types/theme/Preset.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +2 -1
- package/types/theme/Surface.d.ts.map +1 -1
- package/types/theme/Text.d.ts +13 -0
- package/types/theme/Text.d.ts.map +1 -0
- package/types/theme/colorRules.d.ts +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/defaults/builderOptions.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +30 -30
- package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/hairline.d.ts +8 -0
- package/types/theme/hairline.d.ts.map +1 -1
- package/types/theme/hairline.native.d.ts +2 -0
- package/types/theme/hairline.native.d.ts.map +1 -1
- package/types/theme/index.d.ts +4 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +1 -1
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/overlaySurfaces.d.ts.map +1 -1
- package/types/theme/pageGutter.d.ts +41 -0
- package/types/theme/pageGutter.d.ts.map +1 -0
- package/types/theme/ramps.d.ts +20 -0
- package/types/theme/ramps.d.ts.map +1 -0
- package/types/theme/readableColor.d.ts +24 -12
- package/types/theme/readableColor.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +44 -4
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +10 -4
- package/types/theme/resolveKnobs.d.ts.map +1 -1
|
@@ -33,6 +33,7 @@ function useDomThemeColors(): string[] {
|
|
|
33
33
|
const [colors, setColors] = useState<string[]>(() => fallbackGradient());
|
|
34
34
|
|
|
35
35
|
useEffect(() => {
|
|
36
|
+
if (typeof document === "undefined") return;
|
|
36
37
|
const readColors = () => {
|
|
37
38
|
let result = readColorsFromElement(document.documentElement);
|
|
38
39
|
if (!result && document.body) {
|
|
@@ -184,6 +185,8 @@ function createVizStyles(isDark: boolean) {
|
|
|
184
185
|
width: "fit-content" as const,
|
|
185
186
|
fontSize: 11,
|
|
186
187
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
188
|
+
// Not the host page's tracking, which follows the app's bodyFont knob.
|
|
189
|
+
letterSpacing: "normal",
|
|
187
190
|
color: foreground,
|
|
188
191
|
},
|
|
189
192
|
title: {
|
|
@@ -237,6 +237,8 @@ function createStyles(isDark: boolean) {
|
|
|
237
237
|
return {
|
|
238
238
|
panel: {
|
|
239
239
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
240
|
+
// Not the host page's tracking, which follows the app's bodyFont knob.
|
|
241
|
+
letterSpacing: "normal",
|
|
240
242
|
fontSize: 13,
|
|
241
243
|
color: isDark ? "#e8e8e8" : "#1a1a1a",
|
|
242
244
|
padding: 12,
|
|
@@ -10,6 +10,9 @@ import { StyleSheet } from "react-native";
|
|
|
10
10
|
/** Divider line width: one device pixel for the current screen density. */
|
|
11
11
|
export const hairlineWidth: number = StyleSheet.hairlineWidth;
|
|
12
12
|
|
|
13
|
+
/** LC-148: native draws the `small` border stop as a true one-device-pixel hairline. */
|
|
14
|
+
export const hairlineBorderWidth: number = StyleSheet.hairlineWidth;
|
|
15
|
+
|
|
13
16
|
/** Spreadable divider treatments — see `hairline.ts` for usage. */
|
|
14
17
|
export const hairline = {
|
|
15
18
|
/** Standalone horizontal rule as a filled hairline-height line. */
|
package/src/theme/hairline.ts
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
/** Divider line width: half a CSS pixel = one device pixel on 2x displays. */
|
|
25
25
|
export const hairlineWidth: number = 0.5;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* LC-148 HAIRLINE-TOKEN: the width the `borderWidth` knob's `small` stop draws.
|
|
29
|
+
* Blink floors any border between 0 and 1 CSS px to 1px at every device pixel
|
|
30
|
+
* ratio, so on web the stop is 1px and `resolveKnobs` paints it in
|
|
31
|
+
* `$borderColorHairline`, one ramp step toward the page, so `small` still reads
|
|
32
|
+
* finer than `medium`.
|
|
33
|
+
*/
|
|
34
|
+
export const hairlineBorderWidth: number = 1;
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
37
|
* Spreadable divider treatments. Each entry pairs the 0.5px style with the
|
|
29
38
|
* fallback class the low-DPI media query targets:
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { normalizeToHex, relativeLuminance } from "./colorRules";
|
|
3
|
+
import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
|
|
4
|
+
import { hairlineBorderWidth } from "./hairline";
|
|
5
|
+
import { defaultKnobs, type Knobs } from "./knobs";
|
|
6
|
+
import { edgeColorAt, resolveKnobs } from "./resolveKnobs";
|
|
7
|
+
|
|
8
|
+
vi.mock("react-native", () => ({ StyleSheet: { hairlineWidth: 1 / 3 } }));
|
|
9
|
+
|
|
10
|
+
const knobs = (overrides: Partial<Knobs>): Knobs => ({ ...defaultKnobs, ...overrides });
|
|
11
|
+
const edges = (overrides: Partial<Knobs>, scheme: "light" | "dark" = "light") => {
|
|
12
|
+
const { knobProps } = resolveKnobs(knobs(overrides), undefined, scheme);
|
|
13
|
+
return {
|
|
14
|
+
borderRadius: knobProps.borderRadius.borderColor,
|
|
15
|
+
surface: knobProps.surface.borderColor,
|
|
16
|
+
controlBorderOutlined: knobProps.controlBorderOutlined.borderColor,
|
|
17
|
+
elevatedSurface: knobProps.elevatedSurface.borderColor,
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const themes = createDefaultThemeConfig({}).tamagui.themes as unknown as Record<
|
|
22
|
+
string,
|
|
23
|
+
Record<string, { val: string } | undefined>
|
|
24
|
+
>;
|
|
25
|
+
const rampThemes = Object.keys(themes).filter((name) =>
|
|
26
|
+
/^(light|dark)(_(accent|blue|gray|green|orange|pink|purple|red|yellow|warning|error|success))?$/.test(
|
|
27
|
+
name,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
const lum = (raw: string) => relativeLuminance(normalizeToHex(raw)!);
|
|
31
|
+
|
|
32
|
+
describe("LC-148 HAIRLINE-TOKEN: borderWidth small is the theme's hairline", () => {
|
|
33
|
+
it("web draws the small stop at 1px, because Blink floors sub-pixel borders", () => {
|
|
34
|
+
expect(hairlineBorderWidth).toBe(1);
|
|
35
|
+
expect(resolveKnobs(knobs({ borderWidth: "small" })).knobProps.borderRadius.borderWidth).toBe(
|
|
36
|
+
hairlineBorderWidth,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("native draws the small stop as one device pixel", async () => {
|
|
41
|
+
const native = await import("./hairline.native");
|
|
42
|
+
expect(native.hairlineBorderWidth).toBe(1 / 3);
|
|
43
|
+
expect(native.hairlineBorderWidth).toBeLessThan(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("a small edge no finer than medium paints one ramp step toward the page", () => {
|
|
47
|
+
expect(edges({ borderWidth: "small" })).toEqual({
|
|
48
|
+
borderRadius: "$borderColorHairline",
|
|
49
|
+
surface: "$borderColorHairline",
|
|
50
|
+
controlBorderOutlined: "$borderColorHairline",
|
|
51
|
+
elevatedSurface: "$borderColorHairline",
|
|
52
|
+
});
|
|
53
|
+
expect(edges({ borderWidth: "small" }, "dark").elevatedSurface).toBe("$color5");
|
|
54
|
+
expect(edges({ borderWidth: "none", fillStyle: "outlined" }).borderRadius).toBe(
|
|
55
|
+
"$borderColorHairline",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("every other stop keeps the theme's own edge colour", () => {
|
|
60
|
+
for (const borderWidth of ["none", "medium", "large"] as const) {
|
|
61
|
+
expect(edges({ borderWidth })).toEqual({
|
|
62
|
+
borderRadius: "$borderColor",
|
|
63
|
+
surface: "$borderColor",
|
|
64
|
+
controlBorderOutlined: "$borderColor",
|
|
65
|
+
elevatedSurface: "$borderColor",
|
|
66
|
+
});
|
|
67
|
+
expect(edges({ borderWidth }, "dark").elevatedSurface).toBe("$color6");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("a component's own edge colour takes the same step at small, and only there (FZ-7797)", () => {
|
|
72
|
+
expect(edgeColorAt("$borderColor", "small")).toBe("$borderColorHairline");
|
|
73
|
+
expect(edgeColorAt("$color6", "small")).toBe("$color5");
|
|
74
|
+
expect(edgeColorAt("$color8", "small")).toBe("$color7");
|
|
75
|
+
for (const signal of [
|
|
76
|
+
"$color1",
|
|
77
|
+
"$color9",
|
|
78
|
+
"$color11",
|
|
79
|
+
"$red10",
|
|
80
|
+
"$boundaryColor",
|
|
81
|
+
"$borderColorFocus",
|
|
82
|
+
"transparent",
|
|
83
|
+
])
|
|
84
|
+
expect(edgeColorAt(signal, "small"), signal).toBe(signal);
|
|
85
|
+
for (const stop of ["none", "medium", "large"] as const)
|
|
86
|
+
expect(edgeColorAt("$color6", stop)).toBe("$color6");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("the keepLabel disabled surface edge takes the step too", () => {
|
|
90
|
+
const disabled = (borderWidth: Knobs["borderWidth"]) =>
|
|
91
|
+
resolveKnobs(knobs({ borderWidth, disabledStyle: "keepLabel" })).disabledState;
|
|
92
|
+
expect(disabled("small").surfaceKnobProps.borderColor).toBe("$color5");
|
|
93
|
+
expect(disabled("small").selectedSurfaceKnobProps.borderColor).toBe("$color7");
|
|
94
|
+
expect(disabled("medium").surfaceKnobProps.borderColor).toBe("$color6");
|
|
95
|
+
expect(disabled("medium").selectedSurfaceKnobProps.borderColor).toBe("$color8");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("every ramp theme defines $borderColorHairline one step fainter than $borderColor", () => {
|
|
99
|
+
expect(rampThemes.length).toBeGreaterThanOrEqual(26);
|
|
100
|
+
for (const name of rampThemes) {
|
|
101
|
+
const theme = themes[name]!;
|
|
102
|
+
const ramp = Array.from({ length: 12 }, (_, i) => theme[`color${i + 1}`]?.val);
|
|
103
|
+
const border = theme.borderColor!.val;
|
|
104
|
+
const hairline = theme.borderColorHairline?.val;
|
|
105
|
+
expect(hairline, name).toBe(ramp[ramp.indexOf(border) - 1]);
|
|
106
|
+
const page = lum(theme.color1!.val);
|
|
107
|
+
expect(Math.abs(lum(hairline!) - page), name).toBeLessThan(Math.abs(lum(border) - page));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/theme/index.ts
CHANGED
|
@@ -16,10 +16,12 @@ export * from "./fontCategoryStacks";
|
|
|
16
16
|
export * from "./FontKnobStyles";
|
|
17
17
|
export * from "./groupGeometry";
|
|
18
18
|
export * from "./hairline";
|
|
19
|
+
export * from "./Heading";
|
|
19
20
|
export * from "./Intent";
|
|
20
21
|
export * from "./intents";
|
|
21
22
|
export * from "./knobs";
|
|
22
23
|
export * from "./layoutTokens";
|
|
24
|
+
export * from "./pageGutter";
|
|
23
25
|
export * from "./Preset";
|
|
24
26
|
export type { Preset as PresetDefinition, IntentOverride } from "./preset.types";
|
|
25
27
|
export * from "./PresetContext";
|
|
@@ -29,6 +31,7 @@ export * from "./presetStore";
|
|
|
29
31
|
export * from "./presetUserSync";
|
|
30
32
|
export * from "./radiusClass";
|
|
31
33
|
export * from "./radiusClassTable.generated";
|
|
34
|
+
export * from "./ramps";
|
|
32
35
|
export * from "./readableColor";
|
|
33
36
|
export * from "./recipes";
|
|
34
37
|
export * from "./resolveKnobs";
|
|
@@ -39,6 +42,7 @@ export * from "./themeValue";
|
|
|
39
42
|
export * from "./sizeRecipes";
|
|
40
43
|
export * from "./subsetThemes";
|
|
41
44
|
export * from "./Surface";
|
|
45
|
+
export * from "./Text";
|
|
42
46
|
export * from "./tileSizes";
|
|
43
47
|
export * from "./transitionProps";
|
|
44
48
|
|
package/src/theme/knobs.ts
CHANGED
|
@@ -77,7 +77,7 @@ export const RequiredMarking = {
|
|
|
77
77
|
} as const;
|
|
78
78
|
export type RequiredMarking = (typeof RequiredMarking)[keyof typeof RequiredMarking];
|
|
79
79
|
|
|
80
|
-
/** Alternating table row backgrounds. Default:
|
|
80
|
+
/** Alternating table row backgrounds. Default: on (LC-144 TABLE-ZEBRA). */
|
|
81
81
|
export const TableZebra = {
|
|
82
82
|
On: "on",
|
|
83
83
|
Off: "off",
|
|
@@ -279,7 +279,7 @@ export const defaultKnobs: Knobs = {
|
|
|
279
279
|
animation: "quick",
|
|
280
280
|
fieldLabelPlacement: "top",
|
|
281
281
|
requiredMarking: "minority",
|
|
282
|
-
tableZebra: "
|
|
282
|
+
tableZebra: "on",
|
|
283
283
|
bulkBarPlacement: "top",
|
|
284
284
|
selectAllScope: "page",
|
|
285
285
|
timestampStyle: "absolute",
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
FOCUS_VISIBLE_RING,
|
|
34
34
|
ensureFocusVisibleRing,
|
|
35
35
|
} from "./focusState";
|
|
36
|
+
import { hairlineBorderWidth } from "./hairline";
|
|
36
37
|
import { defaultKnobs } from "./knobs";
|
|
37
38
|
import { layoutBreakpoints, OVERLAY_BREAKPOINT } from "./layoutTokens";
|
|
38
39
|
import { resolveKnobs } from "./resolveKnobs";
|
|
@@ -141,7 +142,9 @@ describe("MPO-19 X3 — a filled control's border is transparent, not $borderCol
|
|
|
141
142
|
|
|
142
143
|
it("the width resolves through the borderWidth knob, not a literal", () => {
|
|
143
144
|
expect(knobProps({ borderWidth: "none" }).controlBorderFilled.borderWidth).toBe(0);
|
|
144
|
-
expect(knobProps({ borderWidth: "small" }).controlBorderFilled.borderWidth).toBe(
|
|
145
|
+
expect(knobProps({ borderWidth: "small" }).controlBorderFilled.borderWidth).toBe(
|
|
146
|
+
hairlineBorderWidth,
|
|
147
|
+
);
|
|
145
148
|
expect(knobProps({ borderWidth: "medium" }).controlBorderFilled.borderWidth).toBe(1);
|
|
146
149
|
expect(knobProps({ borderWidth: "large" }).controlBorderFilled.borderWidth).toBe(2);
|
|
147
150
|
// Outlined is the one that clamps: the outline IS the affordance, so it
|
|
@@ -45,7 +45,13 @@ const overlayFrameFocusStyle = {
|
|
|
45
45
|
function isTabbableWithin(node: HTMLElement, container: HTMLElement): boolean {
|
|
46
46
|
if (node.tabIndex < 0 || node.hidden || node.tagName === "A") return false;
|
|
47
47
|
if ((node as HTMLInputElement).disabled) return false;
|
|
48
|
-
if (
|
|
48
|
+
if (
|
|
49
|
+
typeof HTMLInputElement !== "undefined" &&
|
|
50
|
+
node instanceof HTMLInputElement &&
|
|
51
|
+
node.type === "hidden"
|
|
52
|
+
) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
49
55
|
if (getComputedStyle(node).visibility === "hidden") return false;
|
|
50
56
|
for (let el: HTMLElement | null = node; el && el !== container; el = el.parentElement) {
|
|
51
57
|
if (getComputedStyle(el).display === "none") return false;
|
|
@@ -62,7 +68,7 @@ function focusFirstTabbableOnOpen(event: Event): void {
|
|
|
62
68
|
if (!first) return;
|
|
63
69
|
event.preventDefault();
|
|
64
70
|
first.focus({ preventScroll: true });
|
|
65
|
-
if (first instanceof HTMLInputElement) first.select();
|
|
71
|
+
if (typeof HTMLInputElement !== "undefined" && first instanceof HTMLInputElement) first.select();
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
type OpenAutoFocus = (event: Event) => void;
|
|
@@ -93,7 +99,7 @@ function useDialogShowFocus(onOpenAutoFocus: OpenAutoFocus): void {
|
|
|
93
99
|
if (!open || !contentId || !isWeb || typeof document === "undefined") return;
|
|
94
100
|
const content = document.getElementById(contentId);
|
|
95
101
|
const focused = document.activeElement;
|
|
96
|
-
if (!content || !(focused instanceof HTMLElement)) return;
|
|
102
|
+
if (!content || typeof HTMLElement === "undefined" || !(focused instanceof HTMLElement)) return;
|
|
97
103
|
if (!content.contains(focused) || focused.tabIndex >= 0) return;
|
|
98
104
|
content.addEventListener(DIALOG_SHOW_FOCUS_EVENT, onOpenAutoFocus, { once: true });
|
|
99
105
|
content.dispatchEvent(new CustomEvent(DIALOG_SHOW_FOCUS_EVENT, { cancelable: true }));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page gutter: the horizontal inset the page shell puts between the screen
|
|
3
|
+
* edge and its content, carried down the tree so content that sits directly
|
|
4
|
+
* on the page line knows how far the edge is.
|
|
5
|
+
*
|
|
6
|
+
* LC-146 PHONE-TABLE-BLEED: below `$sm` a table drops that gutter and runs
|
|
7
|
+
* edge to edge, and its first and last cells carry the gutter as their own
|
|
8
|
+
* inset, so text still starts on the page line (Axiom 16) while rows, stripes
|
|
9
|
+
* and rules reach the screen edge. A surface that pads its own content
|
|
10
|
+
* (a card, a sheet) resets the gutter to null, because a table inside it is
|
|
11
|
+
* not on the page line.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { createContext, useContext } from "react";
|
|
16
|
+
import { useMedia } from "tamagui";
|
|
17
|
+
|
|
18
|
+
const PageGutterContext = createContext<string | null>(null);
|
|
19
|
+
|
|
20
|
+
export interface PageGutterProps {
|
|
21
|
+
/**
|
|
22
|
+
* The space token the host pads its content by (`knobProps.panelPadding.padding`),
|
|
23
|
+
* or null where content is not on the page line.
|
|
24
|
+
*/
|
|
25
|
+
inset: string | null;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Declares the gutter the surrounding page pads its content by. Renders no DOM. */
|
|
30
|
+
export function PageGutter({ inset, children }: PageGutterProps) {
|
|
31
|
+
return <PageGutterContext.Provider value={inset}>{children}</PageGutterContext.Provider>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The nearest page gutter token, or null off the page line. */
|
|
35
|
+
export function usePageGutter(): string | null {
|
|
36
|
+
return useContext(PageGutterContext);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `$4` → `$-4`. */
|
|
40
|
+
export function negateSpaceToken(token: string): string {
|
|
41
|
+
if (token.startsWith("$-")) return `$${token.slice(2)}`;
|
|
42
|
+
return `$-${token.startsWith("$") ? token.slice(1) : token}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PhoneTableBleed {
|
|
46
|
+
/** The gutter token the first and last cells take as their inset. */
|
|
47
|
+
inset: string;
|
|
48
|
+
/** Pulls the table's outer frame out to the screen edges. */
|
|
49
|
+
frame: { marginHorizontal: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* LC-146: the bleed a table applies below `$sm` when it sits on the page line,
|
|
54
|
+
* else null (wide screens, or no gutter above it).
|
|
55
|
+
*/
|
|
56
|
+
export function usePhoneTableBleed(): PhoneTableBleed | null {
|
|
57
|
+
const inset = usePageGutter();
|
|
58
|
+
const { sm } = useMedia();
|
|
59
|
+
if (!inset || sm !== false) return null;
|
|
60
|
+
return { inset, frame: { marginHorizontal: negateSpaceToken(inset) } };
|
|
61
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type ReactNode, createElement } from "react";
|
|
1
|
+
import { type ReactNode, createElement, useEffect } from "react";
|
|
2
2
|
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
3
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
5
|
|
|
5
6
|
// Mock Tamagui dependencies to avoid needing a full Tamagui config
|
|
@@ -233,6 +234,33 @@ describe("Preset component", () => {
|
|
|
233
234
|
expect(capture.result?.preset.tints).toEqual(boldPreset.tints);
|
|
234
235
|
});
|
|
235
236
|
|
|
237
|
+
it("keeps its subtree mounted while a picked colour comes and goes", () => {
|
|
238
|
+
let mounts = 0;
|
|
239
|
+
function Probe() {
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
mounts += 1;
|
|
242
|
+
}, []);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const view = render(
|
|
246
|
+
<Preset theme={"" as ThemeName}>
|
|
247
|
+
<Probe />
|
|
248
|
+
</Preset>,
|
|
249
|
+
);
|
|
250
|
+
view.rerender(
|
|
251
|
+
<Preset theme={"green" as ThemeName}>
|
|
252
|
+
<Probe />
|
|
253
|
+
</Preset>,
|
|
254
|
+
);
|
|
255
|
+
view.rerender(
|
|
256
|
+
<Preset theme={"" as ThemeName}>
|
|
257
|
+
<Probe />
|
|
258
|
+
</Preset>,
|
|
259
|
+
);
|
|
260
|
+
expect(mounts).toBe(1);
|
|
261
|
+
view.unmount();
|
|
262
|
+
});
|
|
263
|
+
|
|
236
264
|
it("does not wrap with Tamagui <Theme /> when theme prop is absent", () => {
|
|
237
265
|
const capture = createPresetContextCapture();
|
|
238
266
|
|
|
@@ -23,7 +23,7 @@ import { defaultIntents } from "./intents";
|
|
|
23
23
|
const houseKnobDefaults = {
|
|
24
24
|
fieldLabelPlacement: "top",
|
|
25
25
|
requiredMarking: "minority",
|
|
26
|
-
tableZebra: "
|
|
26
|
+
tableZebra: "on",
|
|
27
27
|
bulkBarPlacement: "top",
|
|
28
28
|
selectAllScope: "page",
|
|
29
29
|
timestampStyle: "absolute",
|
package/src/theme/presets.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const defaultPreset: Preset = {
|
|
|
19
19
|
animation: "quick",
|
|
20
20
|
fieldLabelPlacement: "top",
|
|
21
21
|
requiredMarking: "minority",
|
|
22
|
-
tableZebra: "
|
|
22
|
+
tableZebra: "on",
|
|
23
23
|
bulkBarPlacement: "top",
|
|
24
24
|
selectAllScope: "page",
|
|
25
25
|
timestampStyle: "absolute",
|
|
@@ -51,7 +51,7 @@ export const boldPreset: Preset = {
|
|
|
51
51
|
animation: "bouncy",
|
|
52
52
|
fieldLabelPlacement: "top",
|
|
53
53
|
requiredMarking: "minority",
|
|
54
|
-
tableZebra: "
|
|
54
|
+
tableZebra: "on",
|
|
55
55
|
bulkBarPlacement: "top",
|
|
56
56
|
selectAllScope: "page",
|
|
57
57
|
timestampStyle: "absolute",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defaultChildrenThemes } from "@tamagui/themes/v5";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createThemesBuilder } from "./createThemes";
|
|
4
|
+
import { defaultAccentTheme } from "./defaults/accent";
|
|
5
|
+
import { defaultBaseTheme } from "./defaults/base";
|
|
6
|
+
import { defaultBuilderOptions } from "./defaults/builderOptions";
|
|
7
|
+
import { isThemeRampHue, themeRampColor, themeRampHues } from "./ramps";
|
|
8
|
+
|
|
9
|
+
const themes = createThemesBuilder(
|
|
10
|
+
defaultBaseTheme,
|
|
11
|
+
defaultAccentTheme,
|
|
12
|
+
defaultBuilderOptions,
|
|
13
|
+
).themes() as Record<string, Record<string, string>>;
|
|
14
|
+
|
|
15
|
+
const stock = defaultChildrenThemes as unknown as Record<
|
|
16
|
+
string,
|
|
17
|
+
Record<"light" | "dark", Record<string, string>>
|
|
18
|
+
>;
|
|
19
|
+
const steps = Array.from({ length: 12 }, (_, index) => index + 1);
|
|
20
|
+
|
|
21
|
+
describe("LC-147 theme ramps", () => {
|
|
22
|
+
it("names exactly v5's children palettes, less the neutral control grey", () => {
|
|
23
|
+
expect([...themeRampHues].sort()).toEqual(
|
|
24
|
+
Object.keys(defaultChildrenThemes)
|
|
25
|
+
.filter((hue) => hue !== "neutral")
|
|
26
|
+
.sort(),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resolves every step of every ramp in both base schemes, which every sub-theme inherits", () => {
|
|
31
|
+
const missing: string[] = [];
|
|
32
|
+
for (const name of ["light", "dark"]) {
|
|
33
|
+
const theme = themes[name];
|
|
34
|
+
for (const hue of themeRampHues)
|
|
35
|
+
for (const step of steps)
|
|
36
|
+
if (!theme[`${hue}${step}`]) missing.push(`${name}.$${hue}${step}`);
|
|
37
|
+
}
|
|
38
|
+
expect(missing).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("hands out the stock step the theme builder splats in", () => {
|
|
42
|
+
for (const hue of themeRampHues)
|
|
43
|
+
for (const scheme of ["light", "dark"] as const)
|
|
44
|
+
for (const step of steps)
|
|
45
|
+
expect(themeRampColor(hue, step as 1, scheme)).toBe(stock[hue][scheme][`${hue}${step}`]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects a Radix hue no built theme carries", () => {
|
|
49
|
+
expect(isThemeRampHue("green")).toBe(true);
|
|
50
|
+
for (const hue of ["grass", "tomato", "lime", "sand", "neutral", undefined])
|
|
51
|
+
expect(isThemeRampHue(hue)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
blue,
|
|
3
|
+
blueDark,
|
|
4
|
+
gray,
|
|
5
|
+
grayDark,
|
|
6
|
+
green,
|
|
7
|
+
greenDark,
|
|
8
|
+
orange,
|
|
9
|
+
orangeDark,
|
|
10
|
+
pink,
|
|
11
|
+
pinkDark,
|
|
12
|
+
purple,
|
|
13
|
+
purpleDark,
|
|
14
|
+
red,
|
|
15
|
+
redDark,
|
|
16
|
+
teal,
|
|
17
|
+
tealDark,
|
|
18
|
+
yellow,
|
|
19
|
+
yellowDark,
|
|
20
|
+
} from "@tamagui/colors";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The named colour ramps every built theme carries: `$blue1` through
|
|
24
|
+
* `$blue12` and the rest resolve in every scheme, tint and preset, because
|
|
25
|
+
* the theme builder splats v5's children palettes into each base theme.
|
|
26
|
+
* Data that picks a colour (a category, a type, a facet value) picks one of
|
|
27
|
+
* these. A Radix hue outside the list (`grass`, `tomato`, `lime`) has no
|
|
28
|
+
* token in a built theme and paints nothing (LC-147).
|
|
29
|
+
*/
|
|
30
|
+
export const themeRampHues = [
|
|
31
|
+
"gray",
|
|
32
|
+
"blue",
|
|
33
|
+
"red",
|
|
34
|
+
"yellow",
|
|
35
|
+
"green",
|
|
36
|
+
"orange",
|
|
37
|
+
"pink",
|
|
38
|
+
"purple",
|
|
39
|
+
"teal",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type ThemeRampHue = (typeof themeRampHues)[number];
|
|
43
|
+
|
|
44
|
+
export type ThemeRampStep = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
|
45
|
+
|
|
46
|
+
const ramps: Record<ThemeRampHue, { light: Record<string, string>; dark: Record<string, string> }> =
|
|
47
|
+
{
|
|
48
|
+
gray: { light: gray, dark: grayDark },
|
|
49
|
+
blue: { light: blue, dark: blueDark },
|
|
50
|
+
red: { light: red, dark: redDark },
|
|
51
|
+
yellow: { light: yellow, dark: yellowDark },
|
|
52
|
+
green: { light: green, dark: greenDark },
|
|
53
|
+
orange: { light: orange, dark: orangeDark },
|
|
54
|
+
pink: { light: pink, dark: pinkDark },
|
|
55
|
+
purple: { light: purple, dark: purpleDark },
|
|
56
|
+
teal: { light: teal, dark: tealDark },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function isThemeRampHue(value: unknown): value is ThemeRampHue {
|
|
60
|
+
return typeof value === "string" && (themeRampHues as readonly string[]).includes(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A ramp step's stock colour, for a surface no theme runtime reaches (a
|
|
65
|
+
* content script on a third-party page). Inside the provider, paint the
|
|
66
|
+
* `$<hue><step>` token instead: the built theme may lift a step to its
|
|
67
|
+
* contrast floor, and only the token carries that.
|
|
68
|
+
*/
|
|
69
|
+
export function themeRampColor(
|
|
70
|
+
hue: ThemeRampHue,
|
|
71
|
+
step: ThemeRampStep,
|
|
72
|
+
scheme: "light" | "dark" = "light",
|
|
73
|
+
): string {
|
|
74
|
+
return ramps[hue][scheme][`${hue}${step}`];
|
|
75
|
+
}
|