@fibery/ui-kit 1.40.3 → 1.41.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/eslint.config.mjs +16 -0
  3. package/package.json +11 -13
  4. package/src/a11y-color.test.ts +162 -0
  5. package/src/a11y-color.ts +13 -20
  6. package/src/actions-menu/actions-menu-item.tsx +9 -12
  7. package/src/actions-menu/context-actions-menu.tsx +8 -3
  8. package/src/ai/model.tsx +0 -1
  9. package/src/ai/temperature.tsx +0 -1
  10. package/src/animated-height-container.tsx +3 -3
  11. package/src/antd/ant-modal.tsx +10 -5
  12. package/src/antd/index.tsx +3 -0
  13. package/src/antd/input.tsx +0 -1
  14. package/src/antd/styles.ts +15 -15
  15. package/src/app-icon-with-fallback.tsx +6 -6
  16. package/src/app-icon-wrapper.tsx +2 -3
  17. package/src/app-icon.tsx +4 -4
  18. package/src/avatar.tsx +3 -3
  19. package/src/button/base-button.tsx +0 -1
  20. package/src/button/button.tsx +1 -0
  21. package/src/button/make-button-colors.ts +5 -13
  22. package/src/checkbox.tsx +2 -2
  23. package/src/collapsible-section.tsx +11 -1
  24. package/src/color-picker/ColorPickerOrLoader.js +2 -2
  25. package/src/color-utils.test.ts +307 -0
  26. package/src/color-utils.ts +215 -0
  27. package/src/command-menu/index.tsx +1 -0
  28. package/src/comment.tsx +3 -2
  29. package/src/context-menu/index.tsx +12 -7
  30. package/src/date-picker/contexts.ts +2 -2
  31. package/src/date-picker/date-range-picker.tsx +3 -4
  32. package/src/date-picker/single-date-picker.tsx +147 -119
  33. package/src/date-picker/types.ts +4 -3
  34. package/src/day-select/iso-week-day-select.tsx +2 -2
  35. package/src/day-select/week-day-select.tsx +2 -2
  36. package/src/delayed.tsx +0 -1
  37. package/src/design-system/alpha.test.ts +59 -0
  38. package/src/design-system/alpha.ts +41 -0
  39. package/src/design-system/animation.ts +10 -0
  40. package/src/design-system/colors-css.test.ts +39 -0
  41. package/src/design-system/colors-css.ts +25 -0
  42. package/src/design-system/colors-js.test.ts +232 -0
  43. package/src/design-system/colors-js.ts +107 -0
  44. package/src/design-system/colors.test.ts +74 -0
  45. package/src/design-system/colors.ts +759 -0
  46. package/src/design-system/date.ts +3 -0
  47. package/src/design-system/layout.ts +106 -0
  48. package/src/design-system/theme.test.ts +94 -0
  49. package/src/design-system/theme.ts +93 -0
  50. package/src/design-system/typography.ts +179 -0
  51. package/src/design-system/vars.test.ts +1679 -0
  52. package/src/design-system/vars.ts +52 -0
  53. package/src/design-system.test.ts +45 -22
  54. package/src/design-system.ts +25 -1339
  55. package/src/dropdown-menu/index.tsx +21 -16
  56. package/src/emoji-picker/app-icon-picker.tsx +5 -5
  57. package/src/emoji-picker/emoji-picker-content-with-color.tsx +4 -4
  58. package/src/emoji-picker/icon-emoji-picker.tsx +3 -3
  59. package/src/favorites-icon.tsx +1 -1
  60. package/src/fibermoji-placeholder.tsx +3 -3
  61. package/src/field-container.tsx +11 -2
  62. package/src/file-item/file-icon.tsx +169 -0
  63. package/src/file-item/file-menu-items.tsx +68 -0
  64. package/src/file-item/file-preview-actions.tsx +38 -0
  65. package/src/file-item/file-title.tsx +48 -0
  66. package/src/file-item/types.ts +27 -0
  67. package/src/file-item/use-register-in-image-gallery.tsx +70 -0
  68. package/src/file-item-2.tsx +32 -348
  69. package/src/file-item.tsx +6 -3
  70. package/src/hue-shift.test.ts +91 -0
  71. package/src/icons/ast/ChatBubble.ts +8 -0
  72. package/src/icons/ast/ChatFloat.ts +8 -0
  73. package/src/icons/ast/ChatSidebar.ts +8 -0
  74. package/src/icons/ast/FileCounter.ts +8 -0
  75. package/src/icons/ast/FileMultiple.ts +8 -0
  76. package/src/icons/ast/FileOther.ts +8 -0
  77. package/src/icons/ast/NoBorder.ts +8 -0
  78. package/src/icons/ast/NoFill.ts +8 -0
  79. package/src/icons/ast/ValueEdit.ts +8 -0
  80. package/src/icons/ast/index.tsx +9 -0
  81. package/src/icons/react/ChatBubble.tsx +13 -0
  82. package/src/icons/react/ChatFloat.tsx +13 -0
  83. package/src/icons/react/ChatSidebar.tsx +13 -0
  84. package/src/icons/react/FileCounter.tsx +13 -0
  85. package/src/icons/react/FileMultiple.tsx +13 -0
  86. package/src/icons/react/FileOther.tsx +13 -0
  87. package/src/icons/react/NoBorder.tsx +13 -0
  88. package/src/icons/react/NoFill.tsx +13 -0
  89. package/src/icons/react/ValueEdit.tsx +13 -0
  90. package/src/icons/react/index.tsx +9 -0
  91. package/src/icons/svg/chat-bubble.svg +4 -0
  92. package/src/icons/svg/chat-float.svg +4 -0
  93. package/src/icons/svg/chat-sidebar.svg +4 -0
  94. package/src/icons/svg/file-counter.svg +3 -0
  95. package/src/icons/svg/file-multiple.svg +3 -0
  96. package/src/icons/svg/file-other.svg +3 -0
  97. package/src/icons/svg/no-border.svg +17 -0
  98. package/src/icons/svg/no-fill.svg +4 -0
  99. package/src/icons/svg/value-edit.svg +3 -0
  100. package/src/images-gallery/images-gallery.tsx +8 -6
  101. package/src/images-gallery/slide-buttons.tsx +4 -11
  102. package/src/is-iOS.ts +0 -1
  103. package/src/is-in-popup.ts +2 -1
  104. package/src/lists/actions-menu-row-surface.tsx +7 -7
  105. package/src/loading-sausage.tsx +2 -2
  106. package/src/media-query-utils.ts +1 -2
  107. package/src/mobile-keyboard-aware-popup.tsx +6 -5
  108. package/src/modal-menu/modal-menu-content.tsx +2 -2
  109. package/src/number-input/decimal.js +9 -7
  110. package/src/number-input/{number-inline-input-with-autosize.tsx → number-input-inline-with-autosize.tsx} +20 -41
  111. package/src/number-input/number-input-inline.tsx +118 -0
  112. package/src/number-input/{index.test.js → number-input.test.tsx} +38 -43
  113. package/src/number-input/number-input.tsx +63 -121
  114. package/src/number-input/types.ts +19 -0
  115. package/src/number-input/utils.ts +61 -0
  116. package/src/online-users.tsx +2 -2
  117. package/src/palette-generator.test.ts +309 -0
  118. package/src/palette-generator.ts +160 -0
  119. package/src/palettes/_.ts +72 -0
  120. package/src/palettes/slate-arch.colors-snapshot.test.ts +1689 -0
  121. package/src/palettes/slate-arch.ts +188 -0
  122. package/src/palettes/slate-user.colors-snapshot.test.ts +1689 -0
  123. package/src/palettes/slate-user.ts +187 -0
  124. package/src/palettes/warm-arch.colors-snapshot.test.ts +1689 -0
  125. package/src/palettes/warm-arch.ts +222 -0
  126. package/src/palettes/warm-user.colors-snapshot.test.ts +1689 -0
  127. package/src/palettes/warm-user.ts +222 -0
  128. package/src/platform.ts +0 -3
  129. package/src/popover/get-element-ref.ts +28 -0
  130. package/src/popover/index.tsx +236 -339
  131. package/src/popover/{mobile-popup-context.tsx → mobile-popover-context.tsx} +7 -7
  132. package/src/popover/mobile-popover.tsx +169 -0
  133. package/src/popover/modifiers.tsx +2 -2
  134. package/src/popover/popup-stack-context.tsx +8 -9
  135. package/src/progress.tsx +2 -2
  136. package/src/reactions/reaction-button.tsx +12 -6
  137. package/src/root-theme-provider.test.tsx +411 -0
  138. package/src/scale-generator.ts +356 -0
  139. package/src/select/components/menu-list-virtualized.tsx +12 -25
  140. package/src/select/components/menu.tsx +12 -2
  141. package/src/select/index.tsx +6 -5
  142. package/src/select/select.tsx +38 -39
  143. package/src/select/styles.ts +0 -1
  144. package/src/select/util.ts +1 -1
  145. package/src/static-palettes.ts +356 -0
  146. package/src/thematic-color-picker.tsx +266 -0
  147. package/src/thematic-constants.tsx +27 -0
  148. package/src/thematic-controls.tsx +144 -0
  149. package/src/thematic-scales.tsx +122 -0
  150. package/src/thematic-state.ts +370 -0
  151. package/src/thematic.tsx +386 -0
  152. package/src/theme-provider.test.tsx +820 -0
  153. package/src/theme-provider.tsx +158 -92
  154. package/src/theme-settings.ts +67 -12
  155. package/src/theme-styles.ts +58 -6
  156. package/src/toast/toast-action.tsx +1 -1
  157. package/src/toast/toast.tsx +1 -2
  158. package/src/toggle-on-off.tsx +2 -2
  159. package/src/toggle.tsx +7 -8
  160. package/src/tooltip.tsx +14 -10
  161. package/src/type-badge.tsx +7 -14
  162. package/src/unit/styles.ts +2 -25
  163. package/src/unit/unit-with-tooltip.tsx +3 -2
  164. package/src/use-is-phone.tsx +7 -2
  165. package/src/use-long-press.tsx +2 -2
  166. package/src/use-on-screen-keyboard-data.tsx +2 -2
  167. package/src/with-data.tsx +4 -3
  168. package/src/workflow-progress-icon.tsx +2 -2
  169. package/.eslintignore +0 -3
  170. package/.eslintrc +0 -14
  171. package/src/__mocks__/createInlineTheme.js +0 -3
  172. package/src/create-inline-theme.ts +0 -66
  173. package/src/number-input/index.js +0 -191
  174. package/src/palette.ts +0 -237
