@fibery/ui-kit 2.1.0 → 2.1.1

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +6 -3
  3. package/src/@types/react-color.d.ts +36 -0
  4. package/src/antd/{Tabs.js → Tabs.tsx} +5 -2
  5. package/src/color-filter.test.ts +24 -65
  6. package/src/color-filter.ts +62 -93
  7. package/src/color-picker/{ColorPickerOrLoader.js → ColorPickerOrLoader.tsx} +1 -1
  8. package/src/color-picker/{index.js → index.tsx} +32 -6
  9. package/src/color-utils.ts +3 -9
  10. package/src/design-system/colors-css.ts +16 -0
  11. package/src/design-system/colors.ts +23 -45
  12. package/src/design-system/def.ts +17 -0
  13. package/src/design-system/theme.ts +6 -2
  14. package/src/design-system/types.ts +102 -0
  15. package/src/design-system/vars.test.ts +9 -2
  16. package/src/design-system/vars.ts +4 -2
  17. package/src/design-system.ts +9 -1
  18. package/src/emoji-picker/emoji-picker-content-with-color.tsx +3 -3
  19. package/src/heat.ts +8 -0
  20. package/src/highlight-colors.test.ts +68 -0
  21. package/src/highlight-colors.ts +91 -0
  22. package/src/link-input/components/{AntTextAreaWithCustomReadState.js → AntTextAreaWithCustomReadState.tsx} +19 -8
  23. package/src/link-input/index.tsx +94 -0
  24. package/src/link-input/{utils.js → utils.ts} +1 -6
  25. package/src/number-input/decimal.ts +50 -0
  26. package/src/number-input/utils.ts +2 -2
  27. package/src/palette-generator.test.ts +2 -1
  28. package/src/palette-generator.ts +8 -26
  29. package/src/palettes/_.ts +2 -2
  30. package/src/palettes/common.ts +31 -0
  31. package/src/palettes/diff-colors.ts +29 -12
  32. package/src/palettes/inspect.defs.colors.neutral-arch.test.ts +72 -36
  33. package/src/palettes/inspect.defs.colors.neutral-user.test.ts +72 -36
  34. package/src/palettes/inspect.defs.colors.warm-arch.test.ts +226 -190
  35. package/src/palettes/inspect.defs.colors.warm-user.test.ts +226 -190
  36. package/src/palettes/neutral-arch.ts +3 -3
  37. package/src/palettes/neutral-user.ts +3 -3
  38. package/src/palettes/neutral.ts +4 -2
  39. package/src/palettes/warm-arch.ts +3 -3
  40. package/src/palettes/warm-user.ts +3 -3
  41. package/src/palettes/warm.ts +4 -3
  42. package/src/pretty-size.ts +5 -2
  43. package/src/root-theme-provider.test.tsx +1 -1
  44. package/src/scale-generator.ts +1 -32
  45. package/src/select/components/menu-list-virtua.tsx +127 -0
  46. package/src/select/index.tsx +1 -1
  47. package/src/select/reflection.ts +22 -0
  48. package/src/select/select.tsx +1 -0
  49. package/src/static-palettes.ts +2 -1
  50. package/src/thematic-color-picker.tsx +8 -1
  51. package/src/thematic-constants.tsx +2 -0
  52. package/src/thematic-controls.tsx +9 -1
  53. package/src/thematic-cvd.tsx +73 -0
  54. package/src/thematic-highlights.tsx +49 -0
  55. package/src/thematic-scales.tsx +8 -6
  56. package/src/thematic-state.ts +37 -16
  57. package/src/thematic.tsx +298 -305
  58. package/src/theme-provider.tsx +9 -3
  59. package/src/theme-styles.ts +2 -1
  60. package/src/link-input/index.js +0 -89
  61. package/src/number-input/decimal.js +0 -61
@@ -1,10 +1,13 @@
1
1
  /* eslint-disable max-lines */
2
- import {ThemePalette} from "../palette-generator";
3
- import {darkenChroma, hslFrom, produceColor, serializeHexVariadic, setAlpha, setLuminanceChroma} from "../color-utils";
2
+ import {darkenChroma, hslFrom, produceColor, setAlpha, setLuminanceChroma} from "../color-utils";
4
3
  import {ThemeMode} from "../theme-settings";
5
4
  import {alpha, opacity, transparent} from "./alpha";
6
5
  import {getDarkenColor, makeAdaptiveBgFg} from "./colors-js";
7
6
  import {cssFromFilter, makeColorFilter} from "../color-filter";
7
+ import {highlightColors} from "../highlight-colors";
8
+ import type {Def, Defs, ThemePalette} from "./types";
9
+ import {mapDef} from "./def";
10
+ import mapValues from "lodash/mapValues";
8
11
 
