@oxyhq/bloom 0.80.0 → 0.82.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/lib/commonjs/tabs/Tabs.js +153 -15
- package/lib/commonjs/tabs/Tabs.js.map +1 -1
- package/lib/commonjs/theme/color-engine/index.js +6 -0
- package/lib/commonjs/theme/color-engine/index.js.map +1 -1
- package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
- package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
- package/lib/commonjs/theme/color-policy.js +130 -20
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +62 -20
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/module/tabs/Tabs.js +153 -15
- package/lib/module/tabs/Tabs.js.map +1 -1
- package/lib/module/theme/color-engine/index.js +1 -1
- package/lib/module/theme/color-engine/index.js.map +1 -1
- package/lib/module/theme/color-engine/scheme-variants.js +28 -1
- package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
- package/lib/module/theme/color-policy.js +129 -20
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/module/theme/color-presets.js +62 -20
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/module/theme/color-scope/seed-scope.js +6 -2
- package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
- package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
- package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
- package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
- package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts +6 -0
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BloomThemeProvider.test.tsx +1 -1
- package/src/__tests__/Tabs.test.tsx +286 -10
- package/src/__tests__/theme.test.ts +4 -3
- package/src/tabs/Tabs.tsx +172 -12
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +1273 -673
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +775 -425
- package/src/theme/__tests__/policy-legibility.test.ts +55 -13
- package/src/theme/color-engine/index.ts +1 -1
- package/src/theme/color-engine/scheme-variants.ts +38 -1
- package/src/theme/color-policy.ts +147 -24
- package/src/theme/color-presets.ts +44 -17
- package/src/theme/color-scope/seed-scope.ts +6 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
export { Hct } from './hct';
|
|
11
11
|
export { TonalPalette } from './tonal-palette';
|
|
12
12
|
export { DynamicScheme } from './dynamic-scheme';
|
|
13
|
-
export { buildScheme, schemeVibrant, schemeExpressive, schemeTonalSpot, schemeNeutral } from './scheme-variants';
|
|
13
|
+
export { buildScheme, schemeVibrant, schemeExpressive, schemeTonalSpot, schemeNeutral, schemeMonochrome } from './scheme-variants';
|
|
14
14
|
export type { SchemeVariant, AccentSources } from './scheme-variants';
|
|
15
15
|
export { Roles } from './color-roles';
|
|
16
16
|
export type { RoleName } from './color-roles';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-engine/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-engine/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACnI,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAChG,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAGtF,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAErD,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEpE,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED,uCAAuC;AACvC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED,yDAAyD;AACzD,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;AAElD,MAAM,WAAW,eAAe;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wCAAwC;IACxC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,eAAe,GAAG,UAAU,CAuBpE"}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { DynamicScheme } from './dynamic-scheme';
|
|
11
11
|
import type { Hct } from './hct';
|
|
12
|
-
export type SchemeVariant = 'vivid' | 'vibrant' | 'expressive' | 'tonalSpot' | 'neutral';
|
|
12
|
+
export type SchemeVariant = 'vivid' | 'vibrant' | 'expressive' | 'tonalSpot' | 'neutral' | 'monochrome';
|
|
13
13
|
/**
|
|
14
14
|
* Optional explicit accent seeds for a scheme. When a source HCT is supplied, its
|
|
15
15
|
* palette is built from that colour itself (keeping the brand hue + chroma) instead
|
|
@@ -39,6 +39,18 @@ export declare function schemeExpressive(source: Hct, isDark: boolean, contrastL
|
|
|
39
39
|
export declare function schemeTonalSpot(source: Hct, isDark: boolean, contrastLevel: number, accents?: AccentSources): DynamicScheme;
|
|
40
40
|
/** Near-greyscale — the seed hue is present only as a whisper. */
|
|
41
41
|
export declare function schemeNeutral(source: Hct, isDark: boolean, contrastLevel: number, accents?: AccentSources): DynamicScheme;
|
|
42
|
+
/**
|
|
43
|
+
* No colour at all: every palette is greyscale, so the seed's hue is irrelevant
|
|
44
|
+
* and only its tone survives. This is the variant the `isMonochrome` role
|
|
45
|
+
* branches in `color-roles` were written for — they push `primary` to the far end
|
|
46
|
+
* of the scale (tone 0 on a light page, 100 on a dark one) rather than the
|
|
47
|
+
* mid-tone the chromatic curves would pick, which is what keeps a black-and-white
|
|
48
|
+
* theme from reading as a grey one.
|
|
49
|
+
*
|
|
50
|
+
* A pinned accent is ignored on purpose: honouring it would put one coloured
|
|
51
|
+
* family back into a scheme whose entire point is that nothing is coloured.
|
|
52
|
+
*/
|
|
53
|
+
export declare function schemeMonochrome(source: Hct, isDark: boolean, contrastLevel: number): DynamicScheme;
|
|
42
54
|
/**
|
|
43
55
|
* Build a scheme by variant name.
|
|
44
56
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scheme-variants.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-engine/scheme-variants.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAKjC,MAAM,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"scheme-variants.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-engine/scheme-variants.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAKjC,MAAM,MAAM,aAAa,GACrB,OAAO,GACP,SAAS,GACT,YAAY,GACZ,WAAW,GACX,SAAS,GACT,YAAY,CAAC;AAEjB;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,eAAe,CAAC,EAAE,GAAG,CAAC;IACtB,cAAc,CAAC,EAAE,GAAG,CAAC;CACtB;AAoBD,uEAAuE;AACvE,wBAAgB,aAAa,CAC3B,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,aAAkB,GAC1B,aAAa,CAsBf;AAED,gFAAgF;AAChF;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,aAAkB,GAC1B,aAAa,CAsBf;AAED,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,aAAkB,GAC1B,aAAa,CAsBf;AAED,yEAAyE;AACzE,wBAAgB,eAAe,CAC7B,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,aAAkB,GAC1B,aAAa,CAgBf;AAED,kEAAkE;AAClE,wBAAgB,aAAa,CAC3B,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,aAAkB,GAC1B,aAAa,CAgBf;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,MAAM,GACpB,aAAa,CAaf;AAcD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,aAAa,EACtB,MAAM,EAAE,GAAG,EACX,MAAM,EAAE,OAAO,EACf,aAAa,SAAI,EACjB,OAAO,GAAE,aAAkB,GAC1B,aAAa,CAEf"}
|
|
@@ -37,6 +37,12 @@ export interface PinnedAccents {
|
|
|
37
37
|
}
|
|
38
38
|
/** The tokens the policy owns. Everything else keeps coming from the M3 roles. */
|
|
39
39
|
export type PolicyTokens = Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a seed carries no colour to derive a theme from — the one authority for
|
|
42
|
+
* that question, so the policy and the scheme choice can never disagree about a
|
|
43
|
+
* given seed and produce a grey palette wearing chromatic surfaces.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isColourlessSeed(seedHex: string): boolean;
|
|
40
46
|
/**
|
|
41
47
|
* Build the policy-owned tokens for a seed and mode.
|
|
42
48
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"color-policy.d.ts","sourceRoot":"","sources":["../../../../src/theme/color-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAe,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAK9D,sFAAsF;AACtF,eAAO,MAAM,YAAY;;;;;CAKf,CAAC;AAEX,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,kFAAkF;AAClF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"color-policy.d.ts","sourceRoot":"","sources":["../../../../src/theme/color-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAe,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAK9D,sFAAsF;AACtF,eAAO,MAAM,YAAY;;;;;CAKf,CAAC;AAEX,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,kFAAkF;AAClF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAsHlD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEzD;AA2MD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,OAAO,EACf,KAAK,EAAE,UAAU,EACjB,MAAM,GAAE,aAAkB,GACzB,YAAY,CAqJd"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SchemeVariant } from './color-engine';
|
|
2
|
-
export type AppColorName = 'teal' | 'blue' | 'green' | '
|
|
2
|
+
export type AppColorName = 'teal' | 'blue' | 'green' | 'yellow' | 'red' | 'purple' | 'pink' | 'sky' | 'orange' | 'mint' | 'oxy' | 'faircoin' | 'pumpkin' | 'gray' | 'brown' | 'peach' | 'rose' | 'mono';
|
|
3
3
|
/**
|
|
4
4
|
* A single colour preset: a brand SEED colour plus the tonal scheme variant to
|
|
5
5
|
* derive its full role set with.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"color-presets.d.ts","sourceRoot":"","sources":["../../../../src/theme/color-presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,MAAM,GACN,OAAO,GACP,
|
|
1
|
+
{"version":3,"file":"color-presets.d.ts","sourceRoot":"","sources":["../../../../src/theme/color-presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpD,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,MAAM,GACN,OAAO,GACP,QAAQ,GACR,KAAK,GACL,QAAQ,GACR,MAAM,GACN,KAAK,GACL,QAAQ,GACR,MAAM,GACN,KAAK,GACL,UAAU,GACV,SAAS,GACT,MAAM,GACN,OAAO,GACP,OAAO,GACP,MAAM,GACN,MAAM,CAAC;AAEX;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,YAAY,CAAC;IACnB,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,8DAA8D;IAC9D,OAAO,EAAE,aAAa,CAAC;IACvB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAElD,eAAO,MAAM,eAAe,EAAE,SAAS,YAAY,EAAkK,CAAC;AAEtN,wEAAwE;AACxE,eAAO,MAAM,mBAAmB,EAAE,SAAS,YAAY,EAAwB,CAAC;AAEhF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAmBzD,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAE3D;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,YAAY,EAAE,cAAc,CA+BlE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed-scope.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-scope/seed-scope.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAI1F;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA4C9E;AAED,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,uDAAuD;IACvD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"seed-scope.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-scope/seed-scope.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAI1F;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA4C9E;AAED,mEAAmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB;;;;OAIG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,uDAAuD;IACvD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmCpF"}
|
package/package.json
CHANGED
|
@@ -98,7 +98,7 @@ describe('BloomThemeProvider', () => {
|
|
|
98
98
|
|
|
99
99
|
it('renders all color presets without crashing', () => {
|
|
100
100
|
const presets: AppColorName[] = [
|
|
101
|
-
'teal', 'blue', 'green', '
|
|
101
|
+
'teal', 'blue', 'green', 'red',
|
|
102
102
|
'purple', 'pink', 'sky', 'orange', 'mint',
|
|
103
103
|
];
|
|
104
104
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { ReactTestInstance } from 'react-test-renderer';
|
|
3
|
-
import { fireEvent, render } from '@testing-library/react-native';
|
|
3
|
+
import { act, fireEvent, render } from '@testing-library/react-native';
|
|
4
4
|
|
|
5
5
|
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
6
|
import { Tabs, TabsTrigger } from '../tabs';
|
|
7
|
+
import type { TabsDragController } from '../tabs/Tabs';
|
|
7
8
|
|
|
8
9
|
function renderWithTheme(ui: React.ReactElement) {
|
|
9
10
|
return render(
|
|
@@ -34,6 +35,18 @@ function triggerFor(node: ReactTestInstance): ReactTestInstance {
|
|
|
34
35
|
return current;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The selected state as it actually reaches a platform.
|
|
40
|
+
*
|
|
41
|
+
* `aria-selected` rather than `accessibilityState.selected` because
|
|
42
|
+
* react-native-web's `createDOMProps` reads only the former (the latter appears
|
|
43
|
+
* nowhere in it), while React Native's `Pressable` folds `aria-selected` back
|
|
44
|
+
* into `accessibilityState` — so this is the ONE spelling both platforms honour.
|
|
45
|
+
*/
|
|
46
|
+
function selectedState(node: ReactTestInstance): unknown {
|
|
47
|
+
return node.props['aria-selected'];
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
function Bar({
|
|
38
51
|
value,
|
|
39
52
|
onValueChange = () => {},
|
|
@@ -98,14 +111,36 @@ describe('Tabs', () => {
|
|
|
98
111
|
}
|
|
99
112
|
});
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A tab strip that does not say WHICH tab is current is unusable with a
|
|
116
|
+
* screen reader, and the failure is invisible to everyone else: the underline
|
|
117
|
+
* is drawn, so the strip looks correct while announcing nothing.
|
|
118
|
+
*
|
|
119
|
+
* The assertion is on `aria-selected` specifically. Setting only
|
|
120
|
+
* `accessibilityState={{selected}}` — which reads like the React Native
|
|
121
|
+
* answer and is what this component shipped — produces no `aria-selected` on
|
|
122
|
+
* web at all, because react-native-web stopped consulting
|
|
123
|
+
* `accessibilityState` and now maps `aria-*` props directly.
|
|
124
|
+
*/
|
|
101
125
|
it('marks the active trigger as selected for accessibility', () => {
|
|
102
126
|
const { getByText } = renderWithTheme(<Bar value="b" />);
|
|
103
|
-
expect(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
expect(selectedState(triggerFor(getByText('First')))).toBe(false);
|
|
128
|
+
expect(selectedState(triggerFor(getByText('Second')))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('leaves the disabled state to the disabled prop, which both platforms map', () => {
|
|
132
|
+
// Neither Pressable needs telling twice: React Native's folds a non-null
|
|
133
|
+
// `disabled` into `accessibilityState`, react-native-web's emits
|
|
134
|
+
// `aria-disabled` from it. A second copy on the trigger could only ever
|
|
135
|
+
// disagree with this one.
|
|
136
|
+
const { getByText } = renderWithTheme(
|
|
137
|
+
<Tabs value="a" onValueChange={() => {}} testID="tabs">
|
|
138
|
+
<TabsTrigger value="a" label="First" />
|
|
139
|
+
<TabsTrigger value="b" label="Second" disabled />
|
|
140
|
+
</Tabs>,
|
|
141
|
+
);
|
|
142
|
+
expect(triggerFor(getByText('Second')).props.disabled).toBe(true);
|
|
143
|
+
expect(triggerFor(getByText('First')).props.disabled).toBe(false);
|
|
109
144
|
});
|
|
110
145
|
|
|
111
146
|
/**
|
|
@@ -134,7 +169,7 @@ describe('Tabs', () => {
|
|
|
134
169
|
);
|
|
135
170
|
|
|
136
171
|
expect(getByTestId('tabs-indicator')).toBeTruthy();
|
|
137
|
-
expect(triggerFor(getByText('Overview'))
|
|
172
|
+
expect(selectedState(triggerFor(getByText('Overview')))).toBe(true);
|
|
138
173
|
fireEvent.press(getByText('Activity'));
|
|
139
174
|
expect(onValueChange).toHaveBeenCalledWith('activity');
|
|
140
175
|
});
|
|
@@ -148,8 +183,8 @@ describe('Tabs', () => {
|
|
|
148
183
|
<TabsTrigger value="b" label="Second" isFocused />
|
|
149
184
|
</Tabs>,
|
|
150
185
|
);
|
|
151
|
-
expect(triggerFor(getByText('First'))
|
|
152
|
-
expect(triggerFor(getByText('Second'))
|
|
186
|
+
expect(selectedState(triggerFor(getByText('First')))).toBe(false);
|
|
187
|
+
expect(selectedState(triggerFor(getByText('Second')))).toBe(true);
|
|
153
188
|
});
|
|
154
189
|
|
|
155
190
|
it('does NOT report a selection on press, because the caller navigates', () => {
|
|
@@ -294,4 +329,245 @@ describe('Tabs', () => {
|
|
|
294
329
|
).toThrow(/TabsTrigger must be used within a Tabs/);
|
|
295
330
|
spy.mockRestore();
|
|
296
331
|
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A trigger set that CHANGES after the strip has already been laid out.
|
|
335
|
+
*
|
|
336
|
+
* This is the case `onLayout` alone cannot serve, and it is worth being
|
|
337
|
+
* precise about why, because the component looks correct without it. On
|
|
338
|
+
* react-native-web `onLayout` is one shared `ResizeObserver`
|
|
339
|
+
* (`react-native-web/dist/modules/useElementLayout`), so it fires for a SIZE
|
|
340
|
+
* change and never for a position-only move. Insert a tab and every trigger
|
|
341
|
+
* after it slides right without one of them re-reporting; the underline stays
|
|
342
|
+
* on the stale numbers, one tab to the left of the tab that is actually
|
|
343
|
+
* showing. Measured on production `mention.earth`, where a profile's lane tab
|
|
344
|
+
* arrives from a separate query after first paint: `/@nate/boosts` underlined
|
|
345
|
+
* "Likes".
|
|
346
|
+
*
|
|
347
|
+
* Every test below therefore reports layout for the changed triggers ONLY —
|
|
348
|
+
* modelling exactly what the platform does — and asserts the underline anyway.
|
|
349
|
+
* Reporting layout for all of them would hide the bug completely.
|
|
350
|
+
*/
|
|
351
|
+
describe('a trigger set that changes after first layout', () => {
|
|
352
|
+
interface Box {
|
|
353
|
+
x: number;
|
|
354
|
+
width: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Which tab an element belongs to, or `undefined` if it is not a trigger.
|
|
359
|
+
*
|
|
360
|
+
* The element the strip measures is a trigger's animated wrapper, which is
|
|
361
|
+
* anonymous; the tab it stands for is named by the Pressable inside it.
|
|
362
|
+
*/
|
|
363
|
+
function tabLabelOf(element: unknown): string | undefined {
|
|
364
|
+
// `createNodeMock` is handed a plain `{type, props}` pair rather than a
|
|
365
|
+
// real element — `React.isValidElement` is false for it — so the outer
|
|
366
|
+
// hop is narrowed by hand. The CHILD is a genuine element.
|
|
367
|
+
if (typeof element !== 'object' || element === null || !('props' in element)) {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
const props = element.props;
|
|
371
|
+
if (typeof props !== 'object' || props === null || !('children' in props)) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
const child = props.children;
|
|
375
|
+
if (
|
|
376
|
+
!React.isValidElement<{ accessibilityRole?: string; accessibilityLabel?: string }>(
|
|
377
|
+
child,
|
|
378
|
+
)
|
|
379
|
+
) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (child.props.accessibilityRole !== 'tab') return undefined;
|
|
383
|
+
return child.props.accessibilityLabel;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Stand in for a host view.
|
|
388
|
+
*
|
|
389
|
+
* react-test-renderer hands `null` to every host ref unless a
|
|
390
|
+
* `createNodeMock` is supplied, so without this a trigger has no node to
|
|
391
|
+
* measure and the strip is back to trusting `onLayout` — which is the
|
|
392
|
+
* behaviour under test, not a background detail. Geometry is read from a
|
|
393
|
+
* live table so the test can move the tabs between renders, exactly as a
|
|
394
|
+
* reflow would.
|
|
395
|
+
*/
|
|
396
|
+
function nodeMockFor(geometry: Map<string, Box>) {
|
|
397
|
+
return (element: React.ReactElement): unknown => {
|
|
398
|
+
const label = tabLabelOf(element);
|
|
399
|
+
if (label === undefined) return {};
|
|
400
|
+
return {
|
|
401
|
+
measure: (
|
|
402
|
+
report: (x: number, y: number, width: number, height: number) => void,
|
|
403
|
+
) => {
|
|
404
|
+
const box = geometry.get(label);
|
|
405
|
+
if (box) report(box.x, 0, box.width, 40);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function mountStrip(tabs: string[], focused: string, geometry: Map<string, Box>) {
|
|
412
|
+
const dragRef = React.createRef<TabsDragController>();
|
|
413
|
+
const tree = (next: { tabs: string[]; focused: string }) => (
|
|
414
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
415
|
+
<Tabs testID="tabs" ref={dragRef} hasSelection>
|
|
416
|
+
{next.tabs.map((tab) => (
|
|
417
|
+
<TabsTrigger
|
|
418
|
+
key={tab}
|
|
419
|
+
value={tab}
|
|
420
|
+
label={tab}
|
|
421
|
+
isFocused={tab === next.focused}
|
|
422
|
+
/>
|
|
423
|
+
))}
|
|
424
|
+
</Tabs>
|
|
425
|
+
</BloomThemeProvider>
|
|
426
|
+
);
|
|
427
|
+
const utils = render(tree({ tabs, focused }), {
|
|
428
|
+
createNodeMock: nodeMockFor(geometry),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/** Fire the `onLayout` a trigger's own node would fire. */
|
|
432
|
+
const reportLayout = (label: string) => {
|
|
433
|
+
const box = geometry.get(label);
|
|
434
|
+
if (!box) throw new Error(`no geometry for "${label}"`);
|
|
435
|
+
let node: ReactTestInstance | null = triggerFor(utils.getByText(label));
|
|
436
|
+
while (node && node.props?.onLayout === undefined) node = node.parent;
|
|
437
|
+
if (!node) throw new Error(`no onLayout ancestor for "${label}"`);
|
|
438
|
+
fireEvent(node, 'layout', { nativeEvent: { layout: box } });
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Let the measurement pass run and re-render so the mapper is evaluated
|
|
443
|
+
* against the shared values it wrote — the reanimated mock computes
|
|
444
|
+
* `useAnimatedStyle` at render time, so nothing written from a callback is
|
|
445
|
+
* visible in the rendered style until something renders again.
|
|
446
|
+
*/
|
|
447
|
+
const settle = async (next?: { tabs: string[]; focused: string }) => {
|
|
448
|
+
await act(async () => {
|
|
449
|
+
utils.rerender(tree(next ?? { tabs, focused }));
|
|
450
|
+
});
|
|
451
|
+
await act(async () => {
|
|
452
|
+
utils.rerender(tree(next ?? { tabs, focused }));
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const indicator = () => {
|
|
457
|
+
const style = flattenStyle(utils.getByTestId('tabs-indicator').props.style);
|
|
458
|
+
const transform = style.transform as { translateX?: number }[] | undefined;
|
|
459
|
+
return {
|
|
460
|
+
x: transform?.[0]?.translateX,
|
|
461
|
+
width: style.width,
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
return { ...utils, dragRef, reportLayout, settle, indicator };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it('follows the active tab when one is INSERTED before it', async () => {
|
|
469
|
+
const geometry = new Map<string, Box>([
|
|
470
|
+
['posts', { x: 0, width: 80 }],
|
|
471
|
+
['likes', { x: 80, width: 80 }],
|
|
472
|
+
['boosts', { x: 160, width: 80 }],
|
|
473
|
+
]);
|
|
474
|
+
const bar = mountStrip(['posts', 'likes', 'boosts'], 'boosts', geometry);
|
|
475
|
+
for (const tab of ['posts', 'likes', 'boosts']) bar.reportLayout(tab);
|
|
476
|
+
await bar.settle();
|
|
477
|
+
expect(bar.indicator()).toEqual({ x: 160, width: 80 });
|
|
478
|
+
|
|
479
|
+
// The lane tab lands, and everything after it slides right by its width.
|
|
480
|
+
geometry.set('lane', { x: 0, width: 100 });
|
|
481
|
+
geometry.set('posts', { x: 100, width: 80 });
|
|
482
|
+
geometry.set('likes', { x: 180, width: 80 });
|
|
483
|
+
geometry.set('boosts', { x: 260, width: 80 });
|
|
484
|
+
await bar.settle({ tabs: ['lane', 'posts', 'likes', 'boosts'], focused: 'boosts' });
|
|
485
|
+
// Only the NEW node is newly observed, so only it reports.
|
|
486
|
+
bar.reportLayout('lane');
|
|
487
|
+
await bar.settle({ tabs: ['lane', 'posts', 'likes', 'boosts'], focused: 'boosts' });
|
|
488
|
+
|
|
489
|
+
expect(bar.indicator()).toEqual({ x: 260, width: 80 });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('follows the active tab when one is REMOVED before it', async () => {
|
|
493
|
+
const geometry = new Map<string, Box>([
|
|
494
|
+
['lane', { x: 0, width: 100 }],
|
|
495
|
+
['posts', { x: 100, width: 80 }],
|
|
496
|
+
['boosts', { x: 180, width: 80 }],
|
|
497
|
+
]);
|
|
498
|
+
const bar = mountStrip(['lane', 'posts', 'boosts'], 'boosts', geometry);
|
|
499
|
+
for (const tab of ['lane', 'posts', 'boosts']) bar.reportLayout(tab);
|
|
500
|
+
await bar.settle();
|
|
501
|
+
expect(bar.indicator()).toEqual({ x: 180, width: 80 });
|
|
502
|
+
|
|
503
|
+
geometry.set('posts', { x: 0, width: 80 });
|
|
504
|
+
geometry.set('boosts', { x: 80, width: 80 });
|
|
505
|
+
// Nothing resized and the removed node is gone, so NOTHING reports here.
|
|
506
|
+
await bar.settle({ tabs: ['posts', 'boosts'], focused: 'boosts' });
|
|
507
|
+
|
|
508
|
+
expect(bar.indicator()).toEqual({ x: 80, width: 80 });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('follows the active tab when the set is REORDERED', async () => {
|
|
512
|
+
const geometry = new Map<string, Box>([
|
|
513
|
+
['posts', { x: 0, width: 80 }],
|
|
514
|
+
['likes', { x: 80, width: 80 }],
|
|
515
|
+
['lane', { x: 160, width: 100 }],
|
|
516
|
+
]);
|
|
517
|
+
const bar = mountStrip(['posts', 'likes', 'lane'], 'lane', geometry);
|
|
518
|
+
for (const tab of ['posts', 'likes', 'lane']) bar.reportLayout(tab);
|
|
519
|
+
await bar.settle();
|
|
520
|
+
expect(bar.indicator()).toEqual({ x: 160, width: 100 });
|
|
521
|
+
|
|
522
|
+
// Same tabs, same sizes, new order — the case no size-change notification
|
|
523
|
+
// can ever report, on either platform.
|
|
524
|
+
geometry.set('lane', { x: 0, width: 100 });
|
|
525
|
+
geometry.set('posts', { x: 100, width: 80 });
|
|
526
|
+
geometry.set('likes', { x: 180, width: 80 });
|
|
527
|
+
await bar.settle({ tabs: ['lane', 'posts', 'likes'], focused: 'lane' });
|
|
528
|
+
|
|
529
|
+
expect(bar.indicator()).toEqual({ x: 0, width: 100 });
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('forgets a removed tab, so a swipe cannot commit to one that is gone', async () => {
|
|
533
|
+
const geometry = new Map<string, Box>([
|
|
534
|
+
['posts', { x: 0, width: 80 }],
|
|
535
|
+
['likes', { x: 80, width: 80 }],
|
|
536
|
+
['lane', { x: 160, width: 100 }],
|
|
537
|
+
]);
|
|
538
|
+
const bar = mountStrip(['posts', 'likes', 'lane'], 'likes', geometry);
|
|
539
|
+
for (const tab of ['posts', 'likes', 'lane']) bar.reportLayout(tab);
|
|
540
|
+
await bar.settle();
|
|
541
|
+
// Dragging left reveals the tab to the RIGHT, so this commits `lane`.
|
|
542
|
+
expect(bar.dragRef.current?.drag(-1000)).toBe('lane');
|
|
543
|
+
|
|
544
|
+
await bar.settle({ tabs: ['posts', 'likes'], focused: 'likes' });
|
|
545
|
+
|
|
546
|
+
// `likes` is now last. A phantom `lane` left behind in the geometry would
|
|
547
|
+
// still be found here, and releasing would navigate to a tab that is not
|
|
548
|
+
// on screen.
|
|
549
|
+
expect(bar.dragRef.current?.drag(-1000)).toBeNull();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('ignores a zero measurement, which means "not laid out" and not "empty"', async () => {
|
|
553
|
+
const geometry = new Map<string, Box>([
|
|
554
|
+
['posts', { x: 0, width: 80 }],
|
|
555
|
+
['likes', { x: 80, width: 80 }],
|
|
556
|
+
]);
|
|
557
|
+
const bar = mountStrip(['posts', 'likes'], 'likes', geometry);
|
|
558
|
+
for (const tab of ['posts', 'likes']) bar.reportLayout(tab);
|
|
559
|
+
await bar.settle();
|
|
560
|
+
expect(bar.indicator()).toEqual({ x: 80, width: 80 });
|
|
561
|
+
|
|
562
|
+
// What a `display: none` ancestor reports on web. A trigger always
|
|
563
|
+
// carries horizontal padding, so it can never genuinely be zero-wide —
|
|
564
|
+
// taking this at face value would collapse the underline and lose the
|
|
565
|
+
// real geometry it has to come back to.
|
|
566
|
+
geometry.set('posts', { x: 0, width: 0 });
|
|
567
|
+
geometry.set('likes', { x: 0, width: 0 });
|
|
568
|
+
await bar.settle();
|
|
569
|
+
|
|
570
|
+
expect(bar.indicator()).toEqual({ x: 80, width: 80 });
|
|
571
|
+
});
|
|
572
|
+
});
|
|
297
573
|
});
|
|
@@ -78,7 +78,7 @@ describe('Theme system', () => {
|
|
|
78
78
|
describe('hexToAppColorName', () => {
|
|
79
79
|
it('maps known hex values to correct color names', () => {
|
|
80
80
|
expect(hexToAppColorName('#005c67')).toBe('teal');
|
|
81
|
-
expect(hexToAppColorName('#
|
|
81
|
+
expect(hexToAppColorName('#0085fe')).toBe('blue');
|
|
82
82
|
expect(hexToAppColorName('#10b981')).toBe('green');
|
|
83
83
|
expect(hexToAppColorName('#ef4444')).toBe('red');
|
|
84
84
|
expect(hexToAppColorName('#c46ede')).toBe('oxy');
|
|
@@ -86,13 +86,14 @@ describe('Theme system', () => {
|
|
|
86
86
|
|
|
87
87
|
it('is case-insensitive', () => {
|
|
88
88
|
expect(hexToAppColorName('#005C67')).toBe('teal');
|
|
89
|
-
expect(hexToAppColorName('#
|
|
89
|
+
expect(hexToAppColorName('#0085FE')).toBe('blue');
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
it('defaults to teal for unknown hex values', () => {
|
|
93
|
-
expect(hexToAppColorName('#000000')).toBe('teal');
|
|
94
93
|
expect(hexToAppColorName('#ffffff')).toBe('teal');
|
|
95
94
|
expect(hexToAppColorName('#123456')).toBe('teal');
|
|
95
|
+
// '#000000' is no longer unknown — it is the `mono` preset's seed.
|
|
96
|
+
expect(hexToAppColorName('#000000')).toBe('mono');
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|