@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.
- package/README.md +13 -10
- package/dist/components/Alert.d.ts +4 -6
- package/dist/components/Alert.d.ts.map +1 -1
- package/dist/components/Alert.js +4 -6
- package/dist/components/Alert.js.map +1 -1
- package/dist/components/_internal/VisuallyHiddenInput.d.ts.map +1 -1
- package/dist/components/_internal/VisuallyHiddenInput.js +24 -42
- package/dist/components/_internal/VisuallyHiddenInput.js.map +1 -1
- package/dist/components/form/Form.context.d.ts.map +1 -1
- package/dist/components/form/Form.context.js +2 -3
- package/dist/components/form/Form.context.js.map +1 -1
- package/dist/components/form/FormField.js +2 -2
- package/dist/components/form/FormField.js.map +1 -1
- package/dist/components/form/Input.d.ts.map +1 -1
- package/dist/components/form/Input.js +22 -7
- package/dist/components/form/Input.js.map +1 -1
- package/dist/components/form/Slider.d.ts +1 -1
- package/dist/components/form/Slider.d.ts.map +1 -1
- package/dist/entries/theme.d.ts +2 -2
- package/dist/entries/theme.d.ts.map +1 -1
- package/dist/entries/theme.js +1 -1
- package/dist/entries/theme.js.map +1 -1
- package/dist/hooks/use-lazy-ref.d.ts.map +1 -1
- package/dist/hooks/use-lazy-ref.js +3 -4
- package/dist/hooks/use-lazy-ref.js.map +1 -1
- package/dist/lib/compose-refs.d.ts +0 -8
- package/dist/lib/compose-refs.d.ts.map +1 -1
- package/dist/lib/compose-refs.js +6 -19
- package/dist/lib/compose-refs.js.map +1 -1
- package/package.json +3 -3
- package/src/base.css +7 -25
- package/src/components/Alert.tsx +4 -6
- package/src/components/_internal/VisuallyHiddenInput.tsx +33 -51
- package/src/components/form/ColorPickerPrimitive.tsx +1 -1
- package/src/components/form/Form.context.ts +2 -3
- package/src/components/form/FormField.tsx +2 -2
- package/src/components/form/Input.tsx +65 -28
- package/src/components/form/RadioGroup.test.tsx +1 -3
- package/src/components/form/Slider.tsx +1 -1
- package/src/entries/theme.ts +2 -2
- package/src/hooks/use-lazy-ref.ts +3 -5
- package/src/lib/color.test.ts +1 -3
- package/src/lib/compose-refs.ts +7 -19
- package/dist/lib/theme.d.ts +0 -40
- package/dist/lib/theme.d.ts.map +0 -1
- package/dist/lib/theme.js +0 -82
- package/dist/lib/theme.js.map +0 -1
- package/src/lib/theme.test.ts +0 -64
- package/src/lib/theme.ts +0 -143
package/src/components/Alert.tsx
CHANGED
|
@@ -82,12 +82,10 @@ function AlertIcon({ className, ...props }: AlertIconProps) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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 --
|
|
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 --
|
|
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
|
|
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
|
|
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
|
|
52
|
-
//
|
|
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
|
|
50
|
-
//
|
|
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
|
-
|
|
26
|
+
type ResolvedSize = keyof typeof inputControlHeights;
|
|
27
|
+
|
|
28
|
+
function getControlClassName({
|
|
27
29
|
className,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
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[
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
70
|
+
size === 'sm' && 'px-[calc(--spacing(2.5)-1px)]',
|
|
71
|
+
size === 'lg' && 'px-[calc(--spacing(3.5)-1px)]',
|
|
48
72
|
),
|
|
49
|
-
|
|
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
|
-
|
|
75
|
+
type === 'file' &&
|
|
52
76
|
'text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm',
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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;
|
package/src/entries/theme.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { createTheme, themeToCss } from '
|
|
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 '
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
}
|
package/src/lib/color.test.ts
CHANGED
|
@@ -48,9 +48,7 @@ describe('rgb <-> hsv', () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
//
|
|
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,
|
package/src/lib/compose-refs.ts
CHANGED
|
@@ -2,10 +2,9 @@ import * as React from 'react';
|
|
|
2
2
|
|
|
3
3
|
type PossibleRef<T> = React.Ref<T> | undefined;
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
59
|
-
//
|
|
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 };
|
package/dist/lib/theme.d.ts
DELETED
|
@@ -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
|
package/dist/lib/theme.d.ts.map
DELETED
|
@@ -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
|
package/dist/lib/theme.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/lib/theme.ts"],"names":[],"mappings":"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"}
|
package/src/lib/theme.test.ts
DELETED
|
@@ -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
|
-
});
|