@enonic/ui 0.16.0 → 0.18.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,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,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;
@@ -0,0 +1,69 @@
1
+ export type UseRovingTabIndexConfig = {
2
+ /** The ID of this item */
3
+ id: string;
4
+ /** Currently active item ID (undefined if no item is active) */
5
+ active: string | undefined;
6
+ /** Whether this item is disabled */
7
+ disabled: boolean;
8
+ /** Function to get all registered item IDs */
9
+ getItems: () => string[];
10
+ /** Function to check if a specific item is disabled */
11
+ isItemDisabled: (id: string) => boolean;
12
+ /**
13
+ * Focus mode for the component
14
+ * - 'roving-tabindex': Items are individually focusable (default)
15
+ * - 'activedescendant': Container manages focus via aria-activedescendant
16
+ */
17
+ focusMode?: 'roving-tabindex' | 'activedescendant';
18
+ };
19
+ export type UseRovingTabIndexReturn = {
20
+ /** Whether this item should be focusable (tabIndex=0) */
21
+ isFocusable: boolean;
22
+ /** The tabIndex value to apply to this item */
23
+ tabIndex: number;
24
+ };
25
+ /**
26
+ * Implements roving tabindex pattern for keyboard navigation.
27
+ *
28
+ * In roving tabindex, only one item in a list is focusable at a time (tabIndex=0),
29
+ * while all other items have tabIndex=-1. This allows users to:
30
+ * - Tab into the list (focuses the active or first available item)
31
+ * - Use arrow keys to navigate within the list
32
+ * - Tab out of the list
33
+ *
34
+ * The hook automatically determines which item should be focusable based on:
35
+ * 1. The currently active item (if set and not disabled)
36
+ * 2. The first non-disabled item (fallback)
37
+ * 3. The first item in the list (if all items are disabled)
38
+ * 4. This item itself (final fallback if no other items exist)
39
+ *
40
+ * @param config - Configuration object for roving tabindex behavior
41
+ * @returns Object containing isFocusable flag and tabIndex value
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * function MenuItem({ id, disabled }: MenuItemProps) {
46
+ * const { active, getItems, isItemDisabled } = useMenu();
47
+ * const { isFocusable, tabIndex } = useRovingTabIndex({
48
+ * id,
49
+ * active,
50
+ * disabled,
51
+ * getItems,
52
+ * isItemDisabled,
53
+ * });
54
+ *
55
+ * return (
56
+ * <div
57
+ * role="menuitem"
58
+ * tabIndex={tabIndex}
59
+ * data-focusable={isFocusable || undefined}
60
+ * >
61
+ * {children}
62
+ * </div>
63
+ * );
64
+ * }
65
+ * ```
66
+ *
67
+ * @see {@link https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex}
68
+ */
69
+ export declare function useRovingTabIndex({ id, active, disabled, getItems, isItemDisabled, focusMode, }: UseRovingTabIndexConfig): UseRovingTabIndexReturn;
@@ -0,0 +1,83 @@
1
+ import { RefObject } from 'react';
2
+ export type UseScrollActiveIntoViewConfig = {
3
+ /** Reference to the container element */
4
+ containerRef: RefObject<HTMLElement> | null;
5
+ /** ID of the currently active item (undefined if no active item) */
6
+ activeId: string | undefined;
7
+ /**
8
+ * Scroll orientation:
9
+ * - 'vertical': Only scroll vertically (block: 'nearest')
10
+ * - 'horizontal': Scroll both horizontally and vertically (block + inline: 'nearest')
11
+ */
12
+ orientation?: 'vertical' | 'horizontal';
13
+ /**
14
+ * Optional function to construct the element ID from the active ID.
15
+ * If not provided, uses activeId directly.
16
+ *
17
+ * @example
18
+ * // For Listbox, which prefixes IDs:
19
+ * buildElementId: (id) => `${baseId}-listbox-option-${id}`
20
+ */
21
+ buildElementId?: (activeId: string) => string;
22
+ };
23
+ /**
24
+ * Automatically scrolls the active item into view within a scrollable container.
25
+ *
26
+ * This hook is commonly used in keyboard-navigable lists, menus, and navigation
27
+ * components to ensure the currently active item is visible when it changes.
28
+ *
29
+ * Features:
30
+ * - Smooth scrolling behavior with 'nearest' algorithm (minimal scroll distance)
31
+ * - Supports both vertical and horizontal scrolling
32
+ * - Supports custom ID construction for prefixed/namespaced IDs
33
+ * - Only scrolls when activeId changes
34
+ *
35
+ * @param config - Configuration object for scroll behavior
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // Vertical list (Menu)
40
+ * function MenuContent() {
41
+ * const contentRef = useRef<HTMLDivElement>(null);
42
+ * const { active } = useMenu();
43
+ *
44
+ * useScrollActiveIntoView({
45
+ * containerRef: contentRef,
46
+ * activeId: active,
47
+ * orientation: 'vertical',
48
+ * });
49
+ *
50
+ * return <div ref={contentRef}>...</div>;
51
+ * }
52
+ *
53
+ * // Horizontal navigation (Menubar)
54
+ * function Menubar() {
55
+ * const menubarRef = useRef<HTMLDivElement>(null);
56
+ * const { active } = useMenubar();
57
+ *
58
+ * useScrollActiveIntoView({
59
+ * containerRef: menubarRef,
60
+ * activeId: active,
61
+ * orientation: 'horizontal',
62
+ * });
63
+ *
64
+ * return <div ref={menubarRef}>...</div>;
65
+ * }
66
+ *
67
+ * // With ID prefix (Listbox)
68
+ * function ListboxContent({ baseId }: { baseId: string }) {
69
+ * const contentRef = useRef<HTMLDivElement>(null);
70
+ * const { active } = useListbox();
71
+ *
72
+ * useScrollActiveIntoView({
73
+ * containerRef: contentRef,
74
+ * activeId: active,
75
+ * orientation: 'vertical',
76
+ * buildElementId: (id) => `${baseId}-listbox-option-${id}`,
77
+ * });
78
+ *
79
+ * return <div ref={contentRef}>...</div>;
80
+ * }
81
+ * ```
82
+ */
83
+ export declare function useScrollActiveIntoView({ containerRef, activeId, orientation, buildElementId, }: UseScrollActiveIntoViewConfig): void;
@@ -9,7 +9,12 @@ export type ListboxContextValue = {
9
9
  selection: ReadonlySet<string>;
10
10
  selectionMode: 'single' | 'multiple';
11
11
  disabled?: boolean;
12
- focusable?: boolean;
12
+ /**
13
+ * Focus management mode:
14
+ * - 'roving-tabindex': Items are individually focusable (default)
15
+ * - 'activedescendant': Container manages focus via aria-activedescendant
16
+ */
17
+ focusMode?: 'roving-tabindex' | 'activedescendant';
13
18
  /**
14
19
  * Set active item.
15
20
  * Accepts `null` for compatibility with controlled prop API, but converts it to `undefined` internally.
@@ -23,9 +23,7 @@ export type MenubarContextValue = {
23
23
  isItemDisabled: (id: string) => boolean;
24
24
  /**
25
25
  * ID of the currently open menu (if any).
26
- * Used in Phase 2 to track which menu is expanded in the menubar.
27
- *
28
- * @phase2
26
+ * Tracks which menu is expanded in the menubar for conditional hover behavior.
29
27
  */
30
28
  openMenuId: string | undefined;
31
29
  setOpenMenuId: (id: string | undefined) => void;
@@ -67,9 +65,8 @@ export declare const useMenubar: () => MenubarContextValue;
67
65
  * Hook to optionally access menubar context.
68
66
  * Returns undefined if not within a MenubarProvider.
69
67
  *
70
- * Useful for components that can work both inside and outside a menubar (e.g., Menu.Trigger).
68
+ * Useful for components that can work both inside and outside a menubar.
71
69
  *
72
- * @phase2 Used by Menu components to detect if they're within a Menubar
73
70
  * @returns {MenubarContextValue | undefined} The menubar context or undefined
74
71
  */
75
72
  export declare const useMenubarOptional: () => MenubarContextValue | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enonic/ui",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Enonic UI Component Library",
5
5
  "author": "Enonic",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "class-variance-authority": "~0.7.1",
48
48
  "clsx": "~2.1.1",
49
- "tailwind-merge": "~3.3.1"
49
+ "tailwind-merge": "~3.4.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@radix-ui/react-slot": "^1.2.0",
@@ -68,44 +68,44 @@
68
68
  }
