@pickle-ui/react 1.0.0-beta.7 → 1.0.0-beta.9

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 (49) hide show
  1. package/README.md +13 -10
  2. package/dist/components/Alert.d.ts +4 -6
  3. package/dist/components/Alert.d.ts.map +1 -1
  4. package/dist/components/Alert.js +4 -6
  5. package/dist/components/Alert.js.map +1 -1
  6. package/dist/components/_internal/VisuallyHiddenInput.d.ts.map +1 -1
  7. package/dist/components/_internal/VisuallyHiddenInput.js +24 -42
  8. package/dist/components/_internal/VisuallyHiddenInput.js.map +1 -1
  9. package/dist/components/form/Form.context.d.ts.map +1 -1
  10. package/dist/components/form/Form.context.js +2 -3
  11. package/dist/components/form/Form.context.js.map +1 -1
  12. package/dist/components/form/FormField.js +2 -2
  13. package/dist/components/form/FormField.js.map +1 -1
  14. package/dist/components/form/Input.d.ts.map +1 -1
  15. package/dist/components/form/Input.js +22 -7
  16. package/dist/components/form/Input.js.map +1 -1
  17. package/dist/components/form/Slider.d.ts +1 -1
  18. package/dist/components/form/Slider.d.ts.map +1 -1
  19. package/dist/entries/theme.d.ts +2 -2
  20. package/dist/entries/theme.d.ts.map +1 -1
  21. package/dist/entries/theme.js +1 -1
  22. package/dist/entries/theme.js.map +1 -1
  23. package/dist/hooks/use-lazy-ref.d.ts.map +1 -1
  24. package/dist/hooks/use-lazy-ref.js +3 -4
  25. package/dist/hooks/use-lazy-ref.js.map +1 -1
  26. package/dist/lib/compose-refs.d.ts +0 -8
  27. package/dist/lib/compose-refs.d.ts.map +1 -1
  28. package/dist/lib/compose-refs.js +6 -19
  29. package/dist/lib/compose-refs.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/base.css +7 -25
  32. package/src/components/Alert.tsx +4 -6
  33. package/src/components/_internal/VisuallyHiddenInput.tsx +33 -51
  34. package/src/components/form/ColorPickerPrimitive.tsx +1 -1
  35. package/src/components/form/Form.context.ts +2 -3
  36. package/src/components/form/FormField.tsx +2 -2
  37. package/src/components/form/Input.tsx +65 -28
  38. package/src/components/form/RadioGroup.test.tsx +1 -3
  39. package/src/components/form/Slider.tsx +1 -1
  40. package/src/entries/theme.ts +2 -2
  41. package/src/hooks/use-lazy-ref.ts +3 -5
  42. package/src/lib/color.test.ts +1 -3
  43. package/src/lib/compose-refs.ts +7 -19
  44. package/dist/lib/theme.d.ts +0 -40
  45. package/dist/lib/theme.d.ts.map +0 -1
  46. package/dist/lib/theme.js +0 -82
  47. package/dist/lib/theme.js.map +0 -1
  48. package/src/lib/theme.test.ts +0 -64
  49. package/src/lib/theme.ts +0 -143
@@ -82,12 +82,10 @@ function AlertIcon({ className, ...props }: AlertIconProps) {
82
82
  }
83
83
 
84
84
  /**
85
- * An inline message that draws attention to contextual information.
86
- *
87
- * The alert has no live-region role by default, so static notices are not
88
- * announced on page load. When the alert appears in response to a user action,
89
- * pass `role="alert"` for urgent messages (failures) or `role="status"` for
90
- * polite ones (confirmations, info).
85
+ * No live-region role by default, so static notices are not announced on page
86
+ * load. When the alert appears in response to a user action, pass
87
+ * `role="alert"` for urgent messages (failures) or `role="status"` for polite
88
+ * ones (confirmations, info).
91
89
  */