@@ -1,9 +1,9 @@
1
1
  import {css} from "@linaria/core";
2
- import _ from "lodash";
3
- import {FC, Suspense, lazy} from "react";
2
+ import fromPairs from "lodash/fromPairs";
3
+ import {FC, lazy, Suspense} from "react";
4
4
  import {a11yColor} from "./a11y-color";
5
5
  import {AppIconWrapper} from "./app-icon-wrapper";
6
- import {getAppIconBackgroundColor, getOpacities, getThemeValue, iconSize, layout} from "./design-system";
6
+ import {getOpacities, iconSize, layout} from "./design-system";
7
7
  import {Icon} from "./icons/Icon";
8
8
  import {useTheme} from "./theme-provider";
9
9
  import {useIsPhone} from "./use-is-phone";
@@ -89,7 +89,7 @@ type UglyExternalIconProps = Omit<UglyExternalIconImplProps, "appIconsLookup">;
89
89
 
90
90
  const UglyExternalIcon = lazy(async () => {
91
91
  const appIcons = await import("./appIcons.json");
92
- const appIconsLookup = _.fromPairs(
92
+ const appIconsLookup = fromPairs(
93
93
  appIcons.default.flatMap((appIcon) => appIcon.short_names.map((shortName) => [shortName, appIcon]))
94
94
  );
95
95
 
@@ -128,13 +128,13 @@ export const AppIcon: FC<AppIconProps> = ({icon, color, isSelected, ...rest}) =>
128
128
  const isPhone = useIsPhone();
129
129
  const defaultIconSize = isPhone ? layout.mobileSidebarIconSize : layout.newMenuIconSize;
130
130
  const {iconSize = defaultIconSize, containerSize = defaultIconSize} = rest;
131
- const iconColor = getThemeValue(theme.mode, {
131
+ const iconColor = theme.select({
132
132
  dark: a11yColor(getOpacities(color).opacity95, theme.menuBg, 5),
133
133
  light: a11yColor(getOpacities(color).opacity95, theme.whiteColor, 3),
134
134
  light2: a11yColor(getOpacities(color).opacity95, theme.menuBg, 5),
135
135
  });
136
136
 
137
- const bgColor = getAppIconBackgroundColor(theme.mode, color);
137
+ const bgColor = theme.fns.getAppIconBackground(color);
138
138
 
139
139
  return (
140
140
  <AppIconWrapper color={bgColor as string} borderless={isSelected} containerSize={containerSize}>
@@ -1,7 +1,7 @@
1
1
  import {css} from "@linaria/core";
2
2
  import cn from "classnames";
3
3
  import {ReactNode} from "react";
4
- import {border, getThemeValue, layout} from "./design-system";
4
+ import {border, layout} from "./design-system";
5
5
  import {useTheme} from "./theme-provider";
6
6
  import {useIsPhone} from "./use-is-phone";
7
7
 
@@ -34,8 +34,7 @@ export const AppIconWrapper = ({
34
34
  style={{backgroundColor: borderless ? "transparent" : color, width: containerSize, height: containerSize}}
35
35
  className={cn(
36
36
  appIconContainerStyle,
37
- !borderless &&
38
- getThemeValue(theme.mode, {light2: appIconGradientStyle, dark: appIconGradientStyle, light: null})
37
+ !borderless && theme.select({light2: appIconGradientStyle, dark: appIconGradientStyle, light: null})
39
38
  )}
40
39
  >
41
40
  {children}
package/src/app-icon.tsx CHANGED
@@ -3,7 +3,7 @@ import cx from "classnames";
3
3
  import {FC} from "react";
4
4
  import {a11yColor} from "./a11y-color";
5
5
  import {AppIconWithFallback} from "./app-icon-with-fallback";
6
- import {border, getAppIconBackgroundColor, getOpacities, getThemeValue} from "./design-system";
6
+ import {border, getOpacities} from "./design-system";
7
7
  import {useTheme} from "./theme-provider";
8
8
 
9
9
  interface AppIconProps {
@@ -16,15 +16,15 @@ interface AppIconProps {
16
16
 
17
17
  export const AppIcon: FC<AppIconProps> = ({color, icon, containerSize = 54, iconSize = 36, className}) => {
18
18
  const theme = useTheme();
19
- const iconColor = getThemeValue(theme.mode, {
19
+ const iconColor = theme.select({
20
20
  dark: a11yColor(getOpacities(color).opacity95, theme.menuBg, 5),
21
21
  light: a11yColor(getOpacities(color).opacity95, theme.whiteColor, 3),
22
22
  light2: a11yColor(getOpacities(color).opacity95, theme.menuBg, 5),
23
23
  });
24
24
 
25
- const bgColor = getAppIconBackgroundColor(theme.mode, color);
25
+ const bgColor = theme.fns.getAppIconBackground(color);
26
26
 
27
- const appIconGradientStyle = getThemeValue(theme.mode, {
27
+ const appIconGradientStyle = theme.select({
28
28
  dark: "linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0))",
29
29
  light: "none",
30
30
  light2: "linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0))",
package/src/avatar.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import {css} from "@linaria/core";
2
- import _ from "lodash";
2
+ import memoize from "lodash/memoize";
3
3
  import {CSSProperties, FC, memo, useCallback, useMemo, useState} from "react";
4
4
  import {getDarkenColor, getObjectColorMemoized, textStyles, themeVars} from "./design-system";
5
5
  import {FibermojiPlaceholder} from "./fibermoji-placeholder";
@@ -7,7 +7,7 @@ import {getShiftStyle} from "./icons/get-shift-style";
7
7
  import {useFeatures} from "./features";
8
8
  import {abbreviateName} from "./abbreviate-name";
9
9
 
10
- export const getContainerStyle = _.memoize((containerSize) => {
10
+ export const getContainerStyle = memoize((containerSize) => {
11
11
  return {
12
12
  width: containerSize,
13
13
  height: containerSize,
@@ -22,7 +22,7 @@ const statusIconPositionByIndex = [
22
22
  {top: 0, left: 0},
23
23
  ];
24
24
 
25
- const getStatusIconStyle = _.memoize(
25
+ const getStatusIconStyle = memoize(
26
26
  (size, index): CSSProperties => {
27
27
  const iconSize = Math.ceil(size / 2);
28
28
  const position = statusIconPositionByIndex[index];
@@ -139,7 +139,6 @@ export const BaseButton = forwardRef<HTMLButtonElement, BaseButtonProps>(
139
139
  data-variant={variant}
140
140
  className={cx(reset, baseButton, className)}
141
141
  ref={ref}
142
- // eslint-disable-next-line react/button-has-type
143
142
  type={type}
144
143
  {...buttonProps}
145
144
  >
@@ -27,6 +27,7 @@ const button = css`
27
27
  justify-content: center;
28
28
  font-weight: ${fontWeight.medium};
29
29
  white-space: nowrap;
30
+ touch-action: manipulation;
30
31
  `;
31
32
 
32
33
  const shortcutContainer = css`
@@ -3,18 +3,10 @@
3
3
  // so it's easier to find buttons with custom colors
4
4
 
5
5
  import {CSSProperties} from "@linaria/core";
6
- import {
7
- getDarkenColor,
8
- getEnumBackgroundColor,
9
- getIconColor,
10
- getOpacities,
11
- getThemeValue,
12
- themeVars,
13
- } from "../design-system";
6
+ import {getDarkenColor, getOpacities, ThemeColors, themeVars} from "../design-system";
14
7
  import {ButtonVariant} from "./base-button";
15
- import {ThemeMode} from "../theme-settings";
16
8
 
17
- export const makeButtonColors = (color: string, variant: ButtonVariant, mode: ThemeMode = "light"): CSSProperties => {
9
+ export const makeButtonColors = (color: string, variant: ButtonVariant, theme: ThemeColors): CSSProperties => {
18
10
  switch (variant) {
19
11
  case "solid":
20
12
  return {
@@ -43,12 +35,12 @@ export const makeButtonColors = (color: string, variant: ButtonVariant, mode: Th
43
35
  };
44
36
  default:
45
37
  case "soft": {
46
- const bgColor = getEnumBackgroundColor(mode, color) as string;
47
- const textColor = getIconColor(mode, color) as string;
38
+ const bgColor = theme.fns.getEnumBackground(color) as string;
39
+ const textColor = theme.fns.getIconColor(color) as string;
48
40
 
49
41
  return {
50
42
  "--fibery-button-color": bgColor,
51
- "--fibery-button-hover-color": getThemeValue(mode, {
43
+ "--fibery-button-hover-color": theme.select({
52
44
  light: getOpacities(color).opacity30,
53
45
  dark: getOpacities(color).opacity45,
54
46
  }),
package/src/checkbox.tsx CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  useEffect,
13
13
  useRef,
14
14
  } from "react";
15
- import {colors, getDarkenColor, getOpacities, layout, space, textStyles, ThemeColors, themeVars} from "./design-system";
15
+ import {getDarkenColor, getOpacities, layout, space, textStyles, ThemeColors, themeVars} from "./design-system";
16
16
  import {smallHtmlStylesClass} from "./html-styles";
17
17
  import {mobileRootSelector} from "./mobile-styles";
18
18
 
@@ -127,7 +127,7 @@ const checkmarkStyle = css`
127
127
  opacity: 0;
128
128
  transition: transform 0.2s, opacity 0.2s;
129
129
  pointer-events: none;
130
- color: ${colors.inversedTextColor};
130
+ color: ${themeVars.whiteColor};
131
131
  width: ${layout.checkboxSize}px;
132
132
  height: ${layout.checkboxSize}px;
133
133
 
@@ -1,7 +1,7 @@
1
1
  import {css, cx} from "@linaria/core";
2
2
  import ArrowBottom from "./icons/react/ArrowBottom";
3
3
  import ArrowTop from "./icons/react/ArrowTop";
4
- import {ReactNode, useCallback} from "react";
4
+ import {ReactNode, useCallback, useEffect, useRef} from "react";
5
5
  import {useControllableState} from "@fibery/react/src/use-controllable-state";
6
6
  import {space} from "./design-system";
7
7
 
@@ -48,6 +48,16 @@ export const CollapsibleSection = ({
48
48
 
49
49
  const onToggle = useCallback(() => setIsCollapsed(!isCollapsed), [isCollapsed, setIsCollapsed]);
50
50
 
51
+ // Ugly one-time auto-expand logic, in case of collapsedDefault is changed from false to true shortly after render
52
+ // (i.e. when something is loaded asynchronously)
53
+ const expandedOnce = useRef(false);
54
+ useEffect(() => {
55
+ if (isCollapsed && !collapsedDefault && !expandedOnce.current) {
56
+ expandedOnce.current = true;
57
+ setIsCollapsed(false);
58
+ }
59
+ }, [isCollapsed, collapsedDefault, setIsCollapsed]);
60
+
51
61
  const collapsibleHeader = (
52
62
  <div
53
63
  tabIndex={0}
@@ -1,10 +1,10 @@
1
- import {colors, border, space} from "../design-system";
2
1
  import {css} from "@linaria/core";
3
2
  import {lazy, Suspense} from "react";
3
+ import {border, space, themeVars} from "../design-system";
4
4
 
5
5
  const colorPickerLoaderStyle = css`
6
6
  ${{
7
- backgroundColor: colors.inversedTextColor,
7
+ backgroundColor: themeVars.inversedTextColor,
8
8
  borderRadius: border.radius4,
9
9
  boxShadow: "rgba(0, 0, 0, 0.15) 0px 0px 0px 1px, rgba(0, 0, 0, 0.15) 0px 8px 16px",
10
10
  padding: space.s12,
@@ -0,0 +1,307 @@
1
+ import {
2
+ fromHSL,
3
+ fromOKLCH,
4
+ getAlpha,
5
+ getColorDistance,
6
+ getContrastRatio,
7
+ getHsl,
8
+ getLuminance,
9
+ isValidColor,
10
+ toCss,
11
+ toOKLCH,
12
+ } from "./color-utils";
13
+
14
+ describe("color-utils", () => {
15
+ describe("css", () => {
16
+ it("normalizes hex to rgb", () => {
17
+ expect(toCss("#ff0000")).toMatchInlineSnapshot(`"rgb(255,0,0)"`);
18
+ });
19
+
20
+ it("normalizes named colors to rgb", () => {
21
+ expect(toCss("red")).toMatchInlineSnapshot(`"rgb(255,0,0)"`);
22
+ expect(toCss("blue")).toMatchInlineSnapshot(`"rgb(0,0,255)"`);
23
+ });
24
+
25
+ it("preserves alpha in rgba", () => {
26
+ expect(toCss("rgba(255, 0, 0, 0.5)")).toMatchInlineSnapshot(`"rgba(255,0,0,0.5)"`);
27
+ });
28
+
29
+ it("converts hsl to rgb", () => {
30
+ expect(toCss("hsl(0, 100%, 50%)")).toMatchInlineSnapshot(`"rgb(255,0,0)"`);
31
+ });
32
+
33
+ it("returns fallback for invalid color", () => {
34
+ expect(toCss("invalid")).toMatchInlineSnapshot(`"rgb(73,120,212)"`);
35
+ });
36
+ });
37
+
38
+ describe("isValidColor", () => {
39
+ it("returns true for valid hex colors", () => {
40
+ expect(isValidColor("#ff0000")).toBe(true);
41
+ expect(isValidColor("#f00")).toBe(true);
42
+ expect(isValidColor("#FF0000")).toBe(true);
43
+ expect(isValidColor("#ff000080")).toBe(true);
44
+ });
45
+
46
+ it("returns true for valid rgb/rgba colors", () => {
47
+ expect(isValidColor("rgb(255, 0, 0)")).toBe(true);
48
+ expect(isValidColor("rgba(255, 0, 0, 0.5)")).toBe(true);
49
+ expect(isValidColor("rgb(255,0,0)")).toBe(true);
50
+ });
51
+
52
+ it("returns true for valid hsl/hsla colors", () => {
53
+ expect(isValidColor("hsl(0, 100%, 50%)")).toBe(true);
54
+ expect(isValidColor("hsla(0, 100%, 50%, 0.5)")).toBe(true);
55
+ });
56
+
57
+ it("returns true for named colors", () => {
58
+ expect(isValidColor("red")).toBe(true);
59
+ expect(isValidColor("blue")).toBe(true);
60
+ expect(isValidColor("white")).toBe(true);
61
+ expect(isValidColor("black")).toBe(true);
62
+ expect(isValidColor("cornflowerblue")).toBe(true);
63
+ });
64
+
65
+ it("returns true for transparent", () => {
66
+ expect(isValidColor("transparent")).toBe(true);
67
+ });
68
+
69
+ it("returns false for invalid colors", () => {
70
+ expect(isValidColor("invalid")).toBe(false);
71
+ expect(isValidColor("not-a-color")).toBe(false);
72
+ expect(isValidColor("")).toBe(false);
73
+ expect(isValidColor("#gggggg")).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("getAlpha", () => {
78
+ it("returns 1 for solid colors", () => {
79
+ expect(getAlpha("#ff0000")).toBe(1);
80
+ });
81
+
82
+ it("returns 1 for solid rgb colors", () => {
83
+ expect(getAlpha("rgb(255, 0, 0)")).toBe(1);
84
+ });
85
+
86
+ it("returns correct alpha for rgba", () => {
87
+ expect(getAlpha("rgba(255, 0, 0, 0.5)")).toBeCloseTo(0.5, 2);
88
+ expect(getAlpha("rgba(255, 0, 0, 0)")).toBe(0);
89
+ expect(getAlpha("rgba(255, 0, 0, 1)")).toBe(1);
90
+ });
91
+
92
+ it("returns correct alpha for hsla colors", () => {
93
+ expect(getAlpha("hsla(0, 100%, 50%, 0.7)")).toBeCloseTo(0.7, 2);
94
+ });
95
+ });
96
+
97
+ describe("toCss", () => {
98
+ it("converts to rgb for opaque colors", () => {
99
+ const result = toCss("#ff0000");
100
+ expect(result).toMatchInlineSnapshot(`"rgb(255,0,0)"`);
101
+ });
102
+
103
+ it("converts to rgba for colors with alpha", () => {
104
+ const result = toCss("rgba(255, 0, 0, 0.5)");
105
+ expect(result).toMatchInlineSnapshot(`"rgba(255,0,0,0.5)"`);
106
+ });
107
+ });
108
+
109
+ describe("getContrastRatio", () => {
110
+ it("returns 21 for black on white", () => {
111
+ expect(getContrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 0);
112
+ });
113
+
114
+ it("returns 21 for white on black", () => {
115
+ expect(getContrastRatio("#ffffff", "#000000")).toBeCloseTo(21, 0);
116
+ });
117
+
118
+ it("returns 1 for same colors", () => {
119
+ expect(getContrastRatio("#808080", "#808080")).toBeCloseTo(1, 0);
120
+ expect(getContrastRatio("#ff0000", "#ff0000")).toBeCloseTo(1, 0);
121
+ });
122
+
123
+ it("returns symmetric results", () => {
124
+ const ratio1 = getContrastRatio("#ff0000", "#00ff00");
125
+ const ratio2 = getContrastRatio("#00ff00", "#ff0000");
126
+ expect(ratio1).toBeCloseTo(ratio2, 5);
127
+ });
128
+
129
+ it("returns expected values for known pairs", () => {
130
+ // Red on white should have moderate contrast
131
+ const redWhite = getContrastRatio("#ff0000", "#ffffff");
132
+ expect(redWhite).toBeGreaterThan(3);
133
+ expect(redWhite).toBeLessThan(5);
134
+ });
135
+ });
136
+
137
+ describe("getColorDistance", () => {
138
+ it("returns 0 for same colors", () => {
139
+ expect(getColorDistance("#ff0000", "#ff0000")).toBe(0);
140
+ expect(getColorDistance("red", "red")).toBe(0);
141
+ });
142
+
143
+ it("returns large value for very different colors", () => {
144
+ // Red and green are perceptually very different
145
+ const distance = getColorDistance("#ff0000", "#00ff00");
146
+ expect(distance).toBeGreaterThan(50);
147
+ });
148
+
149
+ it("returns small value for similar colors", () => {
150
+ const distance = getColorDistance("#ff0000", "#ff1100");
151
+ expect(distance).toBeLessThan(10);
152
+ });
153
+
154
+ it("is symmetric", () => {
155
+ const d1 = getColorDistance("#ff0000", "#0000ff");
156
+ const d2 = getColorDistance("#0000ff", "#ff0000");
157
+ expect(d1).toBeCloseTo(d2, 5);
158
+ });
159
+ });
160
+
161
+ describe("getLuminance", () => {
162
+ it("returns 1 for white", () => {
163
+ expect(getLuminance("#ffffff")).toBeCloseTo(1, 2);
164
+ expect(getLuminance("white")).toBeCloseTo(1, 2);
165
+ });
166
+
167
+ it("returns 0 for black", () => {
168
+ expect(getLuminance("#000000")).toBeCloseTo(0, 2);
169
+ expect(getLuminance("black")).toBeCloseTo(0, 2);
170
+ });
171
+
172
+ it("returns ~0.5 for middle gray", () => {
173
+ // Note: luminance is perceptual, not linear
174
+ // Middle gray (#808080) has luminance ~0.216
175
+ expect(getLuminance("#808080")).toBeGreaterThan(0.1);
176
+ expect(getLuminance("#808080")).toBeLessThan(0.3);
177
+ });
178
+
179
+ it("returns values between 0 and 1", () => {
180
+ expect(getLuminance("#ff0000")).toBeGreaterThanOrEqual(0);
181
+ expect(getLuminance("#ff0000")).toBeLessThanOrEqual(1);
182
+ expect(getLuminance("#00ff00")).toBeGreaterThanOrEqual(0);
183
+ expect(getLuminance("#00ff00")).toBeLessThanOrEqual(1);
184
+ });
185
+ });
186
+
187
+ describe("hsl", () => {
188
+ it("creates red at hue 0", () => {
189
+ expect(fromHSL(0, 1, 0.5)).toMatchInlineSnapshot(`"rgb(255,0,0)"`);
190
+ });
191
+
192
+ it("creates green at hue 120", () => {
193
+ const color = fromHSL(120, 1, 0.5);
194
+ expect(color).toMatchInlineSnapshot(`"rgb(0,255,0)"`);
195
+ });
196
+
197
+ it("creates blue at hue 240", () => {
198
+ const color = fromHSL(240, 1, 0.5);
199
+ expect(color).toMatchInlineSnapshot(`"rgb(0,0,255)"`);
200
+ });
201
+
202
+ it("creates white at lightness 1", () => {
203
+ const color = fromHSL(0, 1, 1);
204
+ expect(color).toMatchInlineSnapshot(`"rgb(255,255,255)"`);
205
+ });
206
+
207
+ it("creates black at lightness 0", () => {
208
+ const color = fromHSL(0, 1, 0);
209
+ expect(color).toMatchInlineSnapshot(`"rgb(0,0,0)"`);
210
+ });
211
+
212
+ it("creates gray at saturation 0", () => {
213
+ const color = fromHSL(0, 0, 0.5);
214
+ expect(color).toMatchInlineSnapshot(`"rgb(128,128,128)"`);
215
+ });
216
+ });
217
+
218
+ describe("fromLCH (OKLCH)", () => {
219
+ it("creates color from OKLCH values", () => {
220
+ expect(fromOKLCH(0.592, 0.16, 264)).toMatchInlineSnapshot(`"rgb(76,120,220)"`);
221
+ });
222
+
223
+ it("creates white at L=1 with zero chroma", () => {
224
+ expect(fromOKLCH(1, 0, 0)).toMatchInlineSnapshot(`"rgb(255,255,255)"`);
225
+ });
226
+
227
+ it("creates black at L=0", () => {
228
+ expect(fromOKLCH(0, 0, 0)).toMatchInlineSnapshot(`"rgb(0,0,0)"`);
229
+ });
230
+
231
+ it("creates gray at mid-lightness with zero chroma", () => {
232
+ expect(fromOKLCH(0.5, 0, 0)).toMatchInlineSnapshot(`"rgb(99,99,99)"`);
233
+ });
234
+
235
+ it("creates saturated red at hue ~29", () => {
236
+ expect(fromOKLCH(0.628, 0.258, 29)).toMatchInlineSnapshot(`"rgb(255,0,3)"`);
237
+ });
238
+
239
+ it("creates saturated blue at hue ~264", () => {
240
+ expect(fromOKLCH(0.452, 0.313, 264)).toMatchInlineSnapshot(`"rgb(0,1,255)"`);
241
+ });
242
+
243
+ it("higher chroma produces more saturated colors", () => {
244
+ expect(fromOKLCH(0.6, 0.05, 264)).toMatchInlineSnapshot(`"rgb(113,128,159)"`);
245
+ expect(fromOKLCH(0.6, 0.2, 264)).toMatchInlineSnapshot(`"rgb(64,118,245)"`);
246
+ });
247
+
248
+ it("gamut maps out-of-range colors", () => {
249
+ expect(fromOKLCH(0.7, 0.4, 150)).toMatchInlineSnapshot(`"rgb(0,194,72)"`);
250
+ expect(fromOKLCH(0.8, 0.4, 150)).toMatchInlineSnapshot(`"rgb(0,231,90)"`);
251
+ expect(fromOKLCH(0.9, 0.4, 150)).toMatchInlineSnapshot(`"rgb(65,255,135)"`);
252
+ expect(fromOKLCH(1, 0.4, 150)).toMatchInlineSnapshot(`"rgb(255,255,255)"`);
253
+ });
254
+ });
255
+
256
+ describe("getHsl", () => {
257
+ it("extracts HSL from red", () => {
258
+ const [h, s, l] = getHsl("#ff0000");
259
+ expect(h).toBeCloseTo(0, 0);
260
+ expect(s).toBeCloseTo(1, 2);
261
+ expect(l).toBeCloseTo(0.5, 2);
262
+ });
263
+
264
+ it("extracts HSL from green", () => {
265
+ const [h, s, l] = getHsl("#00ff00");
266
+ expect(h).toBeCloseTo(120, 0);
267
+ expect(s).toBeCloseTo(1, 2);
268
+ expect(l).toBeCloseTo(0.5, 2);
269
+ });
270
+
271
+ it("extracts HSL from blue", () => {
272
+ const [h, s, l] = getHsl("#0000ff");
273
+ expect(h).toBeCloseTo(240, 0);
274
+ expect(s).toBeCloseTo(1, 2);
275
+ expect(l).toBeCloseTo(0.5, 2);
276
+ });
277
+
278
+ it("extracts HSL from white", () => {
279
+ const [, , l] = getHsl("#ffffff");
280
+ expect(l).toBeCloseTo(1, 2);
281
+ });
282
+
283
+ it("extracts HSL from black", () => {
284
+ const [, l] = getHsl("#000000");
285
+ expect(l).toBeCloseTo(0, 2);
286
+ });
287
+
288
+ it("returns 0 for undefined hue (grayscale)", () => {
289
+ const [h] = getHsl("#808080");
290
+ expect(h).toBe(0); // NaN converted to 0
291
+ });
292
+ });
293
+
294
+ describe("produceColor", () => {
295
+ it("outputs oklch format when OKLCH space is specified", () => {
296
+ expect(toOKLCH("#ff0000")).toMatchInlineSnapshot(`"oklch(62.796% 0.25768 29.234)"`);
297
+ expect(toOKLCH("rgb(0, 128, 255)")).toMatchInlineSnapshot(`"oklch(61.517% 0.21082 256.1)"`);
298
+ expect(toOKLCH("oklch(61.517% 0.21082 256.1)")).toMatchInlineSnapshot(`"oklch(61.517% 0.21082 256.1)"`);
299
+ });
300
+
301
+ it("parses display-p3 colors correctly (P3 space removed for bundle size)", () => {
302
+ expect(toOKLCH("color(display-p3 0.992 0.992 0.978)")).toMatchInlineSnapshot(`"oklch(99.285% 0.00516 106.49)"`);
303
+ expect(toOKLCH("color(display-p3 0.995 0.99 0.922)")).toMatchInlineSnapshot(`"oklch(98.841% 0.02505 102.92)"`);
304
+ expect(toOKLCH("color(display-p3 0.997 0.982 0.749)")).toMatchInlineSnapshot(`"oklch(97.402% 0.08449 104.36)"`);
305
+ });
306
+ });
307
+ });