9
12
  export const cardTypeColors = [
10
13
  "#4A4A4A",
@@ -57,9 +60,6 @@ const both =
57
60
  (map: (_: string) => string) =>
58
61
  ([light, dark]: Def): Def => [map(light), map(dark)];
59
62
 
60
- type Def = [light: string, dark: string];
61
- type Defs = Record<string, Def>;
62
-
63
63
  export function defsFromPalette({
64
64
  key,
65
65
  white,
@@ -76,20 +76,20 @@ export function defsFromPalette({
76
76
  tealDark,
77
77
  yellow,
78
78
  yellowDark,
79
- deneutralize,
79
+ deneutralize: deneutralizeFilter,
80
+ highlight,
80
81
  }: ThemePalette) {
81
- const shift = makeColorFilter(deneutralize);
82
- const colorPickerCssFilter = cssFromFilter(deneutralize) ?? "none";
82
+ const [deneutralizeL, deneutralizeD] = mapDef(deneutralizeFilter, makeColorFilter);
83
+ const deneutralize = (def: Def) => mapDef(def, deneutralizeL, deneutralizeD);
83
84
 
84
85
  // beg(TODO pull into ThemePalette)
85
86
  const blackish = produceColor(black, (x) => {
86
87
  const hsl = hslFrom(x);
87
88
  return {...hsl, l: (hsl.l ?? 0) + 0.05};
88
89
  });
89
- const purple9 = shift("rgb(157, 99, 212)");
90
- const purpleDark9 = shift("rgb(131, 73, 182)");
91
- const blue9 = shift("rgba(56, 105, 204, 1)");
92
- const blueDark9 = shift("rgba(151, 177, 237, 1)");
90
+
91
+ const [purple9, purpleDark9] = deneutralize(["rgb(157, 99, 212)", "rgb(131, 73, 182)"]);
92
+ const [blue9, blueDark9] = deneutralize(["rgba(56, 105, 204, 1)", "rgba(151, 177, 237, 1)"]);
93
93
  // end(TODO pull into ThemePalette)
94
94
 
95
95
  // whiteA = surface direction (white on light / black on dark) — blends INTO bg
@@ -413,9 +413,9 @@ export function defsFromPalette({
413
413
  const bgActionsMenu = raised.level1;
414
414
  const bgActionsMenuItemHover = surface.level4_5;
415
415
 
416
- const iconFromFg = (fg: string) => ({
417
- fg: same(fg),
418
- bg: same(alpha(fg, 0.2)),
416
+ const iconFromFg = (fg: string): {fg: Def; bg: Def} => ({
417
+ fg: deneutralize(same(fg)),
418
+ bg: deneutralize(same(alpha(fg, 0.2))),
419
419
  });
420
420
 
421
421
  const swatches = {
@@ -426,9 +426,9 @@ export function defsFromPalette({
426
426
  yellow: cardTypeColors[14],
427
427
  };
428
428
 
429
- const iconMySpace = iconFromFg(shift(swatches.grayLight, serializeHexVariadic));
430
- const iconPrivate = iconFromFg(shift(swatches.green, serializeHexVariadic));
431
- const iconFavorites = iconFromFg(shift(swatches.yellow, serializeHexVariadic));
429
+ const iconMySpace = iconFromFg(swatches.grayLight);
430
+ const iconPrivate = iconFromFg(swatches.green);
431
+ const iconFavorites = iconFromFg(swatches.yellow);
432
432
  const defaultCardTypeColorUppercaseHex = same(
433
433
  (key.includes("arch") ? swatches.yellow : key.includes("neutral") ? swatches.indigo : swatches.teal).toUpperCase()
434
434
  );
@@ -436,8 +436,11 @@ export function defsFromPalette({
436
436
  const iconBadge = iconFromFg(base[10]);
437
437
 
438
438
  const colors = {
439
- mode: ["light", "dark"],
440
439
  ...shadows,
440
+ ...mapValues(highlightColors(highlight, surface.level0), deneutralize),
441
+ colorPickerCssFilter: mapDef(deneutralizeFilter, cssFromFilter),
442
+
443
+ mode: ["light", "dark"],
441
444
 
442
445
  // Accent background tints (light → strong)
443
446
  colorAccentBgSubtle: same(alpha(accent[9], 0.1)),
@@ -893,31 +896,6 @@ export function defsFromPalette({
893
896
  colorMermaidPrimaryBorderColor: fg.muted,
894
897
  colorMermaidLineColor: [base[10], baseDark[9]],
895
898
 
896
- // Text highlight backgrounds (shifted for warm palette)
897
- highlightBgYellow: [shift("rgba(250, 240, 185, 1)"), shift("rgba(88, 72, 18, 1)")],
898
- highlightBgGreen: [shift("rgba(208, 242, 187, 1)"), shift("rgba(50, 88, 51, 1)")],
899
- highlightBgAquamarine: [shift("rgba(195, 247, 193, 1)"), shift("rgba(15, 75, 52, 1)")],
900
- highlightBgAqua: [shift("rgba(198, 242, 235, 1)"), shift("rgba(45, 87, 83, 1)")],
901
- highlightBgBlue: [shift("rgba(208, 235, 250, 1)"), shift("rgba(45, 54, 90, 1)")],
902
- highlightBgPurple: [shift("rgba(242, 228, 250, 1)"), shift("rgba(82, 50, 82, 1)")],
903
- highlightBgPink: [shift("rgba(250, 228, 240, 1)"), shift("rgba(108, 50, 85, 1)")],
904
- highlightBgRed: [shift("rgba(250, 224, 221, 1)"), shift("rgba(95, 44, 49, 1)")],
905
- highlightBgGrey: [shift("rgba(222, 227, 233, 1)"), shift("rgba(62, 63, 78, 1)")],
906
-
907
- // Text highlight backgrounds (shifted for warm palette)
908
- highlightFgYellow: [shift("rgba(195, 155, 45, 1)"), shift("rgba(200, 168, 85, 1)")],
909
- highlightFgGreen: [shift("rgba(80, 165, 65, 1)"), shift("rgba(88, 172, 78, 1)")],
910
- highlightFgAquamarine: [shift("rgba(40, 165, 115, 1)"), shift("rgba(52, 172, 128, 1)")],
911
- highlightFgAqua: [shift("rgba(35, 155, 150, 1)"), shift("rgba(52, 168, 158, 1)")],
912
- highlightFgBlue: [shift("rgba(55, 125, 205, 1)"), shift("rgba(72, 135, 200, 1)")],
913
- highlightFgPurple: [shift("rgba(150, 85, 205, 1)"), shift("rgba(155, 90, 200, 1)")],
914
- highlightFgPink: [shift("rgba(205, 85, 148, 1)"), shift("rgba(200, 90, 152, 1)")],
915
- highlightFgRed: [shift("rgba(205, 85, 80, 1)"), shift("rgba(200, 90, 85, 1)")],
916
- highlightFgGrey: [shift("rgba(115, 125, 142, 1)"), shift("rgba(135, 140, 160, 1)")],
917
-
918
- // Color picker CSS filter (applied as CSS filter property to warm the picker UI)
919
- colorPickerCssFilter: same(colorPickerCssFilter),
920
-
921
899
  //Whiteboard
922
900
  colorBgWhiteboard: surface.level0,
923
901
  colorGridDotWhiteboard: bgTabFocus,
@@ -1056,7 +1034,7 @@ export function defsFromPalette({
1056
1034
 
1057
1035
  const adaptive = makeAdaptiveBgFg(white, blackish);
1058
1036
  const fns = {
1059
- deneutralize: {light: shift, dark: shift},
1037
+ deneutralize: {light: deneutralizeL, dark: deneutralizeD},
1060
1038
  getEnumBackground: {light: adaptive.bg[0], dark: adaptive.bg[1]},
1061
1039
  getAppIconBackground: {light: adaptive.bg[0], dark: adaptive.bg[1], light2: adaptive.bg[1]},
1062
1040
  getIconColor: {light: adaptive.fg[0], dark: adaptive.fg[1]},
@@ -0,0 +1,17 @@
1
+ import type {ModeDef} from "./types";
2
+
3
+ type Mapper<T, U> = (_: T) => U;
4
+
5
+ export function mapDef<T, U>(pair: ModeDef<T>, map0: Mapper<T, U>, map1?: Mapper<T, U>): ModeDef<U>;
6
+ export function mapDef<T, U>(
7
+ pair: ModeDef<T> | undefined,
8
+ map0: Mapper<T | undefined, U>,
9
+ map1?: Mapper<T | undefined, U>
10
+ ): ModeDef<U>;
11
+ export function mapDef<T, U>(
12
+ pair: ModeDef<T> | undefined,
13
+ map0: Mapper<T | undefined, U>,
14
+ map1: Mapper<T | undefined, U> = map0
15
+ ): ModeDef<U> {
16
+ return [map0(pair?.[0]), map1(pair?.[1])];
17
+ }
@@ -1,10 +1,12 @@
1
1
  import {defsFromPalette, sideFromDefs, ThemeDefs} from "./colors";
2
2
  import {ThemeMode} from "../theme-settings";
3
- import {selectPalette, ThemePalette} from "../palette-generator";
3
+ import {selectPalette} from "../palette-generator";
4
4
  import memoize from "lodash/memoize";
5
5
  import {varPrefix, varsFromTheme} from "./vars";
6
6
  import {typedObjectEntries} from "@fibery/helpers/utils/object";
7
7
  import {makeSelect} from "./colors-js";
8
+ import type {ThemePalette} from "./types";
9
+ import {LRUCache} from "lru-cache";
8
10
 
9
11
  // intentionally non-memoized implementation detail for theme-styles use-case
10
12
  export function themeFromDefs(defs: ThemeDefs, mode: ThemeMode) {
@@ -26,8 +28,9 @@ function themeFromPaletteImpl(palette: ThemePalette, mode: ThemeMode) {
26
28
  }
27
29
 
28
30
  export const themeFromPalette = memoize(themeFromPaletteImpl, (palette, mode) => `${palette.key}-${mode}`);
31
+ themeFromPalette.cache = new LRUCache({max: 4});
29
32
 
30
- export const themeVars = varsFromTheme(themeFromPaletteImpl(selectPalette(), "light"));
33
+ export const themeVars = varsFromTheme(themeFromPalette(selectPalette(), "light"));
31
34
 
32
35
  export type Styles = Record<string, string>;
33
36
 
@@ -42,3 +45,4 @@ export const stylesFromTheme = memoize(
42
45
  (theme: Pick<Theme, "key" | "colors">) => stylesFromThemeImpl(theme),
43
46
  (theme) => theme.key
44
47
  );
48
+ stylesFromTheme.cache = new LRUCache({max: 4});
@@ -0,0 +1,102 @@
1
+ import type {ColorFilterSpec} from "../color-filter";
2
+
3
+ export type ModeDef<T> = [light: T, dark: T];
4
+
5
+ export type Def = ModeDef<string>; // ColorModeDef?
6
+ export type Defs = Record<string, Def>; // ColorModeDefs?
7
+
8
+ export type OneBasedScale<T> = readonly [
9
+ base00: null,
10
+ step01: T,
11
+ step02: T,
12
+ step03: T,
13
+ step04: T,
14
+ step05: T,
15
+ step06: T,
16
+ step07: T,
17
+ step08: T,
18
+ step09: T,
19
+ step10: T,
20
+ step11: T,
21
+ step12: T,
22
+ ];
23
+
24
+ export type ZeroBasedScale<T> = readonly [
25
+ step01: T,
26
+ step02: T,
27
+ step03: T,
28
+ step04: T,
29
+ step05: T,
30
+ step06: T,
31
+ step07: T,
32
+ step08: T,
33
+ step09: T,
34
+ step10: T,
35
+ step11: T,
36
+ step12: T,
37
+ ];
38
+
39
+ export type ColorScale = OneBasedScale<string>;
40
+
41
+ type BgDef = ModeDef<{Lc: number; c: number}>;
42
+ type FgDependantOnBgDef = ModeDef<{c: number; Lc: number}>;
43
+
44
+ // merge with / derive from text-editor implementation array
45
+ export type HighlightHueName = "yellow" | "green" | "aquamarine" | "aqua" | "blue" | "purple" | "pink" | "red" | "grey";
46
+ export type HighlightHueNameCap =
47
+ | "Yellow"
48
+ | "Green"
49
+ | "Aquamarine"
50
+ | "Aqua"
51
+ | "Blue"
52
+ | "Purple"
53
+ | "Pink"
54
+ | "Red"
55
+ | "Grey";
56
+
57
+ type HighlightIntensityDefs = {
58
+ bg: BgDef;
59
+ fg: FgDependantOnBgDef;
60
+ };
61
+
62
+ type HighlightIntensityOverrides = {
63
+ bg: ModeDef<Partial<{Lc: number; c: number}>>;
64
+ fg: ModeDef<Partial<{c: number; Lc: number}>>;
65
+ };
66
+
67
+ export type HighlightDefs = HighlightIntensityDefs & {
68
+ hues: Record<HighlightHueName, number>;
69
+ overrides?: Partial<Record<HighlightHueName, Partial<HighlightIntensityOverrides>>>;
70
+ };
71
+
72
+ export type ThemePaletteBase = {
73
+ highlight: HighlightDefs;
74
+ };
75
+
76
+ export type ThemePaletteCore = ThemePaletteBase & {
77
+ deneutralize?: ModeDef<ColorFilterSpec>;
78
+
79
+ white: string;
80
+ black: string;
81
+ whiteA: ColorScale;
82
+ blackA: ColorScale;
83
+
84
+ base: ColorScale;
85
+ baseDark: ColorScale;
86
+
87
+ red: ColorScale;
88
+ redDark: ColorScale;
89
+
90
+ teal: ColorScale;
91
+ tealDark: ColorScale;
92
+
93
+ yellow: ColorScale;
94
+ yellowDark: ColorScale;
95
+ };
96
+
97
+ type ThemePaletteKeyless = ThemePaletteCore & {
98
+ accent: ColorScale;
99
+ accentDark: ColorScale;
100
+ };
101
+
102
+ export type ThemePalette = ThemePaletteKeyless & {key: string};
@@ -1,7 +1,7 @@
1
1
  import {themeFromPalette} from "./theme";
2
2
  import {resolveColorArg, varsFromTheme} from "./vars";
3
3
  import {selectPalette} from "../palette-generator";
4
- import {cssFromUnsafe, getAlpha, getLuminance, isValidColor, produceColor, serializeHexVariadic} from "../color-utils";
4
+ import {cssFromUnsafe, getAlpha, getLuminance, produceColor, serializeHexVariadic} from "../color-utils";
5
5
 
6
6
  test("varsFromTheme", () => {
7
7
  const palette = selectPalette();
@@ -13,6 +13,14 @@ test("varsFromTheme", () => {
13
13
  });
14
14
 
15
15
  describe("resolveColorArg — CSS var guard", () => {
16
+ test("null from unsafe external world is identity", () => {
17
+ expect(resolveColorArg(null, "test")).toBe(null);
18
+ });
19
+
20
+ test("undefined from unsafe external world is identity", () => {
21
+ expect(resolveColorArg(undefined, "test")).toBe(undefined);
22
+ });
23
+
16
24
  it("passes through plain colors", () => {
17
25
  expect(resolveColorArg("#ff0000", "test")).toBe("#ff0000");
18
26
  expect(resolveColorArg("rgb(0,0,0)", "test")).toBe("rgb(0,0,0)");
@@ -54,7 +62,6 @@ describe("resolveColorArg — CSS var guard", () => {
54
62
  const spy = jest.spyOn(console, "warn").mockImplementation(() => {});
55
63
 
56
64
  expect(cssFromUnsafe("var(--fibery-color-e2e)")).toMatchInlineSnapshot(`"rgb(255, 84, 0)"`);
57
- expect(isValidColor("var(--fibery-color-e2e)")).toBe(true);
58
65
  expect(getLuminance("var(--fibery-color-e2e)")).toBeGreaterThan(0);
59
66
  expect(getAlpha("var(--fibery-color-e2e)")).toBe(1);
60
67
  expect(serializeHexVariadic("var(--fibery-color-e2e)")).toMatchInlineSnapshot(`"#ff5400"`);
@@ -43,8 +43,10 @@ function tryResolveFromDOM(value: string): string | undefined {
43
43
  return resolved || undefined;
44
44
  }
45
45
 
46
- export function resolveColorArg(value: string, fnName: string): string {
47
- if (!value.startsWith("var(")) {
46
+ export function resolveColorArg(value: string, fnName: string): string;
47
+ export function resolveColorArg(value: string | null | undefined, fnName: string): string | null | undefined;
48
+ export function resolveColorArg(value: string | null | undefined, fnName: string): string | null | undefined {
49
+ if (!value?.startsWith("var(")) {
48
50
  return value;
49
51
  }
50
52
 
@@ -1,7 +1,15 @@
1
+ export type * from "./design-system/types";
1
2
  export {opacity, alpha} from "./design-system/alpha";
2
3
  export {transition, durations, tooltipDelay, transitionDuration, transitions} from "./design-system/animation";
3
4
  export {cardTypeColors, type ThemeDefs} from "./design-system/colors";
4
- export {getLinearGradient, getEnumTextColor, dropCursorColor, shadows} from "./design-system/colors-css";
5
+ export {
6
+ getLinearGradient,
7
+ getEnumTextColor,
8
+ getBgSwatchBorderColor,
9
+ getBgSwatchFillColor,
10
+ dropCursorColor,
11
+ shadows,
12
+ } from "./design-system/colors-css";
5
13
  export {getDarkenColor, getTextColor, getObjectColorMemoized} from "./design-system/colors-js";
6
14
  export {dateFormat, dateTimeFormat, timeFormat} from "./design-system/date";
7
15
  export {
@@ -84,10 +84,10 @@ export const EmojiPickerContentWithColor: React.FC<EmojiPickerContentWithColorPr
84
84
  <div className={colorPickerWrapperCss}>
85
85
  <ColorPicker
86
86
  colors={colors}
87
- getSwatchHoverColor={getHoverColor}
87
+ getSwatchHoverColor={getHoverColor!}
88
88
  getColorOverrideFn={getColorOverrideFn}
89
- color={color}
90
- onChangeComplete={onColorSelect}
89
+ color={color!}
90
+ onChangeComplete={onColorSelect!}
91
91
  />
92
92
  </div>
93
93
  </CollapsibleContent>
package/src/heat.ts ADDED
@@ -0,0 +1,8 @@
1
+ import {interpolate, type Oklch} from "culori";
2
+
3
+ export function adjustHeat(color: Oklch, amount: number): Oklch {
4
+ const source = color;
5
+ const target = {...source, h: amount < 0 ? 264 : 70};
6
+
7
+ return interpolate([source, target], "oklch")(Math.abs(amount));
8
+ }
@@ -0,0 +1,68 @@
1
+ import {converter, parse} from "culori";
2
+ import {calcContrast} from "apcach";
3
+ import {bgKeyFrom, fgOnBgKeyFrom, highlightColors, highlightHueNames} from "./highlight-colors";
4
+ import {common} from "./palettes/common";
5
+ import {locateThemeBundle} from "./theme-styles";
6
+ import {oklchFrom} from "./color-utils";
7
+
8
+ const highlight = common.highlight;
9
+ const surface = locateThemeBundle(false).defs.colors.panelBg;
10
+ const generated = highlightColors(highlight, surface);
11
+
12
+ function calcLc(fg: string, bg: string) {
13
+ return Math.abs(calcContrast(fg, bg, "apca"));
14
+ }
15
+
16
+ function withBgLc(Lc: number) {
17
+ return highlightColors({...highlight, bg: highlight.bg.map((x) => ({...x, Lc})) as typeof highlight.bg}, surface);
18
+ }
19
+
20
+ describe("highlight-colors", () => {
21
+ it("all values are parseable sRGB colors", () => {
22
+ for (const [, [light, dark]] of Object.entries(generated)) {
23
+ expect(parse(light)).toBeDefined();
24
+ expect(parse(dark)).toBeDefined();
25
+ }
26
+ });
27
+
28
+ const tolerance = 1.5;
29
+ it("fg Lc contrast from bg within ±" + tolerance, () => {
30
+ for (const name of highlightHueNames) {
31
+ const bg = generated[bgKeyFrom(name)];
32
+ const fg = generated[fgOnBgKeyFrom(name)];
33
+ const targetL = highlight.overrides?.[name]?.fg?.[0]?.Lc ?? highlight.fg[0].Lc;
34
+ const targetD = highlight.overrides?.[name]?.fg?.[1]?.Lc ?? highlight.fg[1].Lc;
35
+ expect(calcLc(fg[0], bg[0])).toBeGreaterThan(targetL - tolerance);
36
+ expect(calcLc(fg[0], bg[0])).toBeLessThan(targetL + tolerance);
37
+ expect(calcLc(fg[1], bg[1])).toBeGreaterThan(targetD - tolerance);
38
+ expect(calcLc(fg[1], bg[1])).toBeLessThan(targetD + tolerance);
39
+ }
40
+ });
41
+
42
+ it("light bg colors are distinct per hue (not all white)", () => {
43
+ const lightBgs = highlightHueNames.map((n) => generated[bgKeyFrom(n)][0]);
44
+ const unique = new Set(lightBgs);
45
+ expect(unique.size).toBeGreaterThan(1);
46
+ for (const bg of lightBgs) {
47
+ expect(bg).not.toBe("#ffffff");
48
+ expect(bg).not.toBe("rgb(255, 255, 255)");
49
+ }
50
+ });
51
+
52
+ it("bridge is continuous at Lc=8 boundary", () => {
53
+ const below = withBgLc(7.9);
54
+ const above = withBgLc(8.1);
55
+ for (const name of highlightHueNames) {
56
+ const bgBelow = oklchFrom(parse(below[bgKeyFrom(name)][0])!);
57
+ const bgAbove = oklchFrom(parse(above[bgKeyFrom(name)][0])!);
58
+ expect(Math.abs((bgBelow?.l ?? 0) - (bgAbove?.l ?? 0))).toBeLessThan(0.01);
59
+ }
60
+ });
61
+
62
+ it("grey bg is near-neutral (c < 0.025)", () => {
63
+ const lightBg = converter("oklch")(parse(generated.highlightBgGrey[0])!);
64
+ const darkBg = converter("oklch")(parse(generated.highlightBgGrey[1])!);
65
+ expect(lightBg?.c).toBeLessThan(0.025);
66
+ expect(darkBg?.c).toBeLessThan(0.025);
67
+ });
68
+ });
@@ -0,0 +1,91 @@
1
+ import {apcach, crToBg} from "apcach";
2
+ import {clampChroma, formatRgb} from "culori";
3
+ import {oklchFrom} from "./color-utils";
4
+ import type {Def, HighlightDefs, HighlightHueName, HighlightHueNameCap, ModeDef} from "./design-system/types";
5
+
6
+ function pairFrom<T>(def: ModeDef<T>, mapper: (_: T, idx: number) => string) {
7
+ return def.map(mapper) as Def;
8
+ }
9
+
10
+ function merge<T>(base: ModeDef<T>, override?: ModeDef<Partial<T>>): ModeDef<T> {
11
+ return override ? (override.map((o, i) => ({...base[i], ...o})) as ModeDef<T>) : base;
12
+ }
13
+
14
+ const LC_BRIDGE = 8;
15
+
16
+ function strictOklch(L: number, c: number, hue: number) {
17
+ return formatRgb(clampChroma({mode: "oklch", l: L, c, h: hue}, "oklch", "rgb"));
18
+ }
19
+
20
+ function colorFromLc(surfaceColor: string, Lc: number, c: number, hue: number, referenceC?: number) {
21
+ const lcChroma = referenceC !== undefined && c < referenceC ? referenceC : c;
22
+ return strictOklch(colorLightnessFromLc(surfaceColor, Lc, lcChroma, hue), c, hue);
23
+ }
24
+
25
+ function colorLightnessFromLc(surfaceColor: string, Lc: number, c: number, hue: number) {
26
+ if (Lc >= LC_BRIDGE) {
27
+ return apcach(crToBg(surfaceColor, Lc), c, hue, 100, "srgb").lightness;
28
+ }
29
+ const surfaceL = oklchFrom(surfaceColor)?.l ?? 0;
30
+ if (Lc <= 0) {
31
+ return surfaceL;
32
+ }
33
+ const atBridge = apcach(crToBg(surfaceColor, LC_BRIDGE), c, hue, 100, "srgb").lightness;
34
+ return surfaceL + (atBridge - surfaceL) * (Lc / LC_BRIDGE);
35
+ }
36
+
37
+ const capitalize = (s: string) => s[0].toUpperCase() + s.slice(1);
38
+
39
+ type Colors = Record<`highlightFgOnBg${HighlightHueNameCap}`, Def> &
40
+ Record<`highlightBg${HighlightHueNameCap}`, Def> &
41
+ Record<`highlightFg${HighlightHueNameCap}`, Def>;
42
+ type Key = keyof Colors;
43
+
44
+ export function bgKeyFrom(hueName: HighlightHueName) {
45
+ return `highlightBg${capitalize(hueName)}` as Key;
46
+ }
47
+
48
+ export function fgOnBgKeyFrom(hueName: HighlightHueName) {
49
+ return `highlightFgOnBg${capitalize(hueName)}` as Key;
50
+ }
51
+
52
+ export function fgKeyFrom(hueName: HighlightHueName) {
53
+ return `highlightFg${capitalize(hueName)}` as Key;
54
+ }
55
+
56
+ export const highlightHueNames: HighlightHueName[] = [
57
+ "yellow",
58
+ "green",
59
+ "aquamarine",
60
+ "aqua",
61
+ "blue",
62
+ "purple",
63
+ "pink",
64
+ "red",
65
+ "grey",
66
+ ];
67
+
68
+ export function highlightColors({bg, fg, hues, overrides}: HighlightDefs, surface: Def) {
69
+ const defs: Partial<Colors> = {};
70
+
71
+ for (const hueName of highlightHueNames) {
72
+ const hue = hues[hueName];
73
+ const ov = overrides?.[hueName];
74
+ const mergedBg = merge(bg, ov?.bg);
75
+ const bgs = pairFrom(mergedBg, (b, i) => {
76
+ return colorFromLc(surface[i], b.Lc, b.c, hue, bg[i].c);
77
+ });
78
+ defs[bgKeyFrom(hueName)] = bgs;
79
+
80
+ const mergedFg = merge(fg, ov?.fg);
81
+ defs[fgKeyFrom(hueName)] = pairFrom(mergedFg, (fg, i) => {
82
+ return colorFromLc(surface[i], fg.Lc, fg.c, hue);
83
+ });
84
+
85
+ defs[fgOnBgKeyFrom(hueName)] = pairFrom(mergedFg, (fg, i) => {
86
+ return colorFromLc(bgs[i], fg.Lc, fg.c, hue);
87
+ });
88
+ }
89
+
90
+ return defs as Colors;
91
+ }
@@ -19,28 +19,39 @@ const inputStyle = css`
19
19
  position: relative;
20
20
  `;
21
21
 
22
- export const AntTextAreaWithCustomReadState = ({value, onBlur, onFocus, style = {}, renderReadState, ...rest}) => {
22
+ export type AntTextAreaWithCustomReadStateProps = React.ComponentPropsWithoutRef<typeof AntTextArea> & {
23
+ renderReadState: () => React.ReactNode;
24
+ };
25
+
26
+ export const AntTextAreaWithCustomReadState = ({
27
+ value,
28
+ onBlur,
29
+ onFocus,
30
+ style = {},
31
+ renderReadState,
32
+ ...rest
33
+ }: AntTextAreaWithCustomReadStateProps) => {
23
34
  const [focus, setFocus] = useState(false);
24
- const input = useRef(null);
35
+ const input = useRef<HTMLTextAreaElement>(null);
25
36
 
26
37
  const handleFocus = useCallback(
27
- (e) => {
38
+ (e: React.FocusEvent<HTMLTextAreaElement>) => {
28
39
  setFocus(true);
29
- onFocus && onFocus(e);
40
+ onFocus?.(e);
30
41
  },
31
42
  [onFocus]
32
43
  );
33
44
 
34
45
  const handleBlur = useCallback(
35
- (e) => {
46
+ (e: React.FocusEvent<HTMLTextAreaElement>) => {
36
47
  setFocus(false);
37
- onBlur && onBlur(e);
48
+ onBlur?.(e);
38
49
  },
39
50
  [onBlur]
40
51
  );
41
52
 
42
53
  const handleWrapperClick = useCallback(() => {
43
- input.current.focus();
54
+ input.current?.focus();
44
55
  }, [input]);
45
56
 
46
57
  return (
@@ -52,7 +63,7 @@ export const AntTextAreaWithCustomReadState = ({value, onBlur, onFocus, style =
52
63
  onFocus={handleFocus}
53
64
  onBlur={handleBlur}
54
65
  value={value}
55
- style={{...style, color: !focus && "transparent"}}
66
+ style={{...style, color: !focus ? "transparent" : undefined}}
56
67
  />
57
68
  {!focus && (
58
69
  <div className={wrapperStyle} onClick={handleWrapperClick}>
@@ -0,0 +1,94 @@
1
+ import {css} from "@linaria/core";
2
+ import {useCallback} from "react";
3
+ import {
4
+ AntTextAreaWithCustomReadState,
5
+ AntTextAreaWithCustomReadStateProps,
6
+ } from "./components/AntTextAreaWithCustomReadState";
7
+ import {getURL} from "./utils";
8
+
9
+ const urlLinkClassname = css`
10
+ word-break: break-word;
11
+ margin-right: 20px;
12
+ `;
13
+
14
+ type LinkProps = {value: string; children?: React.ReactNode} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
15
+
16
+ export const UrlInputLink = ({value, children, ...rest}: LinkProps) => {
17
+ const normalizedUrl = getURL(value);
18
+ if (!normalizedUrl) {
19
+ return null;
20
+ }
21
+ return (
22
+ <a
23
+ draggable={false}
24
+ target="_blank"
25
+ className={urlLinkClassname}
26
+ rel="noopener noreferrer nofollow"
27
+ href={normalizedUrl}
28
+ {...rest}
29
+ >
30
+ {children}
31
+ </a>
32
+ );
33
+ };
34
+
35
+ export const UrlInput = (props: Omit<AntTextAreaWithCustomReadStateProps, "renderReadState">) => {
36
+ const handleClick = useCallback((e: React.MouseEvent) => e.stopPropagation(), []);
37
+
38
+ return (
39
+ <AntTextAreaWithCustomReadState
40
+ {...props}
41
+ renderReadState={() => (
42
+ <UrlInputLink value={String(props.value ?? "")} onClick={handleClick}>
43
+ {String(props.value ?? "")}
44
+ </UrlInputLink>
45
+ )}
46
+ />
47
+ );
48
+ };
49
+
50
+ export const PhoneInputLink = ({value, children, ...rest}: LinkProps) => {
51
+ return (
52
+ <a draggable={false} href={`tel:${value}`} {...rest}>
53
+ {children}
54
+ </a>
55
+ );
56
+ };
57
+
58
+ export const PhoneInput = (props: Omit<AntTextAreaWithCustomReadStateProps, "renderReadState">) => {
59
+ const handleClick = useCallback((e: React.MouseEvent) => e.stopPropagation(), []);
60
+
61
+ return (
62
+ <AntTextAreaWithCustomReadState
63
+ {...props}
64
+ renderReadState={() => (
65
+ <PhoneInputLink value={String(props.value ?? "")} onClick={handleClick}>
66
+ {String(props.value ?? "")}
67
+ </PhoneInputLink>
68
+ )}
69
+ />
70
+ );
71
+ };
72
+
73
+ export const EmailInputLink = ({value, children, ...rest}: LinkProps) => {
74
+ return (
75
+ <a draggable={false} href={`mailto:${value}`} {...rest}>
76
+ {children}
77
+ </a>
78
+ );
79
+ };
80
+
81
+ export const EmailInput = (props: Omit<AntTextAreaWithCustomReadStateProps, "renderReadState">) => {
82
+ const handleClick = useCallback((e: React.MouseEvent) => e.stopPropagation(), []);
83
+
84
+ return (
85
+ <AntTextAreaWithCustomReadState
86
+ {...props}
87
+ renderReadState={() => (
88
+ <EmailInputLink value={String(props.value ?? "")} onClick={handleClick}>
89
+ {String(props.value ?? "")}
90
+ </EmailInputLink>
91
+ )}
92
+ />
93
+ );
94
+ };