69
69
  },
70
70
  "devDependencies": {
71
- "@chromatic-com/storybook": "~4.1.1",
72
- "@eslint/js": "~9.37.0",
71
+ "@chromatic-com/storybook": "~4.1.2",
72
+ "@eslint/js": "~9.39.1",
73
73
  "@preact/preset-vite": "~2.10.2",
74
74
  "@size-limit/file": "~11.2.0",
75
75
  "@size-limit/preset-small-lib": "~11.2.0",
76
- "@storybook/addon-a11y": "~9.1.10",
77
- "@storybook/addon-docs": "~9.1.10",
78
- "@storybook/addon-links": "~9.1.10",
79
- "@storybook/addon-themes": "~9.1.10",
80
- "@storybook/preact-vite": "~9.1.10",
81
- "@tailwindcss/vite": "~4.1.14",
82
- "@trivago/prettier-plugin-sort-imports": "~5.2.2",
83
- "@types/node": "~24.7.2",
84
- "@types/react": "~19.2.2",
85
- "@types/react-dom": "~19.2.1",
86
- "@typescript-eslint/eslint-plugin": "~8.46.0",
87
- "@typescript-eslint/parser": "~8.46.0",
88
- "eslint": "~9.37.0",
76
+ "@storybook/addon-a11y": "~10.0.7",
77
+ "@storybook/addon-docs": "~10.0.7",
78
+ "@storybook/addon-links": "~10.0.7",
79
+ "@storybook/addon-themes": "~10.0.7",
80
+ "@storybook/preact-vite": "~10.0.7",
81
+ "@tailwindcss/vite": "~4.1.17",
82
+ "@trivago/prettier-plugin-sort-imports": "~6.0.0",
83
+ "@types/node": "~24.10.0",
84
+ "@types/react": "~19.2.3",
85
+ "@types/react-dom": "~19.2.2",
86
+ "@typescript-eslint/eslint-plugin": "~8.46.4",
87
+ "@typescript-eslint/parser": "~8.46.4",
88
+ "eslint": "~9.39.1",
89
89
  "eslint-import-resolver-typescript": "~4.4.4",
90
90
  "eslint-plugin-import": "~2.32.0",
91
91
  "eslint-plugin-jsx-a11y": "~6.10.2",
92
92
  "eslint-plugin-react": "~7.37.5",
93
- "eslint-plugin-react-hooks": "~7.0.0",
93
+ "eslint-plugin-react-hooks": "~7.0.1",
94
94
  "husky": "~9.1.7",
95
- "lint-staged": "~16.2.4",
96
- "lucide-preact": "~0.545.0",
97
- "lucide-react": "~0.545.0",
95
+ "lint-staged": "~16.2.6",
96
+ "lucide-preact": "~0.553.0",
97
+ "lucide-react": "~0.553.0",
98
98
  "postcss": "~8.5.6",
99
99
  "prettier": "~3.6.2",
100
- "rollup-plugin-visualizer": "~6.0.4",
100
+ "rollup-plugin-visualizer": "~6.0.5",
101
101
  "size-limit": "~11.2.0",
102
- "storybook": "~9.1.10",
103
- "tailwindcss": "~4.1.14",
104
- "terser": "~5.44.0",
102
+ "storybook": "~10.0.7",
103
+ "tailwindcss": "~4.1.17",
104
+ "terser": "~5.44.1",
105
105
  "tw-animate-css": "~1.4.0",
106
106
  "typescript": "~5.9.3",
107
- "typescript-eslint": "~8.46.0",
108
- "vite": "~7.1.9",
107
+ "typescript-eslint": "~8.46.4",
108
+ "vite": "~7.2.2",
109
109
  "vite-plugin-dts": "~4.5.4",
110
110
  "vite-plugin-environment": "~1.1.3"
111
111
  },