@mezzanine-ui/react 1.0.3 → 1.1.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.
@@ -3,6 +3,11 @@ import { DropdownOption } from '@mezzanine-ui/core/dropdown/dropdown';
3
3
  import { PopperPlacement } from '../../Popper';
4
4
  import { NativeElementPropsWithoutKeyAndRef } from '../../utils/jsx-types';
5
5
  export interface SelectButtonProps extends Omit<NativeElementPropsWithoutKeyAndRef<'button'>, 'disabled' | 'onSelect' | 'type' | 'selectedValue'> {
6
+ /**
7
+ * Whether clicking an option should automatically close the dropdown.
8
+ * @default true
9
+ */
10
+ closeOnSelect?: boolean;
6
11
  /**
7
12
  * Whether the select button is disabled.
8
13
  */
@@ -10,24 +10,24 @@ import Icon from '../../Icon/Icon.js';
10
10
  import cx from 'clsx';
11
11
 
12
12
  const SelectButton = forwardRef(function SelectButton(props, ref) {
13
- const { className, disabled, dropdownMaxHeight = 114, dropdownPlacement = 'bottom-start', dropdownWidth = 120, onSelect, options = [], size = 'main', value, ...rest } = props;
13
+ const { className, closeOnSelect = true, disabled, dropdownMaxHeight = 114, dropdownPlacement = 'bottom-start', dropdownWidth = 120, onSelect, options = [], size = 'main', value, ...rest } = props;
14
14
  const [open, setOpen] = useState(false);
15
- const handleOpen = useCallback(() => {
16
- if (!disabled) {
17
- setOpen(true);
18
- }
15
+ const handleVisibilityChange = useCallback((next) => {
16
+ if (disabled && next)
17
+ return;
18
+ setOpen(next);
19
19
  }, [disabled]);
20
- const handleClose = useCallback(() => {
21
- setOpen(false);
22
- }, []);
23
20
  const handleSelect = useCallback((option) => {
24
21
  onSelect === null || onSelect === void 0 ? void 0 : onSelect(option.id);
25
- }, [onSelect]);
22
+ if (closeOnSelect) {
23
+ setOpen(false);
24
+ }
25
+ }, [closeOnSelect, onSelect]);
26
26
  const dropdownOptions = options.map((option) => ({
27
27
  ...option,
28
28
  ...(option.id === value ? { checkSite: 'suffix' } : {}),
29
29
  }));
30
- return (jsx(Dropdown, { customWidth: dropdownWidth, disabled: disabled, maxHeight: dropdownMaxHeight, onClose: handleClose, onOpen: handleOpen, onSelect: handleSelect, options: dropdownOptions, placement: dropdownPlacement, value: value, children: jsxs("button", { ref: ref, type: "button", disabled: disabled, className: cx(inputSelectButtonClasses.host, disabled && inputSelectButtonClasses.disabled, size === 'main' ? inputSelectButtonClasses.main : inputSelectButtonClasses.sub, className), title: value, ...rest, children: [jsx("span", { className: inputSelectButtonClasses.text, children: value }), jsx(Rotate, { in: open, duration: MOTION_DURATION.fast, easing: MOTION_EASING.standard, children: jsx(Icon, { className: inputSelectButtonClasses.icon, icon: ChevronDownIcon, size: 16 }) })] }) }));
30
+ return (jsx(Dropdown, { customWidth: dropdownWidth, disabled: disabled, maxHeight: dropdownMaxHeight, onSelect: handleSelect, onVisibilityChange: handleVisibilityChange, open: open, options: dropdownOptions, placement: dropdownPlacement, value: value, children: jsxs("button", { ref: ref, type: "button", disabled: disabled, className: cx(inputSelectButtonClasses.host, disabled && inputSelectButtonClasses.disabled, size === 'main' ? inputSelectButtonClasses.main : inputSelectButtonClasses.sub, className), title: value, ...rest, children: [jsx("span", { className: inputSelectButtonClasses.text, children: value }), jsx(Rotate, { in: open, duration: MOTION_DURATION.fast, easing: MOTION_EASING.standard, children: jsx(Icon, { className: inputSelectButtonClasses.icon, icon: ChevronDownIcon, size: 16 }) })] }) }));
31
31
  });
32
32
 
33
33
  export { SelectButton as default };
@@ -0,0 +1,3 @@
1
+ export declare function getFocusableElements(container: HTMLElement | null): HTMLElement[];
2
+ export declare function getNextTabbableAfter(element: HTMLElement, skipContainer?: HTMLElement | null): HTMLElement | null;
3
+ export declare function getPreviousTabbableBefore(element: HTMLElement): HTMLElement | null;
@@ -0,0 +1,70 @@
1
+ const FOCUSABLE_SELECTOR = [
2
+ 'a[href]',
3
+ 'area[href]',
4
+ 'button:not([disabled])',
5
+ 'input:not([disabled]):not([type="hidden"])',
6
+ 'select:not([disabled])',
7
+ 'textarea:not([disabled])',
8
+ 'iframe',
9
+ 'object',
10
+ 'embed',
11
+ 'audio[controls]',
12
+ 'video[controls]',
13
+ '[contenteditable]:not([contenteditable="false"])',
14
+ '[tabindex]:not([tabindex="-1"])',
15
+ ].join(',');
16
+ function isVisible(element) {
17
+ var _a, _b;
18
+ if (element.hidden)
19
+ return false;
20
+ const style = (_b = (_a = element.ownerDocument) === null || _a === void 0 ? void 0 : _a.defaultView) === null || _b === void 0 ? void 0 : _b.getComputedStyle(element);
21
+ if (!style)
22
+ return true;
23
+ return style.visibility !== 'hidden' && style.display !== 'none';
24
+ }
25
+ function getFocusableElements(container) {
26
+ if (!container)
27
+ return [];
28
+ const nodes = container.querySelectorAll(FOCUSABLE_SELECTOR);
29
+ const result = [];
30
+ for (const node of Array.from(nodes)) {
31
+ if (node.getAttribute('aria-hidden') === 'true')
32
+ continue;
33
+ if (!isVisible(node))
34
+ continue;
35
+ result.push(node);
36
+ }
37
+ return result;
38
+ }
39
+ function getNextTabbableAfter(element, skipContainer) {
40
+ const doc = element.ownerDocument;
41
+ const all = getFocusableElements(doc.body);
42
+ for (const node of all) {
43
+ if (node === element || element.contains(node))
44
+ continue;
45
+ if (skipContainer &&
46
+ (node === skipContainer || skipContainer.contains(node)))
47
+ continue;
48
+ const position = element.compareDocumentPosition(node);
49
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
50
+ return node;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ function getPreviousTabbableBefore(element) {
56
+ const doc = element.ownerDocument;
57
+ const all = getFocusableElements(doc.body);
58
+ let previous = null;
59
+ for (const node of all) {
60
+ if (node === element || element.contains(node))
61
+ continue;
62
+ const position = element.compareDocumentPosition(node);
63
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) {
64
+ previous = node;
65
+ }
66
+ }
67
+ return previous;
68
+ }
69
+
70
+ export { getFocusableElements, getNextTabbableAfter, getPreviousTabbableBefore };
@@ -1,27 +1,141 @@
1
+ import { useRef } from 'react';
1
2
  import { useClickAway } from '../hooks/useClickAway.js';
2
3
  import { useDocumentEscapeKeyDown } from '../hooks/useDocumentEscapeKeyDown.js';
3
- import { useTabKeyClose } from './useTabKeyClose.js';
4
+ import { useDocumentTabKeyDown } from '../hooks/useDocumentTabKeyDown.js';
5
+ import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect.js';
6
+ import { getFocusableElements, getNextTabbableAfter, getPreviousTabbableBefore } from './getFocusableElements.js';
4
7
 
5
8
  function usePickerDocumentEventClose({ anchorRef, lastElementRefInFlow, onClose, onChangeClose, open, popperRef, }) {
6
- useClickAway(() => {
7
- if (!open) {
9
+ /**
10
+ * Mirror the latest values into refs so the document-level event handlers
11
+ * (which are installed once and only re-installed when deps change) always
12
+ * read the freshest props rather than a closure captured at install time.
13
+ */
14
+ const openRef = useRef(open);
15
+ const onCloseRef = useRef(onClose);
16
+ const onChangeCloseRef = useRef(onChangeClose);
17
+ useIsomorphicLayoutEffect(() => {
18
+ openRef.current = open;
19
+ onCloseRef.current = onClose;
20
+ onChangeCloseRef.current = onChangeClose;
21
+ });
22
+ useClickAway(() => (event) => {
23
+ var _a;
24
+ if (!openRef.current)
8
25
  return;
26
+ if (!((_a = popperRef.current) === null || _a === void 0 ? void 0 : _a.contains(event.target))) {
27
+ onChangeCloseRef.current();
9
28
  }
10
- return (event) => {
11
- var _a;
12
- if (!((_a = popperRef.current) === null || _a === void 0 ? void 0 : _a.contains(event.target))) {
13
- onChangeClose();
14
- }
15
- };
16
- }, anchorRef, [open, onClose]);
17
- /** Close popper when escape key down */
29
+ }, anchorRef, []);
30
+ /**
31
+ * Close popper on Escape and return focus to the trigger input so the
32
+ * user does not lose their place in the page tab order.
33
+ */
18
34
  useDocumentEscapeKeyDown(() => () => {
19
- if (open) {
20
- onClose();
35
+ var _a;
36
+ if (!openRef.current)
37
+ return;
38
+ onCloseRef.current();
39
+ const popper = popperRef.current;
40
+ const active = document.activeElement;
41
+ if (popper && active && popper.contains(active)) {
42
+ (_a = lastElementRefInFlow.current) === null || _a === void 0 ? void 0 : _a.focus();
21
43
  }
22
- }, [open, onClose]);
23
- /** Close popper when tab key down */
24
- useTabKeyClose(onChangeClose, lastElementRefInFlow, [onChangeClose]);
44
+ }, [lastElementRefInFlow, popperRef]);
45
+ /**
46
+ * Keyboard navigation across the trigger input and the portalled popper.
47
+ *
48
+ * The popper is rendered into document.body via Portal, so the natural
49
+ * Tab order skips it entirely. We bridge the trigger and the popper as
50
+ * a logical sequence:
51
+ *
52
+ * - Tab from trigger input → first focusable inside popper (handled by
53
+ * the direct trigger listener below, which uses `stopPropagation` so
54
+ * this document-level handler does not run again for that case)
55
+ * - Shift+Tab from trigger → close popper (same)
56
+ * - Tab from last popper → close + focus next tab stop after anchor
57
+ * - Shift+Tab from first → return focus to trigger input
58
+ *
59
+ * Other Tabs inside the popper fall through to the browser's default
60
+ * focus traversal, so users can walk through calendar buttons, footer
61
+ * actions, etc. with the regular keyboard.
62
+ */
63
+ useDocumentTabKeyDown(() => (event) => {
64
+ var _a;
65
+ if (!openRef.current)
66
+ return;
67
+ const popper = popperRef.current;
68
+ const anchor = anchorRef.current;
69
+ const trigger = lastElementRefInFlow.current;
70
+ if (!popper || !anchor)
71
+ return;
72
+ const active = document.activeElement;
73
+ if (!active || !popper.contains(active))
74
+ return;
75
+ const focusables = getFocusableElements(popper);
76
+ if (focusables.length === 0)
77
+ return;
78
+ const first = focusables[0];
79
+ const last = focusables[focusables.length - 1];
80
+ if (!event.shiftKey && active === last) {
81
+ event.preventDefault();
82
+ onChangeCloseRef.current();
83
+ const next = getNextTabbableAfter(anchor, popper);
84
+ if (next) {
85
+ next.focus();
86
+ }
87
+ else {
88
+ trigger === null || trigger === void 0 ? void 0 : trigger.blur();
89
+ }
90
+ return;
91
+ }
92
+ if (event.shiftKey && active === first) {
93
+ event.preventDefault();
94
+ if (trigger) {
95
+ trigger.focus();
96
+ }
97
+ else {
98
+ onChangeCloseRef.current();
99
+ (_a = getPreviousTabbableBefore(anchor)) === null || _a === void 0 ? void 0 : _a.focus();
100
+ }
101
+ }
102
+ }, [anchorRef, popperRef, lastElementRefInFlow]);
103
+ /**
104
+ * Direct keydown listener on the trigger element for the Tab → popper
105
+ * bridge. Binding directly on the trigger (instead of the document) is
106
+ * more reliable: it still fires when the picker lives inside a Modal or
107
+ * focus trap that stops keydown propagation before it reaches document.
108
+ */
109
+ useIsomorphicLayoutEffect(() => {
110
+ const trigger = lastElementRefInFlow.current;
111
+ if (!trigger)
112
+ return;
113
+ const handleKeyDown = (event) => {
114
+ if (event.key !== 'Tab')
115
+ return;
116
+ if (!openRef.current)
117
+ return;
118
+ const popper = popperRef.current;
119
+ if (!popper)
120
+ return;
121
+ if (event.shiftKey) {
122
+ onChangeCloseRef.current();
123
+ return;
124
+ }
125
+ const focusables = getFocusableElements(popper);
126
+ if (focusables.length === 0) {
127
+ onChangeCloseRef.current();
128
+ return;
129
+ }
130
+ event.preventDefault();
131
+ event.stopPropagation();
132
+ focusables[0].focus();
133
+ };
134
+ trigger.addEventListener('keydown', handleKeyDown);
135
+ return () => {
136
+ trigger.removeEventListener('keydown', handleKeyDown);
137
+ };
138
+ }, [lastElementRefInFlow, popperRef]);
25
139
  }
26
140
 
27
141
  export { usePickerDocumentEventClose };
package/Table/Table.js CHANGED
@@ -16,6 +16,7 @@ import { useTableScroll } from './hooks/useTableScroll.js';
16
16
  import { useTableSelection } from './hooks/useTableSelection.js';
17
17
  import { useTableSorting } from './hooks/useTableSorting.js';
18
18
  import { getNumericCSSVariablePixelValue } from '../utils/get-css-variable-value.js';
19
+ import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect.js';
19
20
  import { spacingPrefix } from '@mezzanine-ui/system/spacing';
20
21
  import TableBulkActions from './components/TableBulkActions.js';
21
22
  import { useComposeRefs } from '../hooks/useComposeRefs.js';
@@ -33,27 +34,31 @@ function TableInner(props, ref) {
33
34
  }))
