@enonic/ui 0.15.1 → 0.17.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.
@@ -0,0 +1,346 @@
1
+ import { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react';
2
+ /**
3
+ * Root component that provides context for the Menubar.
4
+ *
5
+ * Implements the ARIA menubar pattern for horizontal navigation.
6
+ * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menubar/}
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * <Menubar.Root>
11
+ * <Menubar.Content aria-label="Main navigation">
12
+ * <Menubar.Button>New</Menubar.Button>
13
+ * <Menubar.Button>Save</Menubar.Button>
14
+ * </Menubar.Content>
15
+ * </Menubar.Root>
16
+ * ```
17
+ */
18
+ export type MenubarRootProps = {
19
+ /** Default active item ID on initial render */
20
+ defaultActive?: string;
21
+ /** Callback when active item changes */
22
+ onActiveChange?: (active: string | undefined) => void;
23
+ /** Base ID for generating unique IDs */
24
+ id?: string;
25
+ children?: ReactNode;
26
+ };
27
+ /**
28
+ * Container component that renders the menubar element with keyboard navigation.
29
+ *
30
+ * Implements horizontal arrow key navigation (ArrowLeft/Right), Home/End keys,
31
+ * and Tab key to exit the menubar.
32
+ *
33
+ * This is exported as part of the Menubar API but used for the horizontal menubar container,
34
+ * not the dropdown menu content (which is also named MenubarContent but context-aware).
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * <MenubarNav aria-label="Main menu" loop>
39
+ * <Menubar.Button>File</Menubar.Button>
40
+ * <Menubar.Button>Edit</Menubar.Button>
41
+ * </MenubarNav>
42
+ * ```
43
+ */
44
+ export type MenubarNavProps = {
45
+ /** Accessible label for the menubar (required for screen readers) */
46
+ 'aria-label': string;
47
+ /** Whether navigation should loop from last to first item */
48
+ loop?: boolean;
49
+ className?: string;
50
+ children?: ReactNode;
51
+ } & ComponentPropsWithoutRef<'div'>;
52
+ /**
53
+ * A button item within the menubar.
54
+ *
55
+ * Implements ARIA menuitem role and integrates with menubar keyboard navigation.
56
+ * Supports active state animations via Tailwind transitions.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * <Menubar.Button onSelect={() => console.log('clicked')}>
61
+ * Save
62
+ * </Menubar.Button>
63
+ *
64
+ * <Menubar.Button disabled>Export</Menubar.Button>
65
+ *
66
+ * <Menubar.Button asChild>
67
+ * <a href="/new">New</a>
68
+ * </Menubar.Button>
69
+ * ```
70
+ */
71
+ export type MenubarButtonProps = {
72
+ /** Unique ID for this button (auto-generated if not provided) */
73
+ id?: string;
74
+ /** Whether the button is disabled */
75
+ disabled?: boolean;
76
+ /** Callback when the button is selected (clicked or activated via keyboard) */
77
+ onSelect?: (event: Event) => void;
78
+ /** Render as a child element using Radix UI Slot */
79
+ asChild?: boolean;
80
+ className?: string;
81
+ children: ReactNode;
82
+ } & Omit<ComponentPropsWithoutRef<'button'>, 'id' | 'children'>;
83
+ /**
84
+ * A visual separator component that adapts based on context.
85
+ *
86
+ * - Within Menubar.Content (menubar): Renders as vertical separator between buttons
87
+ * - Within Menubar.Menu Content (dropdown): Renders as horizontal separator between items
88
+ *
89
+ * Does not participate in keyboard navigation (purely decorative).
90
+ *
91
+ * @example
92
+ * ```tsx
93
+ * // Vertical separator between menubar buttons
94
+ * <Menubar.Content aria-label="Actions">
95
+ * <Menubar.Button>File</Menubar.Button>
96
+ * <Menubar.Separator />
97
+ * <Menubar.Button>Edit</Menubar.Button>
98
+ * </Menubar.Content>
99
+ *
100
+ * // Horizontal separator between menu items
101
+ * <Menubar.Menu>
102
+ * <Menubar.Trigger>File</Menubar.Trigger>
103
+ * <Menubar.Portal>
104
+ * <Menubar.Content>
105
+ * <Menubar.Item>New</Menubar.Item>
106
+ * <Menubar.Separator />
107
+ * <Menubar.Item>Exit</Menubar.Item>
108
+ * </Menubar.Content>
109
+ * </Menubar.Portal>
110
+ * </Menubar.Menu>
111
+ * ```
112
+ */
113
+ export type MenubarSeparatorProps = {
114
+ className?: string;
115
+ } & ComponentPropsWithoutRef<'div'>;
116
+ /**
117
+ * Wrapper component that integrates dropdown menus within a Menubar.
118
+ *
119
+ * Coordinates between the menubar's horizontal navigation and the menu's
120
+ * vertical navigation, implementing the ARIA menubar pattern with submenus.
121
+ *
122
+ * Features:
123
+ * - Registers menu trigger as a menubar item for keyboard navigation
124
+ * - ArrowDown opens the menu from the menubar
125
+ * - ArrowLeft/Right navigate between menus (closing current, opening adjacent)
126
+ * - Automatic conditional hover (hover opens menu when another menu is open)
127
+ * - Tab closes menu and exits menubar entirely
128
+ *
129
+ * @example
130
+ * ```tsx
131
+ * <Menubar.Menu>
132
+ * <Menubar.Trigger>File</Menubar.Trigger>
133
+ * <Menubar.Portal>
134
+ * <Menubar.Content>
135
+ * <Menubar.Item>New</Menubar.Item>
136
+ * <Menubar.Item>Open</Menubar.Item>
137
+ * </Menubar.Content>
138
+ * </Menubar.Portal>
139
+ * </Menubar.Menu>
140
+ * ```
141
+ */
142
+ export type MenubarMenuProps = {
143
+ /** Unique ID for this menu within the menubar */
144
+ id?: string;
145
+ /** Whether the menu is disabled (cannot be opened) */
146
+ disabled?: boolean;
147
+ children?: ReactNode;
148
+ };
149
+ /**
150
+ * Trigger button for a menubar dropdown menu.
151
+ *
152
+ * Opens the menu on click, Enter, Space, or ArrowDown.
153
+ * Implements conditional hover: when another menu is open, hovering this
154
+ * trigger automatically opens its menu and closes the other.
155
+ *
156
+ * Must be used within Menubar.Menu.
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * <Menubar.Menu>
161
+ * <Menubar.Trigger>File</Menubar.Trigger>
162
+ * <Menubar.Portal>
163
+ * <Menubar.Content>...</Menubar.Content>
164
+ * </Menubar.Portal>
165
+ * </Menubar.Menu>
166
+ *
167
+ * <Menubar.Menu>
168
+ * <Menubar.Trigger asChild>
169
+ * <Button variant="text">Edit</Button>
170
+ * </Menubar.Trigger>
171
+ * <Menubar.Portal>
172
+ * <Menubar.Content>...</Menubar.Content>
173
+ * </Menubar.Portal>
174
+ * </Menubar.Menu>
175
+ * ```
176
+ */
177
+ export type MenubarTriggerProps = {
178
+ /** Render as a child element using Radix UI Slot */
179
+ asChild?: boolean;
180
+ className?: string;
181
+ children: ReactNode;
182
+ } & Omit<ComponentPropsWithoutRef<'button'>, 'children'>;
183
+ /**
184
+ * Portal component that renders menubar dropdown content to document.body.
185
+ *
186
+ * Prevents z-index stacking issues by rendering outside the DOM hierarchy.
187
+ * Only renders when the menu is open unless `forceMount` is true.
188
+ *
189
+ * Must be used within Menubar.Menu.
190
+ *
191
+ * @example
192
+ * ```tsx
193
+ * <Menubar.Menu>
194
+ * <Menubar.Trigger>File</Menubar.Trigger>
195
+ * <Menubar.Portal>
196
+ * <Menubar.Content>
197
+ * <Menubar.Item>New</Menubar.Item>
198
+ * </Menubar.Content>
199
+ * </Menubar.Portal>
200
+ * </Menubar.Menu>
201
+ * ```
202
+ */
203
+ export type MenubarPortalProps = {
204
+ /** Custom container element (defaults to document.body) */
205
+ container?: HTMLElement | null;
206
+ /** Keep content mounted in DOM even when menu is closed */
207
+ forceMount?: boolean;
208
+ children?: ReactNode;
209
+ };
210
+ /**
211
+ * Dropdown content container for a menubar menu.
212
+ *
213
+ * Positioned below the trigger with automatic viewport collision detection.
214
+ * Implements vertical keyboard navigation (ArrowUp/Down) within the menu,
215
+ * plus special ArrowLeft/Right handling to navigate between menus.
216
+ *
217
+ * Must be used within Menubar.Portal and Menubar.Menu.
218
+ *
219
+ * @example
220
+ * ```tsx
221
+ * <Menubar.Menu>
222
+ * <Menubar.Trigger>File</Menubar.Trigger>
223
+ * <Menubar.Portal>
224
+ * <Menubar.Content align="start" loop>
225
+ * <Menubar.Item>New File</Menubar.Item>
226
+ * <Menubar.Item>Open File</Menubar.Item>
227
+ * <Menubar.Separator />
228
+ * <Menubar.Item>Exit</Menubar.Item>
229
+ * </Menubar.Content>
230
+ * </Menubar.Portal>
231
+ * </Menubar.Menu>
232
+ * ```
233
+ */
234
+ export type MenubarContentProps = {
235
+ /** Horizontal alignment relative to trigger */
236
+ align?: 'start' | 'end';
237
+ /** Whether navigation should loop from last to first item */
238
+ loop?: boolean;
239
+ /** Keep content mounted in DOM even when menu is closed */
240
+ forceMount?: boolean;
241
+ /** Callback when Escape key is pressed */
242
+ onEscapeKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
243
+ /** Callback when pointer clicks outside the menu */
244
+ onPointerDownOutside?: (event: PointerEvent) => void;
245
+ /** Callback when any outside interaction occurs */
246
+ onInteractOutside?: (event: Event) => void;
247
+ className?: string;
248
+ children?: ReactNode;
249
+ } & ComponentPropsWithoutRef<'div'>;
250
+ /**
251
+ * An interactive item within a menubar dropdown menu.
252
+ *
253
+ * Supports keyboard navigation, hover effects, and the onSelect callback.
254
+ * Automatically closes the menu when selected.
255
+ *
256
+ * Must be used within Menubar.Content.
257
+ *
258
+ * @example
259
+ * ```tsx
260
+ * <Menubar.Content>
261
+ * <Menubar.Item onSelect={() => console.log('New file')}>
262
+ * New File
263
+ * </Menubar.Item>
264
+ * <Menubar.Item disabled>
265
+ * Save (disabled)
266
+ * </Menubar.Item>
267
+ * <Menubar.Item asChild>
268
+ * <a href="/export">Export</a>
269
+ * </Menubar.Item>
270
+ * </Menubar.Content>
271
+ * ```
272
+ */
273
+ export type MenubarItemProps = {
274
+ /** Unique ID for this item (auto-generated if not provided) */
275
+ id?: string;
276
+ /** Whether the item is disabled */
277
+ disabled?: boolean;
278
+ /** Callback when the item is selected (clicked or activated via keyboard) */
279
+ onSelect?: (event: Event) => void;
280
+ /** Render as a child element using Radix UI Slot */
281
+ asChild?: boolean;
282
+ className?: string;
283
+ children: ReactNode;
284
+ } & Omit<ComponentPropsWithoutRef<'div'>, 'id' | 'children'>;
285
+ /**
286
+ * A non-interactive label for grouping items within a menubar dropdown.
287
+ *
288
+ * Used to provide section headings or context within the menu.
289
+ * Does not participate in keyboard navigation.
290
+ *
291
+ * Must be used within Menubar.Content.
292
+ *
293
+ * @example
294
+ * ```tsx
295
+ * <Menubar.Content>
296
+ * <Menubar.Label>File Operations</Menubar.Label>
297
+ * <Menubar.Item>New</Menubar.Item>
298
+ * <Menubar.Item>Open</Menubar.Item>
299
+ * <Menubar.Separator />
300
+ * <Menubar.Label>Recent Files</Menubar.Label>
301
+ * <Menubar.Item>Document.txt</Menubar.Item>
302
+ * </Menubar.Content>
303
+ * ```
304
+ */
305
+ export type MenubarLabelProps = {
306
+ className?: string;
307
+ children?: ReactNode;
308
+ } & ComponentPropsWithoutRef<'div'>;
309
+ export declare const Menubar: {
310
+ ({ defaultActive, onActiveChange, id, children }: MenubarRootProps): ReactElement;
311
+ displayName: string;
312
+ } & {
313
+ Root: {
314
+ ({ defaultActive, onActiveChange, id, children }: MenubarRootProps): ReactElement;
315
+ displayName: string;
316
+ };
317
+ Nav: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarNavProps> & {
318
+ ref?: import('preact').Ref<HTMLDivElement> | undefined;
319
+ }>;
320
+ Content: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarContentProps> & {
321
+ ref?: import('preact').Ref<HTMLDivElement> | undefined;
322
+ }>;
323
+ Button: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarButtonProps> & {
324
+ ref?: import('preact').Ref<HTMLButtonElement> | undefined;
325
+ }>;
326
+ Separator: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarSeparatorProps> & {
327
+ ref?: import('preact').Ref<HTMLDivElement> | undefined;
328
+ }>;
329
+ Menu: {
330
+ ({ id: providedId, disabled, children }: MenubarMenuProps): ReactElement;
331
+ displayName: string;
332
+ };
333
+ Trigger: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarTriggerProps> & {
334
+ ref?: import('preact').Ref<HTMLButtonElement> | undefined;
335
+ }>;
336
+ Portal: {
337
+ ({ container, forceMount, children }: MenubarPortalProps): ReactElement | null;
338
+ displayName: string;
339
+ };
340
+ Item: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarItemProps> & {
341
+ ref?: import('preact').Ref<HTMLDivElement> | undefined;
342
+ }>;
343
+ Label: import('preact').FunctionalComponent<import('preact/compat').PropsWithoutRef<MenubarLabelProps> & {
344
+ ref?: import('preact').Ref<HTMLDivElement> | undefined;
345
+ }>;
346
+ };
@@ -1,4 +1,10 @@
1
- export { useScrollLock } from './use-scroll-lock';
1
+ export { useActiveItemFocus, type UseActiveItemFocusConfig } from './use-active-item-focus';
2
+ export { useClickOutside, type UseClickOutsideConfig } from './use-click-outside';
2
3
  export { useControlledState } from './use-controlled-state';
4
+ export { useControlledStateWithNull } from './use-controlled-state-with-null';
5
+ export { useFloatingPosition, type FloatingPosition, type UseFloatingPositionConfig } from './use-floating-position';
3
6
  export { useItemRegistry, type ItemMetadata, type UseItemRegistryReturn } from './use-item-registry';
4
7
  export { useKeyboardNavigation, type KeyboardNavigationConfig, type UseKeyboardNavigationReturn, } from './use-keyboard-navigation';
8
+ export { useRovingTabIndex, type UseRovingTabIndexConfig, type UseRovingTabIndexReturn } from './use-roving-tabindex';
9
+ export { useScrollActiveIntoView, type UseScrollActiveIntoViewConfig } from './use-scroll-active-into-view';
10
+ export { useScrollLock } from './use-scroll-lock';
@@ -0,0 +1,83 @@
1
+ import { RefObject } from 'react';
2
+ export type UseActiveItemFocusConfig = {
3
+ /** Reference to the item element to focus */
4
+ ref: RefObject<HTMLElement> | null;
5
+ /** Whether this item is currently active */
6
+ isActive: boolean;
7
+ /** Whether this item is disabled */
8
+ disabled: boolean;
9
+ /**
10
+ * Focus mode - determines when to auto-focus:
11
+ * - 'roving-tabindex': Auto-focus when active (default)
12
+ * - 'activedescendant': Don't auto-focus (container manages focus)
13
+ */
14
+ focusMode?: 'roving-tabindex' | 'activedescendant';
15
+ /**
16
+ * When true, only focus if focus is already within the container.
17
+ * This prevents mouse hover from causing focus rings when navigating with keyboard.
18
+ *
19
+ * @example
20
+ * In Listbox, we only want to auto-focus during keyboard navigation,
21
+ * not when the user hovers with their mouse. We check if focus is
22
+ * already within the listbox to determine if keyboard nav is active.
23
+ */
24
+ checkFocusWithin?: {
25
+ /** Enable the focus-within check */
26
+ enabled: boolean;
27
+ /** ARIA role of the container to check (e.g., 'listbox', 'menu') */
28
+ containerRole: string;
29
+ };
30
+ };
31
+ /**
32
+ * Automatically focuses an element when it becomes active, typically used
33
+ * in keyboard navigation patterns like roving tabindex.
34
+ *
35
+ * This hook handles the common pattern where an item should receive DOM focus
36
+ * when it becomes the "active" item in a list, menu, or navigation structure.
37
+ * It includes safeguards to:
38
+ * - Only focus when not disabled
39
+ * - Only focus when not already focused (prevents infinite loops)
40
+ * - Optionally check if focus is within the container (prevents hover-induced focus)
41
+ * - Respect focus mode (roving-tabindex vs activedescendant)
42
+ *
43
+ * @param config - Configuration object for auto-focus behavior
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * // Basic usage (Menu item)
48
+ * function MenuItem() {
49
+ * const itemRef = useRef<HTMLDivElement>(null);
50
+ * const { active } = useMenu();
51
+ * const isActive = active === id;
52
+ *
53
+ * useActiveItemFocus({
54
+ * ref: itemRef,
55
+ * isActive,
56
+ * disabled: false,
57
+ * });
58
+ *
59
+ * return <div ref={itemRef}>Item</div>;
60
+ * }
61
+ *
62
+ * // With focus-within check (Listbox item)
63
+ * function ListboxItem() {
64
+ * const itemRef = useRef<HTMLDivElement>(null);
65
+ * const { active, focusMode } = useListbox();
66
+ * const isActive = active === id;
67
+ *
68
+ * useActiveItemFocus({
69
+ * ref: itemRef,
70
+ * isActive,
71
+ * disabled: false,
72
+ * focusMode,
73
+ * checkFocusWithin: {
74
+ * enabled: true,
75
+ * containerRole: 'listbox',
76
+ * },
77
+ * });
78
+ *
79
+ * return <div ref={itemRef}>Item</div>;
80
+ * }
81
+ * ```
82
+ */
83
+ export declare function useActiveItemFocus({ ref, isActive, disabled, focusMode, checkFocusWithin, }: UseActiveItemFocusConfig): void;
@@ -0,0 +1,99 @@
1
+ import { RefObject } from 'react';
2
+ export type UseClickOutsideConfig = {
3
+ /** Whether the click outside listener is active */
4
+ enabled: boolean;
5
+ /** Reference to the content element (clicks inside are ignored) */
6
+ contentRef: RefObject<HTMLElement>;
7
+ /**
8
+ * Optional references to elements that should be excluded from "outside" detection.
9
+ * Typically used for trigger buttons that toggle the content visibility.
10
+ */
11
+ excludeRefs?: (RefObject<HTMLElement> | undefined)[];
12
+ /** Callback when pointer down occurs outside */
13
+ onPointerDownOutside?: (event: PointerEvent) => void;
14
+ /** Callback when any interaction occurs outside */
15
+ onInteractOutside?: (event: Event) => void;
16
+ /**
17
+ * Callback to close/hide the content.
18
+ * Only called if event.defaultPrevented is false.
19
+ */
20
+ onClose?: () => void;
21
+ };
22
+ /**
23
+ * Detects clicks/pointer events outside a content element and triggers callbacks.
24
+ *
25
+ * This hook is commonly used for dismissible UI elements like dropdowns, menus,
26
+ * dialogs, and popovers that should close when the user clicks outside.
27
+ *
28
+ * Features:
29
+ * - Ignores clicks inside the content element
30
+ * - Supports excluding additional elements (e.g., trigger buttons)
31
+ * - Respects event.defaultPrevented to allow custom handling
32
+ * - Only active when enabled
33
+ * - Properly cleans up event listeners
34
+ *
35
+ * @param config - Configuration object for click outside detection
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // Basic usage (Dialog)
40
+ * function Dialog() {
41
+ * const [open, setOpen] = useState(false);
42
+ * const contentRef = useRef<HTMLDivElement>(null);
43
+ *
44
+ * useClickOutside({
45
+ * enabled: open,
46
+ * contentRef,
47
+ * onClose: () => setOpen(false),
48
+ * });
49
+ *
50
+ * if (!open) return null;
51
+ * return <div ref={contentRef}>Dialog content</div>;
52
+ * }
53
+ *
54
+ * // With trigger exclusion (Menu)
55
+ * function Menu() {
56
+ * const [open, setOpen] = useState(false);
57
+ * const triggerRef = useRef<HTMLButtonElement>(null);
58
+ * const contentRef = useRef<HTMLDivElement>(null);
59
+ *
60
+ * useClickOutside({
61
+ * enabled: open,
62
+ * contentRef,
63
+ * excludeRefs: [triggerRef],
64
+ * onPointerDownOutside: (e) => console.log('Outside click', e),
65
+ * onInteractOutside: (e) => console.log('Outside interaction', e),
66
+ * onClose: () => setOpen(false),
67
+ * });
68
+ *
69
+ * return (
70
+ * <>
71
+ * <button ref={triggerRef}>Open Menu</button>
72
+ * {open && <div ref={contentRef}>Menu content</div>}
73
+ * </>
74
+ * );
75
+ * }
76
+ *
77
+ * // With custom close prevention
78
+ * function CustomDialog() {
79
+ * const [open, setOpen] = useState(false);
80
+ * const contentRef = useRef<HTMLDivElement>(null);
81
+ *
82
+ * useClickOutside({
83
+ * enabled: open,
84
+ * contentRef,
85
+ * onPointerDownOutside: (e) => {
86
+ * const shouldClose = confirm('Close dialog?');
87
+ * if (!shouldClose) {
88
+ * e.preventDefault(); // Prevents onClose from being called
89
+ * }
90
+ * },
91
+ * onClose: () => setOpen(false),
92
+ * });
93
+ *
94
+ * if (!open) return null;
95
+ * return <div ref={contentRef}>Dialog content</div>;
96
+ * }
97
+ * ```
98
+ */
99
+ export declare function useClickOutside({ enabled, contentRef, excludeRefs, onPointerDownOutside, onInteractOutside, onClose, }: UseClickOutsideConfig): void;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A variant of `useControlledState` that handles `null` as "no value" in controlled mode.
3
+ *
4
+ * This hook provides a clean external API where `null` represents "no value" while
5
+ * internally using `undefined` for consistency with React/Preact patterns.
6
+ *
7
+ * **External API (Component Props):**
8
+ * - `undefined` = uncontrolled mode (prop not provided)
9
+ * - `null` = controlled mode with no value
10
+ * - `value` = controlled mode with value
11
+ *
12
+ * **Internal State:**
13
+ * - `undefined` = no value (converted from external `null`)
14
+ * - `value` = has value
15
+ *
16
+ * @template T - The type of the state value (typically `string`)
17
+ *
18
+ * @param controlledValue - The controlled value from props (e.g., `active`)
19
+ * @param defaultValue - The default value for uncontrolled mode
20
+ * @param onChange - Callback invoked when the value changes (receives `null` for no value)
21
+ *
22
+ * @returns A tuple containing the current value and a setter function
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * // Component with controlled "no active" support
27
+ * function Listbox({ active, defaultActive, setActive }: {
28
+ * active?: string | null;
29
+ * defaultActive?: string;
30
+ * setActive?: (active: string | null) => void;
31
+ * }) {
32
+ * const [activeInternal, setActiveInternal] = useControlledStateWithNull(
33
+ * active,
34
+ * defaultActive,
35
+ * setActive
36
+ * );
37
+ *
38
+ * // activeInternal is string | undefined internally
39
+ * // But external API uses string | null
40
+ *
41
+ * // active={null} → activeInternal = undefined
42
+ * // active="item" → activeInternal = "item"
43
+ * // active not provided → activeInternal from defaultActive
44
+ * }
45
+ * ```
46
+ */
47
+ export declare function useControlledStateWithNull<T extends string>(controlledValue: T | null | undefined, defaultValue: T | undefined, onChange?: (value: T | null) => void): [T | undefined, (value: T | null | undefined) => void];
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Manages state that can be either controlled or uncontrolled.
3
- * Follows the pattern for controlled/uncontrolled state management.
3
+ * Follows the React pattern for controlled/uncontrolled state management.
4
4
  *
5
- * @template T - The type of the state value
5
+ * @template T - The type of the state value (can include `null` for "no value" state)
6
6
  *
7
7
  * @param controlledValue - The controlled value from props (e.g., `value`, `open`, `checked`)
8
8
  * @param defaultValue - The default value for uncontrolled mode (e.g., `defaultValue`, `defaultOpen`)
@@ -10,6 +10,16 @@
10
10
  *
11
11
  * @returns A tuple containing the current value and a setter function
12
12
  *
13
+ * **Controlled vs Uncontrolled Detection:**
14
+ * - `undefined` = uncontrolled mode (prop not provided)
15
+ * - Any other value (including `null`) = controlled mode
16
+ *
17
+ * **Supporting "No Value" in Controlled Mode:**
18
+ * Use `null` to represent "no value" in controlled mode:
19
+ * - `active={null}` → controlled with no active item
20
+ * - `active="item-1"` → controlled with active item
21
+ * - `active` not provided → uncontrolled mode
22
+ *
13
23
  * @example
14
24
  * ```tsx
15
25
  * // Uncontrolled usage
@@ -23,6 +33,14 @@
23
33
  * const [value, setValue] = useControlledState(open, false, onOpenChange);
24
34
  * return <div>{value ? 'Open' : 'Closed'}</div>;
25
35
  * }
