@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.
Files changed (33) hide show
  1. package/README.md +45 -22
  2. package/lib/commonjs/common/mergeRefs.js +39 -1
  3. package/lib/commonjs/common/mergeRefs.js.map +1 -1
  4. package/lib/commonjs/form-control/index.js +6 -0
  5. package/lib/commonjs/form-control/index.js.map +1 -1
  6. package/lib/commonjs/form-control/useFormControl.js +33 -0
  7. package/lib/commonjs/form-control/useFormControl.js.map +1 -1
  8. package/lib/module/common/mergeRefs.js +39 -1
  9. package/lib/module/common/mergeRefs.js.map +1 -1
  10. package/lib/module/form-control/index.js +1 -1
  11. package/lib/module/form-control/index.js.map +1 -1
  12. package/lib/module/form-control/useFormControl.js +33 -0
  13. package/lib/module/form-control/useFormControl.js.map +1 -1
  14. package/lib/typescript/common/mergeRefs.d.ts +8 -0
  15. package/lib/typescript/common/mergeRefs.d.ts.map +1 -1
  16. package/lib/typescript/form-control/index.d.ts +1 -1
  17. package/lib/typescript/form-control/index.d.ts.map +1 -1
  18. package/lib/typescript/form-control/useFormControl.d.ts +28 -3
  19. package/lib/typescript/form-control/useFormControl.d.ts.map +1 -1
  20. package/package.json +4 -2
  21. package/src/common/accessibilityUtils.test.ts +12 -0
  22. package/src/common/cn.test.ts +19 -0
  23. package/src/common/composeEventHandlers.test.ts +35 -0
  24. package/src/common/getSpacedChild.test.tsx +64 -0
  25. package/src/common/mergeRefs.test.ts +148 -0
  26. package/src/common/mergeRefs.ts +45 -2
  27. package/src/common/useControllableState.test.tsx +90 -0
  28. package/src/context/createContext.test.tsx +31 -0
  29. package/src/form-control/index.ts +2 -0
  30. package/src/form-control/useFormControl.test.tsx +214 -0
  31. package/src/form-control/useFormControl.tsx +48 -4
  32. package/src/style/context/index.test.tsx +31 -0
  33. package/src/style/withStyleContext/index.test.tsx +55 -0
@@ -1,9 +1,22 @@
1
1
  import React from 'react';
2
- import { Platform, type TextInput } from 'react-native';
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<TextInput | null>;
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<TextInput | null>;
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 inputRef = React.useRef<TextInput | null>(null);
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
+ });