34
35
  : dataSource;
35
36
  /** Feature: Row Height Preset */
36
- const rowHeight = useMemo(() => {
37
+ const rowHeightVariableName = useMemo(() => {
37
38
  switch (rowHeightPreset) {
38
39
  case 'condensed':
39
40
  return size === 'main'
40
- ? getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-condensed`)
41
- : getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-reduced`);
41
+ ? `--${spacingPrefix}-size-container-condensed`
42
+ : `--${spacingPrefix}-size-container-reduced`;
42
43
  case 'detailed':
43
44
  return size === 'main'
44
- ? getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-tiny`)
45
- : getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-tightened`);
45
+ ? `--${spacingPrefix}-size-container-tiny`
46
+ : `--${spacingPrefix}-size-container-tightened`;
46
47
  case 'roomy':
47
48
  return size === 'main'
48
- ? getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-small`)
49
- : getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-medium`);
49
+ ? `--${spacingPrefix}-size-container-small`
50
+ : `--${spacingPrefix}-size-container-medium`;
50
51
  case 'base':
51
52
  default:
52
53
  return size === 'main'
53
- ? getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-minimized`)
54
- : getNumericCSSVariablePixelValue(`--${spacingPrefix}-size-container-minimal`);
54
+ ? `--${spacingPrefix}-size-container-minimized`
55
+ : `--${spacingPrefix}-size-container-minimal`;
55
56
  }
56
57
  }, [rowHeightPreset, size]);
58
+ const [rowHeight, setRowHeight] = useState(undefined);
59
+ useIsomorphicLayoutEffect(() => {
60
+ setRowHeight(getNumericCSSVariablePixelValue(rowHeightVariableName));
61
+ }, [rowHeightVariableName]);
57
62
  /** Feature: Highlight */
58
63
  const [hoveredRowIndex, setHoveredRowIndex] = useState(null);
59
64
  const [hoveredColumnIndex, setHoveredColumnIndex] = useState(null);
@@ -60,7 +60,7 @@ export interface TableContextValue<T extends TableDataSource = TableDataSource>
60
60
  pinnable?: TablePinnable;
61
61
  resizable?: boolean;
62
62
  rowState?: TableRowState | ((rowData: TableDataSource) => TableRowState | undefined);
63
- rowHeight: number;
63
+ rowHeight: number | undefined;
64
64
  scroll?: TableScroll;
65
65
  scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
66
66
  selection?: TableSelectionState<T>;
@@ -45,7 +45,7 @@ const TableRowInner = forwardRef(function TableRow(props, ref) {
45
45
  const resolvedStyle = useMemo(() => ({
46
46
  ...style,
47
47
  ...draggableProvided === null || draggableProvided === void 0 ? void 0 : draggableProvided.draggableProps.style,
48
- height: rowHeight,
48
+ ...(rowHeight !== undefined && { height: rowHeight }),
49
49
  }), [style, rowHeight, draggableProvided === null || draggableProvided === void 0 ? void 0 : draggableProvided.draggableProps.style]);
50
50
  const { containerWidth, getResizedColumnWidth, scrollLeft } = useTableSuperContext();
51
51
  const rowKey = useMemo(() => getRowKey(record), [record]);
@@ -16,7 +16,7 @@ function useTableVirtualization({ dataSource, enabled = true, isContainerReady =
16
16
  if (measuredHeight !== undefined) {
17
17
  return measuredHeight;
18
18
  }
19
- return rowHeight;
19
+ return rowHeight !== null && rowHeight !== void 0 ? rowHeight : 0;
20
20
  }, [dataSource, rowHeight]);
21
21
  const getItemKey = useCallback((index) => {
22
22
  const record = dataSource[index];
@@ -1,6 +1,6 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { inputTriggerPopperClasses } from '@mezzanine-ui/core/_internal/input-trigger-popper';
3
- import { offset, size } from '@floating-ui/react-dom';
3
+ import { offset, flip, size } from '@floating-ui/react-dom';
4
4
  import { forwardRef } from 'react';
5
5
  import Fade from '../../Transition/Fade.js';
6
6
  import Popper from '../../Popper/Popper.js';
@@ -20,13 +20,14 @@ const sameWidthMiddleware = size({
20
20
  const InputTriggerPopper = forwardRef(function InputTriggerPopper(props, ref) {
21
21
  const { anchor, children, className, fadeProps, open, options, sameWidth, ...restPopperProps } = props;
22
22
  const { middleware = [], ...restPopperOptions } = options || {};
23
- return (jsx(Fade, { ...fadeProps, in: open, ref: ref, children: jsx(Popper, { ...restPopperProps, open: true, anchor: anchor, className: cx(inputTriggerPopperClasses.host, className), disablePortal: true,
23
+ return (jsx(Fade, { ...fadeProps, in: open, ref: ref, children: jsx(Popper, { ...restPopperProps, open: true, anchor: anchor, className: cx(inputTriggerPopperClasses.host, className),
24
24
  /** Prevent event bubble (Because popper may use portal, then click away function would be buggy) */
25
25
  onClick: (e) => e.stopPropagation(), onTouchStart: (e) => e.stopPropagation(), onTouchMove: (e) => e.stopPropagation(), onTouchEnd: (e) => e.stopPropagation(), options: {
26
26
  placement: 'bottom-start',
27
27
  ...restPopperOptions,
28
28
  middleware: [
29
29
  offset({ mainAxis: 4 }),
30
+ flip({ fallbackAxisSideDirection: 'end', padding: 8 }),
30
31
  ...(sameWidth ? [sameWidthMiddleware] : []),
31
32
  ...middleware,
32
33
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mezzanine-ui/react",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "React components for mezzanine-ui",
5
5
  "author": "Mezzanine",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "@floating-ui/dom": "^1.7.4",
33
33
  "@floating-ui/react-dom": "^2.1.6",
34
34
  "@hello-pangea/dnd": "^18.0.1",
35
- "@mezzanine-ui/core": "1.0.3",
35
+ "@mezzanine-ui/core": "1.0.4",
36
36
  "@mezzanine-ui/icons": "1.0.2",
37
37
  "@mezzanine-ui/system": "1.0.2",
38
38
  "@tanstack/react-virtual": "^3.13.13",