@cdx-ui/utils 0.0.1-beta.7 → 0.0.1-beta.70
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 +45 -22
- package/lib/commonjs/common/mergeRefs.js +39 -1
- package/lib/commonjs/common/mergeRefs.js.map +1 -1
- package/lib/commonjs/form-control/index.js +6 -0
- package/lib/commonjs/form-control/index.js.map +1 -1
- package/lib/commonjs/form-control/useFormControl.js +33 -0
- package/lib/commonjs/form-control/useFormControl.js.map +1 -1
- package/lib/module/common/mergeRefs.js +39 -1
- package/lib/module/common/mergeRefs.js.map +1 -1
- package/lib/module/form-control/index.js +1 -1
- package/lib/module/form-control/index.js.map +1 -1
- package/lib/module/form-control/useFormControl.js +33 -0
- package/lib/module/form-control/useFormControl.js.map +1 -1
- package/lib/typescript/common/mergeRefs.d.ts +8 -0
- package/lib/typescript/common/mergeRefs.d.ts.map +1 -1
- package/lib/typescript/form-control/index.d.ts +1 -1
- package/lib/typescript/form-control/index.d.ts.map +1 -1
- package/lib/typescript/form-control/useFormControl.d.ts +28 -3
- package/lib/typescript/form-control/useFormControl.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/common/accessibilityUtils.test.ts +12 -0
- package/src/common/cn.test.ts +19 -0
- package/src/common/composeEventHandlers.test.ts +35 -0
- package/src/common/getSpacedChild.test.tsx +64 -0
- package/src/common/mergeRefs.test.ts +148 -0
- package/src/common/mergeRefs.ts +45 -2
- package/src/common/useControllableState.test.tsx +90 -0
- package/src/context/createContext.test.tsx +31 -0
- package/src/form-control/index.ts +2 -0
- package/src/form-control/useFormControl.test.tsx +214 -0
- package/src/form-control/useFormControl.tsx +48 -4
- package/src/style/context/index.test.tsx +31 -0
- package/src/style/withStyleContext/index.test.tsx +55 -0
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Platform
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
3
|
import { ariaAttr } from '../common/accessibilityUtils';
|
|
4
4
|
|
|
5
5
|
type HTMLProps = Record<string, unknown>;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Minimal contract for any control registered on `FormControlContextValue.inputRef`.
|
|
9
|
+
*
|
|
10
|
+
* `Field.Label` / `focusInput()` only need to call `.focus()` on the registered node;
|
|
11
|
+
* narrower control-specific shapes (`TextInput`, `HTMLInputElement`, custom focus
|
|
12
|
+
* bridges like `Select.Trigger`'s) all satisfy this contract. Consumers that need
|
|
13
|
+
* more (e.g. reading `.value`) should cast locally — keeping the context type
|
|
14
|
+
* minimal avoids a typed lie when a non-input bridge is registered.
|
|
15
|
+
*/
|
|
16
|
+
export interface Focusable {
|
|
17
|
+
focus(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export interface FormControlState {
|
|
8
21
|
isRequired: boolean;
|
|
9
22
|
isInvalid: boolean;
|
|
@@ -25,9 +38,16 @@ export interface FormControlContextValue extends FormControlState {
|
|
|
25
38
|
/**
|
|
26
39
|
* @platform native — set by `Input.Field` / `Select.Trigger` for label press-to-focus.
|
|
27
40
|
* On web, use `<label htmlFor>` with a labelable control (`input`, `button`, `select`, …).
|
|
41
|
+
*
|
|
42
|
+
* Typed as `Focusable` so any control (TextInput, HTMLInputElement, custom focus
|
|
43
|
+
* bridge) can register without a cast at the assignment site. Consumers that need
|
|
44
|
+
* the narrower shape cast locally.
|
|
28
45
|
*/
|
|
29
|
-
inputRef?: React.RefObject<
|
|
46
|
+
inputRef?: React.RefObject<Focusable | null>;
|
|
30
47
|
focusInput?: () => void;
|
|
48
|
+
/** True when the primary child control is focused or (for Select) open — drives `Field.Label` styling. */
|
|
49
|
+
isLabelFocused?: boolean;
|
|
50
|
+
setIsLabelFocused?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
export const FormControlContext = React.createContext<Partial<FormControlContextValue>>({});
|
|
@@ -57,8 +77,10 @@ export interface FormControlRootReturn {
|
|
|
57
77
|
feedbackId: string;
|
|
58
78
|
helpTextId: string;
|
|
59
79
|
htmlProps: HTMLProps;
|
|
60
|
-
inputRef?: React.RefObject<
|
|
80
|
+
inputRef?: React.RefObject<Focusable | null>;
|
|
61
81
|
focusInput?: () => void;
|
|
82
|
+
isLabelFocused: boolean;
|
|
83
|
+
setIsLabelFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
|
62
84
|
}
|
|
63
85
|
|
|
64
86
|
/**
|
|
@@ -87,7 +109,9 @@ export function useFormControlRoot(props: FormControlRootProps): FormControlRoot
|
|
|
87
109
|
*/
|
|
88
110
|
const [hasHelpText, setHasHelpText] = React.useState(false);
|
|
89
111
|
|
|
90
|
-
const
|
|
112
|
+
const [isLabelFocused, setIsLabelFocused] = React.useState(false);
|
|
113
|
+
|
|
114
|
+
const inputRef = React.useRef<Focusable | null>(null);
|
|
91
115
|
|
|
92
116
|
const focusInput = React.useCallback(() => {
|
|
93
117
|
const node = inputRef.current;
|
|
@@ -111,12 +135,32 @@ export function useFormControlRoot(props: FormControlRootProps): FormControlRoot
|
|
|
111
135
|
feedbackId,
|
|
112
136
|
helpTextId,
|
|
113
137
|
htmlProps,
|
|
138
|
+
isLabelFocused,
|
|
139
|
+
setIsLabelFocused,
|
|
114
140
|
...(Platform.OS !== 'web' ? { inputRef, focusInput } : {}),
|
|
115
141
|
};
|
|
116
142
|
|
|
117
143
|
return context;
|
|
118
144
|
}
|
|
119
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Reports focus/active state from `Input` / `Select.Trigger` to the parent `Field` for label styling.
|
|
148
|
+
* No-ops outside a `Field` (no `setIsLabelFocused` on context).
|
|
149
|
+
*/
|
|
150
|
+
export function useReportFormControlLabelFocus(isActive: boolean) {
|
|
151
|
+
const { setIsLabelFocused } = useFormControlContext();
|
|
152
|
+
|
|
153
|
+
React.useLayoutEffect(() => {
|
|
154
|
+
if (typeof setIsLabelFocused !== 'function') {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
setIsLabelFocused(isActive);
|
|
158
|
+
return () => {
|
|
159
|
+
setIsLabelFocused(false);
|
|
160
|
+
};
|
|
161
|
+
}, [isActive, setIsLabelFocused]);
|
|
162
|
+
}
|
|
163
|
+
|
|
120
164
|
/**
|
|
121
165
|
* Hook that merges local field props with the nearest form field context
|
|
122
166
|
* and returns normalised HTML / ARIA attributes for input elements.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { renderHook } from '@testing-library/react-native';
|
|
3
|
+
import { ParentContext, useParentContext, useStyleContext } from './index';
|
|
4
|
+
|
|
5
|
+
describe('style context hooks', () => {
|
|
6
|
+
it('reads parent context values', () => {
|
|
7
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
8
|
+
<ParentContext.Provider value={{ Button: { size: 'lg' }, Card: { padded: true } }}>
|
|
9
|
+
{children}
|
|
10
|
+
</ParentContext.Provider>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const { result: parentResult } = renderHook(() => useParentContext(), { wrapper });
|
|
14
|
+
const { result: styleResult } = renderHook(() => useStyleContext('Button'), { wrapper });
|
|
15
|
+
|
|
16
|
+
expect(parentResult.current).toEqual({ Button: { size: 'lg' }, Card: { padded: true } });
|
|
17
|
+
expect(styleResult.current).toEqual({ size: 'lg' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('defaults to Global scope', () => {
|
|
21
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
22
|
+
<ParentContext.Provider value={{ Global: { theme: 'dark' } }}>
|
|
23
|
+
{children}
|
|
24
|
+
</ParentContext.Provider>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const { result } = renderHook(() => useStyleContext(), { wrapper });
|
|
28
|
+
|
|
29
|
+
expect(result.current).toEqual({ theme: 'dark' });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Text, View } from 'react-native';
|
|
2
|
+
import { render, screen } from '@testing-library/react-native';
|
|
3
|
+
import { useStyleContext } from '../context';
|
|
4
|
+
import { withStyleContext } from './index';
|
|
5
|
+
|
|
6
|
+
const StyledView = withStyleContext(View, 'Button');
|
|
7
|
+
|
|
8
|
+
function StyleConsumer() {
|
|
9
|
+
const context = useStyleContext('Button');
|
|
10
|
+
return <Text testID="style-context">{JSON.stringify(context)}</Text>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('withStyleContext', () => {
|
|
14
|
+
it('provides scoped context to descendants', () => {
|
|
15
|
+
render(
|
|
16
|
+
<StyledView context={{ size: 'lg' }} testID="wrapped">
|
|
17
|
+
<StyleConsumer />
|
|
18
|
+
</StyledView>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId('style-context')).toHaveTextContent('{"size":"lg"}');
|
|
22
|
+
expect(screen.getByTestId('wrapped')).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('inherits parent context for other scopes', () => {
|
|
26
|
+
const Outer = withStyleContext(View, 'Card');
|
|
27
|
+
|
|
28
|
+
render(
|
|
29
|
+
<Outer context={{ padded: true }} testID="outer">
|
|
30
|
+
<StyledView context={{ size: 'sm' }} testID="inner">
|
|
31
|
+
<StyleConsumer />
|
|
32
|
+
</StyledView>
|
|
33
|
+
</Outer>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByTestId('style-context')).toHaveTextContent('{"size":"sm"}');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('defaults to the Global scope when none is provided', () => {
|
|
40
|
+
const GlobalView = withStyleContext(View);
|
|
41
|
+
|
|
42
|
+
function GlobalConsumer() {
|
|
43
|
+
const context = useStyleContext('Global');
|
|
44
|
+
return <Text testID="global-context">{JSON.stringify(context)}</Text>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
render(
|
|
48
|
+
<GlobalView context={{ theme: 'dark' }} testID="global-wrapped">
|
|
49
|
+
<GlobalConsumer />
|
|
50
|
+
</GlobalView>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(screen.getByTestId('global-context')).toHaveTextContent('{"theme":"dark"}');
|
|
54
|
+
});
|
|
55
|
+
});
|