colibri-ui 0.8.94 → 0.8.95

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.
@@ -1,6 +1,7 @@
1
1
  .root {
2
2
  bottom: 0;
3
3
  height: 100%;
4
+ outline: none;
4
5
  position: fixed;
5
6
  top: 0;
6
7
  width: 100%;
package/Modal/index.js CHANGED
@@ -32,7 +32,7 @@ const ModalRoot = ({
32
32
  title,
33
33
  withinParent = false
34
34
  }) => {
35
- const modalRef = React.useRef(null);
35
+ const [dialog, setDialog] = React.useState(null);
36
36
  const [container, setContainer] = React.useState(null);
37
37
  const parentModal = React.useContext(context.ModalContext);
38
38
  const parentContainer = withinParent ? parentModal === null || parentModal === void 0 ? void 0 : parentModal.container : undefined;
@@ -42,7 +42,7 @@ const ModalRoot = ({
42
42
  const [titleCount, setTitleCount] = React.useState(0);
43
43
  const isTopmost = index$5.useModalStack(titleId);
44
44
  index$6.useScrollLock();
45
- index$7.useTrapFocus(modalRef, isTopmost);
45
+ index$7.useTrapFocus(dialog, isTopmost);
46
46
  const handleClose = React.useCallback(() => {
47
47
  onClose === null || onClose === void 0 ? void 0 : onClose();
48
48
  }, [onClose]);
@@ -50,10 +50,6 @@ const ModalRoot = ({
50
50
  setTitleCount(count => count + 1);
51
51
  return () => setTitleCount(count => count - 1);
52
52
  }, []);
53
- const setWrapperRef = React.useCallback(node => {
54
- modalRef.current = node;
55
- setContainer(node);
56
- }, []);
57
53
  const contextValue = React.useMemo(() => ({
58
54
  onClose: handleClose,
59
55
  titleId,
@@ -99,8 +95,10 @@ const ModalRoot = ({
99
95
  className: clsx__default.default(styles__default.default.root, {
100
96
  [styles__default.default.root_contained]: isContained
101
97
  }),
98
+ ref: setDialog,
102
99
  role: "dialog",
103
100
  "aria-modal": "true",
101
+ tabIndex: -1,
104
102
  "aria-labelledby": titleCount > 0 ? titleId : undefined
105
103
  }, /*#__PURE__*/React__default.default.createElement("div", {
106
104
  className: styles__default.default["modal-overlay"],
@@ -112,7 +110,7 @@ const ModalRoot = ({
112
110
  [styles__default.default["modal-wrapper_mobile"]]: !isDesktop,
113
111
  [styles__default.default["modal-wrapper_contained"]]: isContained
114
112
  }, className),
115
- ref: setWrapperRef
113
+ ref: setContainer
116
114
  }, content))));
117
115
  };
118
116
  const Modal = Object.assign(ModalRoot, {
@@ -1,6 +1,7 @@
1
1
  .root {
2
2
  bottom: 0;
3
3
  height: 100%;
4
+ outline: none;
4
5
  position: fixed;
5
6
  top: 0;
6
7
  width: 100%;
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState, useContext, useId, useCallback, useMemo, useEffect, Children, isValidElement } from 'react';
1
+ import React, { useState, useContext, useId, useCallback, useMemo, useEffect, Children, isValidElement } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useScrollLock } from '../hooks/useScrollLock/index.js';
4
4
  import { useTrapFocus } from '../hooks/useTrapFocus/index.js';
@@ -24,7 +24,7 @@ const ModalRoot = ({
24
24
  title,
25
25
  withinParent = false
26
26
  }) => {
27
- const modalRef = useRef(null);
27
+ const [dialog, setDialog] = useState(null);
28
28
  const [container, setContainer] = useState(null);
29
29
  const parentModal = useContext(ModalContext);
30
30
  const parentContainer = withinParent ? parentModal === null || parentModal === void 0 ? void 0 : parentModal.container : undefined;
@@ -34,7 +34,7 @@ const ModalRoot = ({
34
34
  const [titleCount, setTitleCount] = useState(0);
35
35
  const isTopmost = useModalStack(titleId);
36
36
  useScrollLock();
37
- useTrapFocus(modalRef, isTopmost);
37
+ useTrapFocus(dialog, isTopmost);
38
38
  const handleClose = useCallback(() => {
39
39
  onClose === null || onClose === void 0 ? void 0 : onClose();
40
40
  }, [onClose]);
@@ -42,10 +42,6 @@ const ModalRoot = ({
42
42
  setTitleCount(count => count + 1);
43
43
  return () => setTitleCount(count => count - 1);
44
44
  }, []);
45
- const setWrapperRef = useCallback(node => {
46
- modalRef.current = node;
47
- setContainer(node);
48
- }, []);
49
45
  const contextValue = useMemo(() => ({
50
46
  onClose: handleClose,
51
47
  titleId,
@@ -91,8 +87,10 @@ const ModalRoot = ({
91
87
  className: clsx(styles.root, {
92
88
  [styles.root_contained]: isContained
93
89
  }),
90
+ ref: setDialog,
94
91
  role: "dialog",
95
92
  "aria-modal": "true",
93
+ tabIndex: -1,
96
94
  "aria-labelledby": titleCount > 0 ? titleId : undefined
97
95
  }, /*#__PURE__*/React.createElement("div", {
98
96
  className: styles["modal-overlay"],
@@ -104,7 +102,7 @@ const ModalRoot = ({
104
102
  [styles["modal-wrapper_mobile"]]: !isDesktop,
105
103
  [styles["modal-wrapper_contained"]]: isContained
106
104
  }, className),
107
- ref: setWrapperRef
105
+ ref: setContainer
108
106
  }, content))));
109
107
  };
110
108
  const Modal = Object.assign(ModalRoot, {
@@ -1,2 +1,7 @@
1
- import type { RefObject } from "react";
2
- export declare function useTrapFocus(refObject: RefObject<HTMLElement | null>, isActive?: () => boolean): void;
1
+ /**
2
+ * Moves focus into `element` when it mounts (first focusable element, or the
3
+ * element itself, which needs tabIndex={-1}), keeps Tab inside it while
4
+ * `isActive` returns true, and returns focus to the previously focused
5
+ * element when it unmounts.
6
+ */
7
+ export declare function useTrapFocus(element: HTMLElement | null, isActive?: () => boolean): void;
@@ -1,15 +1,70 @@
1
- import { useEffect } from 'react';
1
+ import { useRef, useEffect } from 'react';
2
2
 
3
- const getFocusableModalElements = element => element.querySelectorAll('a[href], button, textarea, input[type="text"], input[type="password"], input[type="radio"], input[type="checkbox"], select');
4
- function useTrapFocus(refObject, isActive) {
3
+ const candidateSelector = ["a[href]", "button", "textarea", 'input:not([type="hidden"])', "select", "[tabindex]", '[role="button"]'].join(",");
4
+ // The selector only finds candidates: `[tabindex]` also matches disabled
5
+ // controls and `[role="button"]` alone does not make an element focusable.
6
+ const isTabbable = node => node.tabIndex >= 0 && !node.matches(":disabled");
7
+ const getFocusableElements = element => Array.from(element.querySelectorAll(candidateSelector)).filter(isTabbable);
8
+ // Open traps in mount order, so a closing trap can hand its saved focus to
9
+ // traps opened on top of it.
10
+ const focusStack = [];
11
+ /**
12
+ * Moves focus into `element` when it mounts (first focusable element, or the
13
+ * element itself, which needs tabIndex={-1}), keeps Tab inside it while
14
+ * `isActive` returns true, and returns focus to the previously focused
15
+ * element when it unmounts.
16
+ */
17
+ function useTrapFocus(element, isActive) {
18
+ // Read during render, before children with autoFocus take focus.
19
+ const previousFocusRef = useRef(null);
20
+ if (!element && typeof document !== "undefined") {
21
+ previousFocusRef.current = document.activeElement;
22
+ }
5
23
  useEffect(() => {
24
+ var _a;
25
+ if (!element) return;
26
+ const entry = {
27
+ element,
28
+ previousFocus: previousFocusRef.current
29
+ };
30
+ focusStack.push(entry);
31
+ if (!element.contains(document.activeElement)) {
32
+ ((_a = getFocusableElements(element)[0]) !== null && _a !== void 0 ? _a : element).focus();
33
+ }
34
+ return () => {
35
+ focusStack.splice(focusStack.indexOf(entry), 1);
36
+ // Traps opened from inside this one would restore focus to a removed
37
+ // element; give them the focus this trap saved instead.
38
+ focusStack.forEach(other => {
39
+ if (element.contains(other.previousFocus)) {
40
+ other.previousFocus = entry.previousFocus;
41
+ }
42
+ });
43
+ // Focus inside another trap stays there. Focus removed with this
44
+ // element falls back to body.
45
+ const active = document.activeElement;
46
+ const ownsFocus = !active || active === document.body || element.contains(active);
47
+ const {
48
+ previousFocus
49
+ } = entry;
50
+ if (ownsFocus && previousFocus instanceof HTMLElement && previousFocus.isConnected) {
51
+ previousFocus.focus();
52
+ }
53
+ };
54
+ }, [element]);
55
+ useEffect(() => {
56
+ if (!element) return;
6
57
  const handler = event => {
7
- if (!refObject.current || event.code !== "Tab") return;
58
+ if (event.key !== "Tab") return;
8
59
  if (isActive && !isActive()) return;
9
- const focusable = Array.from(getFocusableModalElements(refObject.current));
60
+ const focusable = getFocusableElements(element);
10
61
  const firstElement = focusable[0];
11
62
  const lastElement = focusable[focusable.length - 1];
12
- if (!lastElement || !firstElement) return;
63
+ if (!lastElement || !firstElement) {
64
+ event.preventDefault();
65
+ element.focus();
66
+ return;
67
+ }
13
68
  const focusOutside = document.activeElement ? !focusable.includes(document.activeElement) : true;
14
69
  if (event.shiftKey && (document.activeElement === firstElement || focusOutside)) {
15
70
  lastElement.focus();
@@ -23,7 +78,7 @@ function useTrapFocus(refObject, isActive) {
23
78
  return () => {
24
79
  document.removeEventListener("keydown", handler);
25
80
  };
26
- }, [refObject, isActive]);
81
+ }, [element, isActive]);
27
82
  }
28
83
 
29
84
  export { useTrapFocus };
@@ -1,2 +1,7 @@
1
- import type { RefObject } from "react";
2
- export declare function useTrapFocus(refObject: RefObject<HTMLElement | null>, isActive?: () => boolean): void;
1
+ /**
2
+ * Moves focus into `element` when it mounts (first focusable element, or the
3
+ * element itself, which needs tabIndex={-1}), keeps Tab inside it while
4
+ * `isActive` returns true, and returns focus to the previously focused
5
+ * element when it unmounts.
6
+ */
7
+ export declare function useTrapFocus(element: HTMLElement | null, isActive?: () => boolean): void;
@@ -2,16 +2,71 @@
2
2
 
3
3
  var React = require('react');
4
4
 
5
- const getFocusableModalElements = element => element.querySelectorAll('a[href], button, textarea, input[type="text"], input[type="password"], input[type="radio"], input[type="checkbox"], select');
6
- function useTrapFocus(refObject, isActive) {
5
+ const candidateSelector = ["a[href]", "button", "textarea", 'input:not([type="hidden"])', "select", "[tabindex]", '[role="button"]'].join(",");
6
+ // The selector only finds candidates: `[tabindex]` also matches disabled
7
+ // controls and `[role="button"]` alone does not make an element focusable.
8
+ const isTabbable = node => node.tabIndex >= 0 && !node.matches(":disabled");
9
+ const getFocusableElements = element => Array.from(element.querySelectorAll(candidateSelector)).filter(isTabbable);
10
+ // Open traps in mount order, so a closing trap can hand its saved focus to
11
+ // traps opened on top of it.
12
+ const focusStack = [];
13
+ /**
14
+ * Moves focus into `element` when it mounts (first focusable element, or the
15
+ * element itself, which needs tabIndex={-1}), keeps Tab inside it while
16
+ * `isActive` returns true, and returns focus to the previously focused
17
+ * element when it unmounts.
18
+ */
19
+ function useTrapFocus(element, isActive) {
20
+ // Read during render, before children with autoFocus take focus.
21
+ const previousFocusRef = React.useRef(null);
22
+ if (!element && typeof document !== "undefined") {
23
+ previousFocusRef.current = document.activeElement;
24
+ }
7
25
  React.useEffect(() => {
26
+ var _a;
27
+ if (!element) return;
28
+ const entry = {
29
+ element,
30
+ previousFocus: previousFocusRef.current
31
+ };
32
+ focusStack.push(entry);
33
+ if (!element.contains(document.activeElement)) {
34
+ ((_a = getFocusableElements(element)[0]) !== null && _a !== void 0 ? _a : element).focus();
35
+ }
36
+ return () => {
37
+ focusStack.splice(focusStack.indexOf(entry), 1);
38
+ // Traps opened from inside this one would restore focus to a removed
39
+ // element; give them the focus this trap saved instead.
40
+ focusStack.forEach(other => {
41
+ if (element.contains(other.previousFocus)) {
42
+ other.previousFocus = entry.previousFocus;
43
+ }
44
+ });
45
+ // Focus inside another trap stays there. Focus removed with this
46
+ // element falls back to body.
47
+ const active = document.activeElement;
48
+ const ownsFocus = !active || active === document.body || element.contains(active);
49
+ const {
50
+ previousFocus
51
+ } = entry;
52
+ if (ownsFocus && previousFocus instanceof HTMLElement && previousFocus.isConnected) {
53
+ previousFocus.focus();
54
+ }
55
+ };
56
+ }, [element]);
57
+ React.useEffect(() => {
58
+ if (!element) return;
8
59
  const handler = event => {
9
- if (!refObject.current || event.code !== "Tab") return;
60
+ if (event.key !== "Tab") return;
10
61
  if (isActive && !isActive()) return;
11
- const focusable = Array.from(getFocusableModalElements(refObject.current));
62
+ const focusable = getFocusableElements(element);
12
63
  const firstElement = focusable[0];
13
64
  const lastElement = focusable[focusable.length - 1];
14
- if (!lastElement || !firstElement) return;
65
+ if (!lastElement || !firstElement) {
66
+ event.preventDefault();
67
+ element.focus();
68
+ return;
69
+ }
15
70
  const focusOutside = document.activeElement ? !focusable.includes(document.activeElement) : true;
16
71
  if (event.shiftKey && (document.activeElement === firstElement || focusOutside)) {
17
72
  lastElement.focus();
@@ -25,7 +80,7 @@ function useTrapFocus(refObject, isActive) {
25
80
  return () => {
26
81
  document.removeEventListener("keydown", handler);
27
82
  };
28
- }, [refObject, isActive]);
83
+ }, [element, isActive]);
29
84
  }
30
85
 
31
86
  exports.useTrapFocus = useTrapFocus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "colibri-ui",
3
- "version": "0.8.94",
3
+ "version": "0.8.95",
4
4
  "description": "React UI components library",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",