92
90
  function AlertRoot({ className, variant = 'default', ...props }: AlertProps) {
93
91
  return (
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
 
5
- /* eslint-disable react-hooks/refs -- this component intentionally reads prevValueRef.current during render to track the previous value across renders; the ref holds derived bookkeeping state, not render-affecting data. */
5
+ /* eslint-disable react-hooks/refs -- prevValueRef is read during render on purpose: it holds the previous value and never affects the output. */
6
6
 
7
7
  type InputValue = string | string[];
8
8
 
@@ -16,6 +16,36 @@ interface VisuallyHiddenInputProps<T = InputValue> extends Omit<
16
16
  value?: T;
17
17
  }
18
18
 
19
+ const noSize: { height?: number; width?: number } = {};
20
+
21
+ // The snapshot is a string so React can compare it by value; an object would be
22
+ // a new snapshot on every read and re-render forever.
23
+ function useElementSize(element: HTMLElement | null) {
24
+ const subscribe = React.useCallback(
25
+ (onChange: () => void) => {
26
+ if (!element) return () => {};
27
+
28
+ const resizeObserver = new ResizeObserver(onChange);
29
+ resizeObserver.observe(element, { box: 'border-box' });
30
+
31
+ return () => {
32
+ resizeObserver.disconnect();
33
+ };
34
+ },
35
+ [element],
36
+ );
37
+ const getSnapshot = () =>
38
+ element ? `${element.offsetWidth}x${element.offsetHeight}` : '';
39
+ const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, () => '');
40
+
41
+ return React.useMemo(() => {
42
+ if (!snapshot) return noSize;
43
+
44
+ const [width, height] = snapshot.split('x').map(Number);
45
+ return { height, width };
46
+ }, [snapshot]);
47
+ }
48
+
19
49
  function VisuallyHiddenInput<T = InputValue>(
20
50
  props: VisuallyHiddenInputProps<T>,
21
51
  ) {
@@ -27,7 +57,7 @@ function VisuallyHiddenInput<T = InputValue>(
27
57
  type = 'hidden',
28
58
  value,
29
59
  ...inputProps
30
- // react-doctor-disable-next-line react-doctor/no-event-handler -- this hidden input intentionally mirrors value/checked into the native control and dispatches a native input/click event from an effect; the side effect is driven by prop changes, not a user event, so it cannot move into an event handler
60
+ // react-doctor-disable-next-line react-doctor/no-event-handler -- an effect mirrors value/checked into the native input and dispatches its event: prop changes drive it, not a user event
31
61
  } = props;
32
62
 
33
63
  const isCheckInput = React.useMemo(
@@ -53,55 +83,7 @@ function VisuallyHiddenInput<T = InputValue>(
53
83
  return prevValueRef.current.previous;
54
84
  }, [isCheckInput, value, checked]);
55
85
 
56
- const [controlSize, setControlSize] = React.useState<{
57
- height?: number;
58
- width?: number;
59
- }>({});
60
-
61
- // react-doctor-disable-next-line react-doctor/no-cascading-set-state -- this effect measures the control element and only calls setControlSize in mutually exclusive branches (or inside an async ResizeObserver callback), not as a synchronous render cascade
62
- React.useLayoutEffect(() => {
63
- if (!control) {
64
- // eslint-disable-next-line react-hooks/set-state-in-effect -- reset measured size synchronously when the control is removed so the hidden input does not retain a stale size
65
- setControlSize({});
66
- return;
67
- }
68
-
69
- setControlSize({
70
- height: control.offsetHeight,
71
- width: control.offsetWidth,
72
- });
73
-
74
- if (typeof window === 'undefined') return;
75
-
76
- const resizeObserver = new ResizeObserver((entries) => {
77
- if (!Array.isArray(entries) || !entries.length) return;
78
-
79
- const entry = entries[0];
80
- if (!entry) return;
81
-
82
- let width: number;
83
- let height: number;
84
-
85
- if ('borderBoxSize' in entry) {
86
- const borderSizeEntry = entry.borderBoxSize;
87
- const borderSize = Array.isArray(borderSizeEntry)
88
- ? borderSizeEntry[0]
89
- : borderSizeEntry;
90
- width = borderSize.inlineSize;
91
- height = borderSize.blockSize;
92
- } else {
93
- width = control.offsetWidth;
94
- height = control.offsetHeight;
95
- }
96
-
97
- setControlSize({ height, width });
98
- });
99
-
100
- resizeObserver.observe(control, { box: 'border-box' });
101
- return () => {
102
- resizeObserver.disconnect();
103
- };
104
- }, [control]);
86
+ const controlSize = useElementSize(control);
105
87
 
106
88
  React.useEffect(() => {
107
89
  const input = inputRef.current;
@@ -146,7 +146,7 @@ interface ColorPickerFormatSelectProps
146
146
  Omit<React.ComponentProps<typeof Select>, 'onValueChange' | 'value'>,
147
147
  Pick<React.ComponentProps<typeof SelectTrigger>, 'className'> {}
148
148
 
149
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- a named props interface that is intentionally an alias of the Omit below, kept as an interface for naming and future extension
149
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- a named alias of the Omit below
150
150
  interface ColorPickerImplProps extends Omit<
151
151
  ColorPickerProps,
152
152
  | 'defaultFormat'
@@ -48,9 +48,8 @@ function useFieldName(name: string | undefined) {
48
48
  return name ?? field?.name;
49
49
  }
50
50
 
51
- // React Hook Form exposes field state, but it does not attach DOM aria/id
52
- // props to custom controls. This maps the current Form.Field onto the actual
53
- // visible control.
51
+ // React Hook Form tracks field state but attaches no aria or id props to
52
+ // custom controls: this maps the current Form.Field onto its visible control.
54
53
  function useFormControlProps(
55
54
  props: FormControlProps = {},
56
55
  { enabled = true }: UseFormControlPropsOptions = {},
@@ -46,8 +46,8 @@ function FormField({
46
46
  });
47
47
  const fieldError = name ? getFieldState(name, formState).error : undefined;
48
48
  const errorMessage = error ?? getErrorMessage(fieldError);
49
- // The control id is the focus target for the visible field label.
50
- // It is generated by default to avoid duplicate DOM ids across forms/rows.
49
+ // The label focuses the control by this id; `useId` keeps it unique across
50
+ // forms and rows.
51
51
  const generatedControlId = React.useId();
52
52
  const controlId = controlIdProp ?? generatedControlId;
53
53
  const nativeLabel = shouldUseNativeFieldLabel(children);
@@ -23,48 +23,85 @@ type InputProps = Omit<
23
23
  unstyled?: boolean;
24
24
  };
25
25
 
26
- function Input({
26
+ type ResolvedSize = keyof typeof inputControlHeights;
27
+
28
+ function getControlClassName({
27
29
  className,
28
- classNames,
29
- nativeInput = false,
30
- size = 'md',
31
- style,
32
- unstyled = false,
33
- ...props
34
- }: InputProps) {
35
- const resolvedSize = typeof size === 'number' ? 'md' : size;
36
- const inputClassName = cn(
30
+ size,
31
+ unstyled,
32
+ }: {
33
+ className: InputProps['className'];
34
+ size: ResolvedSize;
35
+ unstyled: boolean;
36
+ }) {
37
+ return (
38
+ cn(
39
+ !unstyled &&
40
+ cn(
41
+ inputControlHeights[size],
42
+ 'border-input bg-background text-foreground ring-ring/24 has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 relative inline-flex w-full rounded-lg border text-base shadow-xs/5 transition-shadow not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:ring-[3px] has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none sm:text-sm dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]',
43
+ ),
44
+ className,
45
+ ) || undefined
46
+ );
47
+ }
48
+
49
+ function getInputClassName({
50
+ inputClassNames,
51
+ size,
52
+ type,
53
+ unstyled,
54
+ }: {
55
+ inputClassNames: string | undefined;
56
+ size: ResolvedSize;
57
+ type: React.HTMLInputTypeAttribute | undefined;
58
+ unstyled: boolean;
59
+ }) {
60
+ return cn(
37
61
  unstyled
38
62
  ? cn(
39
63
  'w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-none outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72',
40
- inputControlHeights[resolvedSize],
41
- resolvedSize === 'sm' && 'px-[calc(--spacing(2.5)-1px)]',
42
- resolvedSize === 'lg' && 'px-[calc(--spacing(3.5)-1px)]',
64
+ inputControlHeights[size],
65
+ size === 'sm' && 'px-[calc(--spacing(2.5)-1px)]',
66
+ size === 'lg' && 'px-[calc(--spacing(3.5)-1px)]',
43
67
  )
44
68
  : cn(
45
69
  'h-full min-h-0 w-full rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-none outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72',
46
- resolvedSize === 'sm' && 'px-[calc(--spacing(2.5)-1px)]',
47
- resolvedSize === 'lg' && 'px-[calc(--spacing(3.5)-1px)]',
70
+ size === 'sm' && 'px-[calc(--spacing(2.5)-1px)]',
71
+ size === 'lg' && 'px-[calc(--spacing(3.5)-1px)]',
48
72
  ),
49
- props.type === 'search' &&
73
+ type === 'search' &&
50
74
  '[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none',
51
- props.type === 'file' &&
75
+ type === 'file' &&
52
76
  'text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm',
53
- classNames?.input,
77
+ inputClassNames,
54
78
  );
79
+ }
80
+
81
+ function Input({
82
+ className,
83
+ classNames,
84
+ nativeInput = false,
85
+ size = 'md',
86
+ style,
87
+ unstyled = false,
88
+ ...props
89
+ }: InputProps) {
90
+ const resolvedSize = typeof size === 'number' ? 'md' : size;
91
+ const inputClassName = getInputClassName({
92
+ inputClassNames: classNames?.input,
93
+ size: resolvedSize,
94
+ type: props.type,
95
+ unstyled,
96
+ });
55
97
 
56
98
  return (
57
99
  <span
58
- className={
59
- cn(
60
- !unstyled &&
61
- cn(
62
- inputControlHeights[resolvedSize],
63
- 'border-input bg-background text-foreground ring-ring/24 has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 relative inline-flex w-full rounded-lg border text-base shadow-xs/5 transition-shadow not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:ring-[3px] has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none sm:text-sm dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]',
64
- ),
65
- className,
66
- ) || undefined
67
- }
100
+ className={getControlClassName({
101
+ className,
102
+ size: resolvedSize,
103
+ unstyled,
104
+ })}
68
105
  data-size={resolvedSize}
69
106
  data-slot="input-control"
70
107
  >
@@ -109,13 +109,11 @@ describe('RadioGroup', () => {
109
109
  <RadioGroup.Item label="Option C" value="c" />
110
110
  </RadioGroup>,
111
111
  );
112
- // All radios in a group are reachable via keyboard.
113
112
  const radios = screen.getAllByRole('radio');
114
113
  expect(radios).toHaveLength(3);
115
- // The group element exists and has roving-tabindex structure (tabIndex 0 on selected).
116
114
  const radioA = screen.getByRole('radio', { name: 'Option A' });
117
115
  expect(radioA).toHaveAttribute('aria-checked', 'true');
118
- // Click directly to simulate selection change (roving focus is a browser behavior).
116
+ // Arrow-key roving focus is browser behavior jsdom lacks, so click.
119
117
  fireEvent.click(screen.getByRole('radio', { name: 'Option C' }));
120
118
  expect(screen.getByRole('radio', { name: 'Option C' })).toHaveAttribute(
121
119
  'aria-checked',
@@ -20,7 +20,7 @@ type SliderProps = SliderPrimitive.Root.Props & {
20
20
  classNames?: SlotClassNames<'control' | 'indicator' | 'thumb' | 'track'>;
21
21
  getAriaLabel?: SliderPrimitive.Thumb.Props['getAriaLabel'];
22
22
  getAriaValueText?: SliderPrimitive.Thumb.Props['getAriaValueText'];
23
- /** Render the filled indicator. Set to `false` for sliders with a custom track (e.g. color pickers). Defaults to `true`. */
23
+ /** Draws the filled track. `false` for a custom track, as in the color picker. Defaults to `true`. */
24
24
  indicator?: boolean;
25
25
  ref?: Ref<HTMLDivElement>;
26
26
  trackStyle?: CSSProperties;
@@ -1,8 +1,8 @@
1
- export { createTheme, themeToCss } from '../lib/theme';
1
+ export { createTheme, themeToCss } from '@pickle-ui/tokens/theme';
2
2
  export type {
3
3
  Theme,
4
4
  ThemeCssOptions,
5
5
  ThemeFonts,
6
6
  ThemeOptions,
7
7
  ThemeVariables,
8
- } from '../lib/theme';
8
+ } from '@pickle-ui/tokens/theme';
@@ -1,11 +1,9 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  function useLazyRef<T>(fn: () => T) {
4
- const ref = React.useRef<null | T>(null);
5
-
6
- if (ref.current === null) {
7
- ref.current = fn();
8
- }
4
+ // `useState` runs its initializer once, so `fn` is never called again and the
5
+ // `{ current }` box stays stable; render itself never writes to a ref.
6
+ const [ref] = React.useState(() => ({ current: fn() }));
9
7
 
10
8
  return ref as React.RefObject<T>;
11
9
  }
@@ -48,9 +48,7 @@ describe('rgb <-> hsv', () => {
48
48
  });
49
49
  });
50
50
 
51
- // Documents CURRENT behavior: achromatic colors report hue 0 (red).
52
- // Plan 004 changes this to preserve a caller-supplied hue. When 004 lands,
53
- // this assertion is expected to be updated there.
51
+ // Without a fallback hue, an achromatic color reports hue 0.
54
52
  it('reports hue 0 for white (achromatic)', () => {
55
53
  expect(rgbToHsv({ a: 1, b: 255, g: 255, r: 255 })).toEqual({
56
54
  a: 1,
@@ -2,10 +2,9 @@ import * as React from 'react';
2
2
 
3
3
  type PossibleRef<T> = React.Ref<T> | undefined;
4
4
 
5
- /**
6
- * A utility to compose multiple refs together
7
- * Accepts callback refs and RefObject(s)
8
- */
5
+ // Radix UI's compose-refs (MIT): callback refs and ref objects merged into one
6
+ // callback ref, with React 19 ref cleanups.
7
+
9
8
  function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
10
9
  return (node) => {
11
10
  let hasCleanup = false;
@@ -17,10 +16,8 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
17
16
  return cleanup;
18
17
  });
19
18
 
20
- // React <19 will log an error to the console if a callback ref returns a
21
- // value. We don't use ref cleanups internally so this will only happen if a
22
- // user's ref callback returns a value, which we only expect if they are
23
- // using the cleanup functionality added in React 19.
19
+ // Only return a cleanup when a ref returned one: React 18 logs an error
20
+ // for a callback ref that returns a value.
24
21
  if (hasCleanup) {
25
22
  return () => {
26
23
  for (let i = 0; i < cleanups.length; i++) {
@@ -36,10 +33,6 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
36
33
  };
37
34
  }
38
35
 
39
- /**
40
- * Set a given ref to a given value
41
- * This utility takes care of different types of refs: callback refs and RefObject(s)
42
- */
43
36
  function setRef<T>(ref: PossibleRef<T>, value: T) {
44
37
  if (typeof ref === 'function') {
45
38
  return ref(value);
@@ -50,14 +43,9 @@ function setRef<T>(ref: PossibleRef<T>, value: T) {
50
43
  }
51
44
  }
52
45
 
53
- /**
54
- * A custom hook that composes multiple refs
55
- * Accepts callback refs and RefObject(s)
56
- */
57
46
  function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
58
- // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
59
- // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo -- memoize by every ref value; the spread `refs` array is the intended dependency list
60
- return React.useCallback(composeRefs(...refs), refs); // react-doctor-disable-line react-doctor/exhaustive-deps -- same intentional memo: the dependency list is the spread `refs` array on purpose
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo -- memoized by every ref: the spread `refs` is the dependency list
48
+ return React.useCallback(composeRefs(...refs), refs); // react-doctor-disable-line react-doctor/exhaustive-deps -- memoized by every ref: the spread `refs` is the dependency list
61
49
  }
62
50
 
63
51
  export { composeRefs, useComposedRefs };
@@ -1,40 +0,0 @@
1
- /**
2
- * Theme generator: derives Pickle's brand-dependent tokens from a single
3
- * primary color so consumers can theme the library without hand-writing CSS
4
- * variables. Tokens that do not depend on the brand (neutrals, status colors)
5
- * keep the values from `@pickle-ui/react/theme.css`.
6
- */
7
- type Theme = {
8
- dark: ThemeVariables;
9
- light: ThemeVariables;
10
- };
11
- type ThemeCssOptions = {
12
- darkSelector?: string;
13
- lightSelector?: string;
14
- };
15
- type ThemeFonts = {
16
- heading?: string;
17
- mono?: string;
18
- sans?: string;
19
- };
20
- type ThemeOptions = {
21
- /** Font stacks, written to `--font-sans`, `--font-heading` and `--font-mono`. */
22
- fonts?: ThemeFonts;
23
- /** Brand color in any CSS notation (hex, rgb, hsl, oklch, …). */
24
- primary: string;
25
- /** Brand color for the dark scheme. Defaults to a lighter version of `primary`. */
26
- primaryDark?: string;
27
- /** Base radius, e.g. `'0.5rem'`. Every `rounded-*` scale derives from it. */
28
- radius?: string;
29
- };
30
- type ThemeVariables = Record<`--${string}`, string>;
31
- /**
32
- * Build the light and dark token sets for a brand color. Apply the result
33
- * with `themeToCss` in a stylesheet, or spread `theme.light` / `theme.dark`
34
- * as the `style` of a wrapper element.
35
- */
36
- export declare function createTheme(options: ThemeOptions): Theme;
37
- /** Serialize a theme as CSS rules for the light and dark selectors. */
38
- export declare function themeToCss(theme: Theme, { darkSelector, lightSelector }?: ThemeCssOptions): string;
39
- export type { Theme, ThemeCssOptions, ThemeFonts, ThemeOptions, ThemeVariables, };
40
- //# sourceMappingURL=theme.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/lib/theme.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,KAAK,KAAK,GAAG;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,EAAE,cAAc,CAAC;CACvB,CAAC;AAEF,KAAK,eAAe,GAAG;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,UAAU,GAAG;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,KAAK,YAAY,GAAG;IAClB,iFAAiF;IACjF,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,KAAK,cAAc,GAAG,MAAM,CAAC,KAAK,MAAM,EAAE,EAAE,MAAM,CAAC,CAAC;AAOpD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAWxD;AAED,uEAAuE;AACvE,wBAAgB,UAAU,CACxB,KAAK,EAAE,KAAK,EACZ,EAAE,YAAsB,EAAE,aAAuB,EAAE,GAAE,eAAoB,GACxE,MAAM,CAOR;AAyDD,YAAY,EACV,KAAK,EACL,eAAe,EACf,UAAU,EACV,YAAY,EACZ,cAAc,GACf,CAAC"}
package/dist/lib/theme.js DELETED
@@ -1,82 +0,0 @@
1
- /**
2
- * Theme generator: derives Pickle's brand-dependent tokens from a single
3
- * primary color so consumers can theme the library without hand-writing CSS
4
- * variables. Tokens that do not depend on the brand (neutrals, status colors)
5
- * keep the values from `@pickle-ui/react/theme.css`.
6
- */
7
- import { clampChroma, converter, formatCss, parse, wcagContrast, } from 'culori';
8
- const toOklch = converter('oklch');
9
- const LIGHT_FOREGROUND = { c: 0, h: 0, l: 1, mode: 'oklch' };
10
- const DARK_FOREGROUND = { c: 0.006, h: 285.9, l: 0.21, mode: 'oklch' };
11
- /**
12
- * Build the light and dark token sets for a brand color. Apply the result
13
- * with `themeToCss` in a stylesheet, or spread `theme.light` / `theme.dark`
14
- * as the `style` of a wrapper element.
15
- */
16
- export function createTheme(options) {
17
- const primary = parseColor(options.primary, 'primary');
18
- const primaryDark = options.primaryDark
19
- ? parseColor(options.primaryDark, 'primaryDark')
20
- : lightenForDark(primary);
21
- const shared = sharedVariables(options);
22
- return {
23
- dark: { ...shared, ...brandVariables(primaryDark) },
24
- light: { ...shared, ...brandVariables(primary) },
25
- };
26
- }
27
- /** Serialize a theme as CSS rules for the light and dark selectors. */
28
- export function themeToCss(theme, { darkSelector = '.dark', lightSelector = ':root' } = {}) {
29
- const block = (selector, variables) => `${selector} {\n${Object.entries(variables)
30
- .map(([name, value]) => ` ${name}: ${value};`)
31
- .join('\n')}\n}`;
32
- return `${block(lightSelector, theme.light)}\n\n${block(darkSelector, theme.dark)}\n`;
33
- }
34
- function brandVariables(primary) {
35
- const foreground = foregroundFor(primary);
36
- return {
37
- '--primary': format(primary),
38
- '--primary-foreground': format(foreground),
39
- '--ring': format({ ...primary, c: primary.c * 0.6 }),
40
- '--sidebar-primary': format(primary),
41
- '--sidebar-primary-foreground': format(foreground),
42
- '--sidebar-ring': format({ ...primary, c: primary.c * 0.6 }),
43
- };
44
- }
45
- /** White or near-black, whichever reads better on the given fill. */
46
- function foregroundFor(fill) {
47
- return wcagContrast(fill, LIGHT_FOREGROUND) >=
48
- wcagContrast(fill, DARK_FOREGROUND)
49
- ? LIGHT_FOREGROUND
50
- : DARK_FOREGROUND;
51
- }
52
- function format(color) {
53
- return formatCss(clampChroma(color, 'oklch'));
54
- }
55
- /** Lift a brand color so it stays vivid on dark surfaces. */
56
- function lightenForDark(color) {
57
- return {
58
- ...color,
59
- c: color.c * 0.92,
60
- l: Math.min(Math.max(color.l + 0.12, 0.6), 0.85),
61
- };
62
- }
63
- function parseColor(value, name) {
64
- const color = parse(value);
65
- if (!color) {
66
- throw new Error(`createTheme: ${name} is not a valid CSS color: ${value}`);
67
- }
68
- return toOklch(color);
69
- }
70
- function sharedVariables({ fonts, radius }) {
71
- const variables = {};
72
- if (radius)
73
- variables['--radius'] = radius;
74
- if (fonts?.sans)
75
- variables['--font-sans'] = fonts.sans;
76
- if (fonts?.heading)
77
- variables['--font-heading'] = fonts.heading;
78
- if (fonts?.mono)
79
- variables['--font-mono'] = fonts.mono;
80
- return variables;
81
- }
82
- //# sourceMappingURL=theme.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/lib/theme.ts"],"names":[],"mappings":"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"}
@@ -1,64 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { createTheme, themeToCss } from './theme';
4
-
5
- describe('createTheme', () => {
6
- it('derives primary tokens for both schemes', () => {
7
- const theme = createTheme({ primary: '#16a34a' });
8
-
9
- expect(theme.light['--primary']).toMatch(/^oklch\(/);
10
- expect(theme.dark['--primary']).toMatch(/^oklch\(/);
11
- expect(theme.light['--primary']).not.toBe(theme.dark['--primary']);
12
- expect(theme.light['--sidebar-primary']).toBe(theme.light['--primary']);
13
- });
14
-
15
- it('picks a readable foreground for light and dark fills', () => {
16
- const onDarkFill = createTheme({ primary: '#1e3a8a' });
17
- const onLightFill = createTheme({ primary: '#fde047' });
18
-
19
- expect(onDarkFill.light['--primary-foreground']).toBe('oklch(1 0 0)');
20
- expect(onLightFill.light['--primary-foreground']).not.toBe('oklch(1 0 0)');
21
- });
22
-
23
- it('uses primaryDark as given and passes radius and fonts through', () => {
24
- const theme = createTheme({
25
- fonts: { mono: 'Menlo', sans: 'Inter' },
26
- primary: 'rebeccapurple',
27
- primaryDark: 'oklch(0.8 0.1 300)',
28
- radius: '1rem',
29
- });
30
-
31
- expect(theme.dark['--primary']).toBe('oklch(0.8 0.1 300)');
32
- expect(theme.light['--radius']).toBe('1rem');
33
- expect(theme.dark['--radius']).toBe('1rem');
34
- expect(theme.light['--font-sans']).toBe('Inter');
35
- expect(theme.light['--font-mono']).toBe('Menlo');
36
- expect(theme.light['--font-heading']).toBeUndefined();
37
- });
38
-
39
- it('rejects colors it cannot parse', () => {
40
- expect(() => createTheme({ primary: 'not-a-color' })).toThrow(
41
- /not a valid CSS color/,
42
- );
43
- });
44
- });
45
-
46
- describe('themeToCss', () => {
47
- it('serializes both schemes under their selectors', () => {
48
- const css = themeToCss(createTheme({ primary: '#000', radius: '4px' }));
49
-
50
- expect(css).toMatch(/^:root \{\n {2}--radius: 4px;\n/);
51
- expect(css).toContain('\n.dark {\n');
52
- expect(css).toContain('--primary-foreground: oklch(1 0 0);');
53
- });
54
-
55
- it('accepts custom selectors', () => {
56
- const css = themeToCss(createTheme({ primary: '#000' }), {
57
- darkSelector: '[data-theme="dark"]',
58
- lightSelector: 'html',
59
- });
60
-
61
- expect(css.startsWith('html {')).toBe(true);
62
- expect(css).toContain('[data-theme="dark"] {');
63
- });
64
- });