36
+ *
37
+ * // Controlled with null support (for "no value" state)
38
+ * function Component({ active, onActiveChange }: { active?: string | null }) {
39
+ * const [value, setValue] = useControlledState(active, undefined, onActiveChange);
40
+ * // active={null} → controlled, value = null
41
+ * // active="item" → controlled, value = "item"
42
+ * // active not provided → uncontrolled, value from defaultActive
43
+ * }
26
44
  * ```
27
45
  */
28
46
  export declare function useControlledState<T>(controlledValue: T | undefined, defaultValue: T, onChange?: (value: T) => void): [T, (value: T) => void];
@@ -0,0 +1,58 @@
1
+ import { RefObject } from 'react';
2
+ export type FloatingPosition = {
3
+ top: number;
4
+ left?: number;
5
+ right?: number;
6
+ };
7
+ export type UseFloatingPositionConfig = {
8
+ /** Whether the floating element is open/visible */
9
+ enabled: boolean;
10
+ /** Reference to the trigger element */
11
+ triggerRef: RefObject<HTMLElement> | null;
12
+ /** Reference to the floating content element */
13
+ contentRef: RefObject<HTMLElement> | null;
14
+ /** Horizontal alignment relative to trigger */
15
+ align?: 'start' | 'end';
16
+ };
17
+ /**
18
+ * Calculates optimal position for floating elements (menus, dropdowns) relative to a trigger,
19
+ * with automatic viewport collision detection and flip behavior.
20
+ *
21
+ * This hook handles:
22
+ * - Positioning below trigger with configurable alignment
23
+ * - Vertical flipping when overflowing bottom edge
24
+ * - Horizontal adjustment to stay within viewport bounds
25
+ * - Automatic repositioning on window resize and scroll
26
+ *
27
+ * @param config - Configuration object for positioning behavior
28
+ * @returns Position object with top and left/right coordinates, or null if not yet calculated
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * function Menu() {
33
+ * const triggerRef = useRef<HTMLButtonElement>(null);
34
+ * const contentRef = useRef<HTMLDivElement>(null);
35
+ * const position = useFloatingPosition({
36
+ * enabled: open,
37
+ * triggerRef,
38
+ * contentRef,
39
+ * align: 'start'
40
+ * });
41
+ *
42
+ * return (
43
+ * <div
44
+ * ref={contentRef}
45
+ * style={{
46
+ * position: 'fixed',
47
+ * top: position ? `${position.top}px` : '0',
48
+ * left: position?.left !== undefined ? `${position.left}px` : undefined,
49
+ * right: position?.right !== undefined ? `${position.right}px` : undefined,
50
+ * }}
51
+ * >
52
+ * Menu content
53
+ * </div>
54
+ * );
55
+ * }
56
+ * ```
57
+ */
58
+ export declare function useFloatingPosition({ enabled, triggerRef, contentRef, align, }: UseFloatingPositionConfig): FloatingPosition | null;