@-reddacted-/react-ui 0.3.0 → 0.4.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.
Files changed (137) hide show
  1. package/dist/components/Accordion.d.ts +27 -0
  2. package/dist/components/Accordion.d.ts.map +1 -0
  3. package/dist/components/Accordion.js +35 -0
  4. package/dist/components/Badge.d.ts +15 -0
  5. package/dist/components/Badge.d.ts.map +1 -0
  6. package/dist/components/Badge.js +7 -0
  7. package/dist/components/Button.d.ts +15 -2
  8. package/dist/components/Button.d.ts.map +1 -1
  9. package/dist/components/Button.js +76 -44
  10. package/dist/components/Card.d.ts +21 -0
  11. package/dist/components/Card.d.ts.map +1 -0
  12. package/dist/components/Card.js +17 -0
  13. package/dist/components/ColorField.d.ts.map +1 -1
  14. package/dist/components/ColorField.js +1 -1
  15. package/dist/components/ConfirmDeleteButton.d.ts +4 -1
  16. package/dist/components/ConfirmDeleteButton.d.ts.map +1 -1
  17. package/dist/components/ConfirmDeleteButton.js +6 -7
  18. package/dist/components/Drawer.d.ts +33 -0
  19. package/dist/components/Drawer.d.ts.map +1 -0
  20. package/dist/components/Drawer.js +123 -0
  21. package/dist/components/DropdownMenu.d.ts +61 -0
  22. package/dist/components/DropdownMenu.d.ts.map +1 -0
  23. package/dist/components/DropdownMenu.js +302 -0
  24. package/dist/components/Modal.d.ts.map +1 -1
  25. package/dist/components/Modal.js +102 -134
  26. package/dist/components/NavBar.d.ts +11 -14
  27. package/dist/components/NavBar.d.ts.map +1 -1
  28. package/dist/components/NavBar.js +53 -31
  29. package/dist/components/PageStatus.d.ts +12 -12
  30. package/dist/components/PageStatus.d.ts.map +1 -1
  31. package/dist/components/PageStatus.js +9 -29
  32. package/dist/components/Pagination.d.ts +44 -0
  33. package/dist/components/Pagination.d.ts.map +1 -0
  34. package/dist/components/Pagination.js +57 -0
  35. package/dist/components/Skeleton.d.ts +13 -0
  36. package/dist/components/Skeleton.d.ts.map +1 -0
  37. package/dist/components/Skeleton.js +15 -0
  38. package/dist/components/Spinner.d.ts +14 -0
  39. package/dist/components/Spinner.d.ts.map +1 -0
  40. package/dist/components/Spinner.js +14 -0
  41. package/dist/components/Stack.d.ts +13 -0
  42. package/dist/components/Stack.d.ts.map +1 -0
  43. package/dist/components/Stack.js +15 -0
  44. package/dist/components/Table.d.ts +54 -0
  45. package/dist/components/Table.d.ts.map +1 -0
  46. package/dist/components/Table.js +101 -0
  47. package/dist/components/Tabs.d.ts +34 -0
  48. package/dist/components/Tabs.d.ts.map +1 -0
  49. package/dist/components/Tabs.js +82 -0
  50. package/dist/components/Tag.d.ts +10 -0
  51. package/dist/components/Tag.d.ts.map +1 -0
  52. package/dist/components/Tag.js +7 -0
  53. package/dist/components/Tooltip.d.ts +22 -0
  54. package/dist/components/Tooltip.d.ts.map +1 -0
  55. package/dist/components/Tooltip.js +76 -0
  56. package/dist/components/drawerStore.d.ts +11 -0
  57. package/dist/components/drawerStore.d.ts.map +1 -0
  58. package/dist/components/drawerStore.js +40 -0
  59. package/dist/components/paginationItems.d.ts +18 -0
  60. package/dist/components/paginationItems.d.ts.map +1 -0
  61. package/dist/components/paginationItems.js +32 -0
  62. package/dist/index.d.ts +23 -7
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +16 -2
  65. package/dist/popup/PopupHost.d.ts +19 -3
  66. package/dist/popup/PopupHost.d.ts.map +1 -1
  67. package/dist/popup/PopupHost.js +65 -44
  68. package/dist/popup/PopupProvider.d.ts +19 -2
  69. package/dist/popup/PopupProvider.d.ts.map +1 -1
  70. package/dist/popup/PopupProvider.js +69 -17
  71. package/dist/popup/usePopup.d.ts +11 -4
  72. package/dist/popup/usePopup.d.ts.map +1 -1
  73. package/dist/style/accordionCss.d.ts +2 -0
  74. package/dist/style/accordionCss.d.ts.map +1 -0
  75. package/dist/style/accordionCss.js +101 -0
  76. package/dist/style/badgeCss.d.ts +5 -0
  77. package/dist/style/badgeCss.d.ts.map +1 -0
  78. package/dist/style/badgeCss.js +68 -0
  79. package/dist/style/breadcrumbCss.d.ts +2 -0
  80. package/dist/style/breadcrumbCss.d.ts.map +1 -0
  81. package/dist/style/breadcrumbCss.js +53 -0
  82. package/dist/style/cardCss.d.ts +2 -0
  83. package/dist/style/cardCss.d.ts.map +1 -0
  84. package/dist/style/cardCss.js +49 -0
  85. package/dist/style/inputCss.d.ts +2 -0
  86. package/dist/style/inputCss.d.ts.map +1 -0
  87. package/dist/style/inputCss.js +107 -0
  88. package/dist/style/loaderCss.d.ts +2 -0
  89. package/dist/style/loaderCss.d.ts.map +1 -0
  90. package/dist/style/loaderCss.js +39 -0
  91. package/dist/style/menuCss.d.ts +2 -0
  92. package/dist/style/menuCss.d.ts.map +1 -0
  93. package/dist/style/menuCss.js +71 -0
  94. package/dist/style/paginationCss.d.ts +2 -0
  95. package/dist/style/paginationCss.d.ts.map +1 -0
  96. package/dist/style/paginationCss.js +57 -0
  97. package/dist/style/resolveColor.d.ts +8 -0
  98. package/dist/style/resolveColor.d.ts.map +1 -0
  99. package/dist/style/resolveColor.js +13 -0
  100. package/dist/style/stackCss.d.ts +2 -0
  101. package/dist/style/stackCss.d.ts.map +1 -0
  102. package/dist/style/stackCss.js +21 -0
  103. package/dist/style/statusCss.d.ts +2 -0
  104. package/dist/style/statusCss.d.ts.map +1 -0
  105. package/dist/style/statusCss.js +38 -0
  106. package/dist/style/tableCss.d.ts +2 -0
  107. package/dist/style/tableCss.d.ts.map +1 -0
  108. package/dist/style/tableCss.js +92 -0
  109. package/dist/style/tabsCss.d.ts +2 -0
  110. package/dist/style/tabsCss.d.ts.map +1 -0
  111. package/dist/style/tabsCss.js +72 -0
  112. package/dist/style/toLength.d.ts +4 -0
  113. package/dist/style/toLength.d.ts.map +1 -0
  114. package/dist/style/toLength.js +3 -0
  115. package/dist/style/tooltipCss.d.ts +2 -0
  116. package/dist/style/tooltipCss.d.ts.map +1 -0
  117. package/dist/style/tooltipCss.js +44 -0
  118. package/dist/style/useStyle.d.ts +7 -0
  119. package/dist/style/useStyle.d.ts.map +1 -0
  120. package/dist/style/useStyle.js +16 -0
  121. package/dist/theme/ThemeProvider.d.ts.map +1 -1
  122. package/dist/theme/ThemeProvider.js +32 -0
  123. package/dist/theme/palette.d.ts.map +1 -1
  124. package/dist/theme/palette.js +0 -2
  125. package/dist/tooltip/attributeTooltips.d.ts +7 -0
  126. package/dist/tooltip/attributeTooltips.d.ts.map +1 -0
  127. package/dist/tooltip/attributeTooltips.js +76 -0
  128. package/dist/tooltip/tooltipEngine.d.ts +41 -0
  129. package/dist/tooltip/tooltipEngine.d.ts.map +1 -0
  130. package/dist/tooltip/tooltipEngine.js +128 -0
  131. package/dist/utils/childRef.d.ts +5 -0
  132. package/dist/utils/childRef.d.ts.map +1 -0
  133. package/dist/utils/childRef.js +14 -0
  134. package/dist/utils/isDev.d.ts +3 -0
  135. package/dist/utils/isDev.d.ts.map +1 -0
  136. package/dist/utils/isDev.js +4 -0
  137. package/package.json +5 -4
@@ -0,0 +1,302 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
5
+ import { useEscapeKey, useOverlayStack } from "@-reddacted-/react-hooks";
6
+ import { menuCss } from "../style/menuCss.js";
7
+ import { resolveColor } from "../style/resolveColor.js";
8
+ import { toLength } from "../style/toLength.js";
9
+ import { useStyle } from "../style/useStyle.js";
10
+ import { assignRef, getElementRef } from "../utils/childRef.js";
11
+ const MenuContext = createContext(null);
12
+ function useMenuContext(part) {
13
+ const ctx = useContext(MenuContext);
14
+ if (!ctx)
15
+ throw new Error(`DropdownMenu.${part} must be used within DropdownMenu`);
16
+ return ctx;
17
+ }
18
+ const ITEM_SELECTOR = '[role^="menuitem"]:not([aria-disabled="true"])';
19
+ const TYPEAHEAD_RESET = 500;
20
+ function menuItems(content) {
21
+ return content ? Array.from(content.querySelectorAll(ITEM_SELECTOR)) : [];
22
+ }
23
+ function focusItem(content, intent) {
24
+ const items = menuItems(content);
25
+ if (intent === "first")
26
+ items[0]?.focus();
27
+ else if (intent === "last")
28
+ items[items.length - 1]?.focus();
29
+ }
30
+ export function DropdownMenu({ trigger = "click", placement = "bottom-start", open: controlledOpen, defaultOpen = false, onOpenChange, closeDelay = 200, color = "--color-primary", children, }) {
31
+ useStyle("menu", menuCss);
32
+ const baseId = useId();
33
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
34
+ const controlled = controlledOpen !== undefined;
35
+ const open = controlled ? controlledOpen : uncontrolledOpen;
36
+ const triggerRef = useRef(null);
37
+ const contentRef = useRef(null);
38
+ const focusIntent = useRef("none");
39
+ const closeTimer = useRef();
40
+ const onOpenChangeRef = useRef(onOpenChange);
41
+ onOpenChangeRef.current = onOpenChange;
42
+ const openRef = useRef(open);
43
+ openRef.current = open;
44
+ const setOpen = useCallback((next, options = {}) => {
45
+ clearTimeout(closeTimer.current);
46
+ if (next)
47
+ focusIntent.current = options.focus ?? "none";
48
+ if (!next && options.returnFocus)
49
+ triggerRef.current?.focus();
50
+ if (next === openRef.current) {
51
+ // Already open: still honour a keyboard request to move into the menu.
52
+ if (next)
53
+ focusItem(contentRef.current, focusIntent.current);
54
+ return;
55
+ }
56
+ if (!controlled)
57
+ setUncontrolledOpen(next);
58
+ onOpenChangeRef.current?.(next);
59
+ }, [controlled]);
60
+ const cancelClose = useCallback(() => clearTimeout(closeTimer.current), []);
61
+ const scheduleClose = useCallback(() => {
62
+ clearTimeout(closeTimer.current);
63
+ closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
64
+ }, [setOpen, closeDelay]);
65
+ useEffect(() => () => clearTimeout(closeTimer.current), []);
66
+ const value = {
67
+ open,
68
+ setOpen,
69
+ focusIntent,
70
+ triggerRef,
71
+ contentRef,
72
+ menuId: `${baseId}-menu`,
73
+ triggerId: `${baseId}-trigger`,
74
+ trigger,
75
+ placement,
76
+ color,
77
+ scheduleClose,
78
+ cancelClose,
79
+ };
80
+ return _jsx(MenuContext.Provider, { value: value, children: children });
81
+ }
82
+ /** Wraps one element of the project (e.g. a Button), which must accept a ref. */
83
+ function Trigger({ children }) {
84
+ const ctx = useMenuContext("Trigger");
85
+ const child = Children.only(children);
86
+ const childProps = child.props;
87
+ const childRef = getElementRef(child);
88
+ const lastPointerType = useRef("mouse");
89
+ const setRef = useCallback((node) => {
90
+ ctx.triggerRef.current = node;
91
+ assignRef(childRef, node);
92
+ }, [ctx.triggerRef, childRef]);
93
+ const hoverMode = ctx.trigger === "hover";
94
+ return cloneElement(child, {
95
+ ref: setRef,
96
+ id: childProps.id ?? ctx.triggerId,
97
+ "aria-haspopup": "menu",
98
+ "aria-expanded": ctx.open,
99
+ "aria-controls": ctx.open ? ctx.menuId : undefined,
100
+ onPointerDown: (event) => {
101
+ childProps.onPointerDown?.(event);
102
+ lastPointerType.current = event.pointerType;
103
+ },
104
+ onClick: (event) => {
105
+ childProps.onClick?.(event);
106
+ if (event.defaultPrevented)
107
+ return;
108
+ // Hover mode on a mouse: the pointer already opened it, a click keeps it open.
109
+ if (hoverMode && lastPointerType.current !== "touch")
110
+ ctx.setOpen(true);
111
+ else
112
+ ctx.setOpen(!ctx.open, { focus: "first" });
113
+ },
114
+ onKeyDown: (event) => {
115
+ childProps.onKeyDown?.(event);
116
+ if (event.defaultPrevented)
117
+ return;
118
+ // A button turns Enter / Space into a click (handled above); other elements need it here.
119
+ const activates = (event.key === "Enter" || event.key === " ") && event.currentTarget.tagName !== "BUTTON";
120
+ if (event.key === "ArrowDown" || activates) {
121
+ event.preventDefault();
122
+ ctx.setOpen(true, { focus: "first" });
123
+ }
124
+ else if (event.key === "ArrowUp") {
125
+ event.preventDefault();
126
+ ctx.setOpen(true, { focus: "last" });
127
+ }
128
+ },
129
+ onPointerEnter: (event) => {
130
+ childProps.onPointerEnter?.(event);
131
+ if (hoverMode && event.pointerType !== "touch")
132
+ ctx.setOpen(true);
133
+ },
134
+ onPointerLeave: (event) => {
135
+ childProps.onPointerLeave?.(event);
136
+ if (hoverMode && event.pointerType !== "touch")
137
+ ctx.scheduleClose();
138
+ },
139
+ });
140
+ }
141
+ function Content({ minWidth, className, style, onKeyDown, children, ...props }) {
142
+ const ctx = useMenuContext("Content");
143
+ const { open, setOpen, contentRef, triggerRef } = ctx;
144
+ const { isTop, zIndex } = useOverlayStack(open);
145
+ const [triggerWidth, setTriggerWidth] = useState();
146
+ const typeahead = useRef({ text: "", timer: undefined });
147
+ const closeAndReturn = useCallback(() => setOpen(false, { returnFocus: true }), [setOpen]);
148
+ useEscapeKey(closeAndReturn, open && isTop);
149
+ // Position, then move the focus in as asked by the opening gesture.
150
+ useLayoutEffect(() => {
151
+ const trigger = triggerRef.current;
152
+ const content = contentRef.current;
153
+ if (!open || !trigger || !content)
154
+ return;
155
+ setTriggerWidth(trigger.offsetWidth);
156
+ focusItem(content, ctx.focusIntent.current);
157
+ return autoUpdate(trigger, content, () => {
158
+ void computePosition(trigger, content, {
159
+ strategy: "fixed",
160
+ placement: ctx.placement,
161
+ middleware: [offset(4), flip(), shift({ padding: 8 })],
162
+ }).then(({ x, y, placement }) => {
163
+ content.style.left = `${x}px`;
164
+ content.style.top = `${y}px`;
165
+ content.dataset.placement = placement;
166
+ });
167
+ });
168
+ }, [open, triggerRef, contentRef, ctx.focusIntent, ctx.placement]);
169
+ // Pressing outside the menu and its trigger closes it, leaving the focus where it goes.
170
+ useEffect(() => {
171
+ if (!open)
172
+ return;
173
+ const onPointerDown = (event) => {
174
+ const target = event.target;
175
+ if (!contentRef.current?.contains(target) && !triggerRef.current?.contains(target))
176
+ setOpen(false);
177
+ };
178
+ document.addEventListener("pointerdown", onPointerDown, true);
179
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
180
+ }, [open, setOpen, contentRef, triggerRef]);
181
+ useEffect(() => () => clearTimeout(typeahead.current.timer), []);
182
+ if (!open)
183
+ return null;
184
+ const handleKeyDown = (event) => {
185
+ onKeyDown?.(event);
186
+ if (event.defaultPrevented)
187
+ return;
188
+ const items = menuItems(contentRef.current);
189
+ const index = items.indexOf(document.activeElement);
190
+ const move = (next) => {
191
+ event.preventDefault();
192
+ items[next]?.focus();
193
+ };
194
+ switch (event.key) {
195
+ case "ArrowDown":
196
+ return move(index === -1 ? 0 : (index + 1) % items.length);
197
+ case "ArrowUp":
198
+ return move(index <= 0 ? items.length - 1 : index - 1);
199
+ case "Home":
200
+ return move(0);
201
+ case "End":
202
+ return move(items.length - 1);
203
+ case "Tab":
204
+ event.preventDefault();
205
+ return closeAndReturn();
206
+ case "Enter":
207
+ case " ":
208
+ if (index !== -1) {
209
+ event.preventDefault();
210
+ items[index].click();
211
+ }
212
+ return;
213
+ }
214
+ // Typeahead: jump to the next item starting with the typed letters.
215
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey)
216
+ return;
217
+ const state = typeahead.current;
218
+ clearTimeout(state.timer);
219
+ state.text += event.key.toLowerCase();
220
+ state.timer = setTimeout(() => (state.text = ""), TYPEAHEAD_RESET);
221
+ const ordered = [...items.slice(index + 1), ...items.slice(0, index + 1)];
222
+ const match = ordered.find(item => (item.dataset.textValue ?? item.textContent ?? "").trim().toLowerCase().startsWith(state.text));
223
+ match?.focus();
224
+ };
225
+ const hoverMode = ctx.trigger === "hover";
226
+ return createPortal(_jsx("div", { role: "menu", id: ctx.menuId, "aria-labelledby": triggerRef.current?.id, tabIndex: -1, ...props, ref: contentRef, className: className ? `rui-menu ${className}` : "rui-menu", style: {
227
+ zIndex,
228
+ minWidth: toLength(minWidth) ?? triggerWidth,
229
+ "--rui-menu-accent": resolveColor(ctx.color),
230
+ ...style,
231
+ }, onKeyDown: handleKeyDown, onPointerEnter: hoverMode ? ctx.cancelClose : undefined, onPointerLeave: hoverMode ? ctx.scheduleClose : undefined, children: children }), document.body);
232
+ }
233
+ /** Shared behaviour of every item: role, focus on hover, activation, closing. */
234
+ function useItemProps(role, { disabled = false, closeOnSelect = true, textValue }, onActivate, checked) {
235
+ const ctx = useMenuContext("Item");
236
+ return {
237
+ role,
238
+ tabIndex: -1,
239
+ "aria-disabled": disabled || undefined,
240
+ "aria-checked": checked,
241
+ "data-text-value": textValue,
242
+ onPointerMove: (event) => {
243
+ if (!disabled && document.activeElement !== event.currentTarget)
244
+ event.currentTarget.focus();
245
+ },
246
+ onClick: (event) => {
247
+ if (disabled) {
248
+ event.preventDefault();
249
+ return;
250
+ }
251
+ onActivate(event);
252
+ if (closeOnSelect && !event.defaultPrevented)
253
+ ctx.setOpen(false, { returnFocus: true });
254
+ },
255
+ };
256
+ }
257
+ function Item({ disabled, closeOnSelect, textValue, onSelect, asChild, className, children, ...props }) {
258
+ const itemProps = useItemProps("menuitem", { disabled, closeOnSelect, textValue }, event => onSelect?.(event));
259
+ if (asChild && isValidElement(children)) {
260
+ const childProps = children.props;
261
+ return cloneElement(children, {
262
+ ...itemProps,
263
+ className: [childProps.className, "rui-menu-item", className].filter(Boolean).join(" "),
264
+ onClick: (event) => {
265
+ childProps.onClick?.(event);
266
+ itemProps.onClick(event);
267
+ },
268
+ });
269
+ }
270
+ return (_jsx("div", { ...props, ...itemProps, className: className ? `rui-menu-item ${className}` : "rui-menu-item", children: children }));
271
+ }
272
+ function CheckboxItem({ disabled, closeOnSelect, textValue, checked, onCheckedChange, className, children, ...props }) {
273
+ const itemProps = useItemProps("menuitemcheckbox", { disabled, closeOnSelect, textValue }, () => onCheckedChange(!checked), checked);
274
+ return (_jsxs("div", { ...props, ...itemProps, className: className ? `rui-menu-item ${className}` : "rui-menu-item", children: [_jsx("span", { className: "rui-menu-indicator", "aria-hidden": "true", children: checked ? "✓" : "" }), children] }));
275
+ }
276
+ const RadioGroupContext = createContext(null);
277
+ function RadioGroup({ value, onValueChange, ...props }) {
278
+ return (_jsx(RadioGroupContext.Provider, { value: { value, onValueChange }, children: _jsx("div", { role: "group", ...props }) }));
279
+ }
280
+ function RadioItem({ value, disabled, closeOnSelect, textValue, className, children, ...props }) {
281
+ const group = useContext(RadioGroupContext);
282
+ if (!group)
283
+ throw new Error("DropdownMenu.RadioItem must be used within DropdownMenu.RadioGroup");
284
+ const checked = group.value === value;
285
+ const itemProps = useItemProps("menuitemradio", { disabled, closeOnSelect, textValue }, () => group.onValueChange(value), checked);
286
+ return (_jsxs("div", { ...props, ...itemProps, className: className ? `rui-menu-item ${className}` : "rui-menu-item", children: [_jsx("span", { className: "rui-menu-indicator", "aria-hidden": "true", children: checked ? "●" : "" }), children] }));
287
+ }
288
+ /** Group title, not focusable. */
289
+ function Label({ className, ...props }) {
290
+ return _jsx("div", { role: "presentation", ...props, className: className ? `rui-menu-label ${className}` : "rui-menu-label" });
291
+ }
292
+ function Separator({ className, ...props }) {
293
+ return (_jsx("div", { role: "separator", ...props, className: className ? `rui-menu-separator ${className}` : "rui-menu-separator" }));
294
+ }
295
+ DropdownMenu.Trigger = Trigger;
296
+ DropdownMenu.Content = Content;
297
+ DropdownMenu.Item = Item;
298
+ DropdownMenu.CheckboxItem = CheckboxItem;
299
+ DropdownMenu.RadioGroup = RadioGroup;
300
+ DropdownMenu.RadioItem = RadioItem;
301
+ DropdownMenu.Label = Label;
302
+ DropdownMenu.Separator = Separator;
@@ -1 +1 @@
1
- {"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../src/components/Modal.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAgBrD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,QAGnC;AAED,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,QAEpC;AAID,KAAK,UAAU,GAAG;IAChB,EAAE,EAAE,MAAM,CAAA;IACV,0EAA0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,QAAQ,EAAE,SAAS,CAAA;IACnB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,aAAa,CAAA;IACrB,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAED,MAAM,CAAC,OAAO,UAAU,KAAK,CAAC,EAC5B,EAAE,EACF,IAAI,EACJ,OAAO,EACP,KAAK,EACL,MAAM,EACN,QAAQ,EACR,KAAK,EACL,MAAM,EACN,UAAoB,EACpB,SAAS,EACT,KAAK,EACL,YAAoB,EACpB,cAAc,EACd,eAAe,GAChB,EAAE,UAAU,sCAkMZ"}
1
+ {"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../src/components/Modal.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAkBrD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,QAGnC;AAED,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,QAEpC;AAED,KAAK,UAAU,GAAG;IAChB,EAAE,EAAE,MAAM,CAAA;IACV,0EAA0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACxB,QAAQ,EAAE,SAAS,CAAA;IACnB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,aAAa,CAAA;IACrB,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAED,MAAM,CAAC,OAAO,UAAU,KAAK,CAAC,EAC5B,EAAE,EACF,IAAI,EACJ,OAAO,EACP,KAAK,EACL,MAAM,EACN,QAAQ,EACR,KAAK,EACL,MAAM,EACN,UAAoB,EACpB,SAAS,EACT,KAAK,EACL,YAAoB,EACpB,cAAc,EACd,eAAe,GAChB,EAAE,UAAU,sCA+FZ"}
@@ -1,6 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { createPortal } from "react-dom";
4
+ import { useEscapeKey, useFocusTrap, useOverlayStack, useScrollLock } from "@-reddacted-/react-hooks";
5
+ import { useStyle } from "../style/useStyle.js";
4
6
  const listeners = new Map();
5
7
  function on(event, cb) {
6
8
  if (!listeners.has(event))
@@ -18,11 +20,9 @@ export function openModal(id) {
18
20
  export function closeModal(id) {
19
21
  emit("modal:close", id);
20
22
  }
21
- const FOCUSABLE = `a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])`;
22
23
  export default function Modal({ id, open, onClose, width, height, children, title, footer, closeLabel = "Close", className, style, preventClose = false, ariaLabelledBy, ariaDescribedBy, }) {
23
24
  const [busOpen, setBusOpen] = useState(false);
24
25
  const contentRef = useRef(null);
25
- const lastFocusedEl = useRef(null);
26
26
  const portalTarget = useRef(null);
27
27
  const controlled = open !== undefined;
28
28
  const isOpen = controlled ? open : busOpen;
@@ -36,10 +36,8 @@ export default function Modal({ id, open, onClose, width, height, children, titl
36
36
  return;
37
37
  const setIsOpen = setBusOpen;
38
38
  const offOpen = on("modal:open", (targetId) => {
39
- if (targetId === id) {
40
- lastFocusedEl.current = document.activeElement ?? null;
39
+ if (targetId === id)
41
40
  setIsOpen(true);
42
- }
43
41
  });
44
42
  const offClose = on("modal:close", (targetId) => {
45
43
  if (targetId === id)
@@ -61,135 +59,105 @@ export default function Modal({ id, open, onClose, width, height, children, titl
61
59
  }
62
60
  portalTarget.current = el;
63
61
  }, []);
64
- useEffect(() => {
65
- if (!isOpen)
66
- return;
67
- const prevOverflow = document.body.style.overflow;
68
- const prevPadRight = document.body.style.paddingRight;
69
- const scrollbarW = window.innerWidth - document.documentElement.clientWidth;
70
- if (scrollbarW > 0)
71
- document.body.style.paddingRight = `${scrollbarW}px`;
72
- document.body.style.overflow = "hidden";
73
- const to = setTimeout(() => {
74
- const container = contentRef.current;
75
- if (!container)
76
- return;
77
- const focusables = container.querySelectorAll(FOCUSABLE);
78
- (focusables[0] ?? container).focus();
79
- }, 0);
80
- const onKeyDown = (e) => {
81
- if (e.key === "Escape" && !preventClose) {
82
- dismissRef.current();
83
- return;
84
- }
85
- if (e.key !== "Tab")
86
- return;
87
- const container = contentRef.current;
88
- if (!container)
89
- return;
90
- const focusables = Array.from(container.querySelectorAll(FOCUSABLE)).filter(el => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"));
91
- if (focusables.length === 0)
92
- return;
93
- const first = focusables[0];
94
- const last = focusables[focusables.length - 1];
95
- const active = document.activeElement;
96
- if (e.shiftKey) {
97
- if (active === first || !container.contains(active)) {
98
- last.focus();
99
- e.preventDefault();
100
- }
101
- }
102
- else {
103
- if (active === last || !container.contains(active)) {
104
- first.focus();
105
- e.preventDefault();
106
- }
107
- }
108
- };
109
- const onFocusIn = (e) => {
110
- const container = contentRef.current;
111
- if (!container)
112
- return;
113
- if (!container.contains(e.target))
114
- container.focus();
115
- };
116
- document.addEventListener("keydown", onKeyDown);
117
- document.addEventListener("focusin", onFocusIn);
118
- return () => {
119
- document.body.style.overflow = prevOverflow;
120
- document.body.style.paddingRight = prevPadRight;
121
- clearTimeout(to);
122
- document.removeEventListener("keydown", onKeyDown);
123
- document.removeEventListener("focusin", onFocusIn);
124
- lastFocusedEl.current?.focus?.();
125
- lastFocusedEl.current = null;
126
- };
127
- }, [isOpen, preventClose]);
62
+ useStyle("modal", modalCss);
63
+ const { isTop, zIndex } = useOverlayStack(isOpen);
64
+ useScrollLock(isOpen);
65
+ // Paused while a menu or drawer is open above: focus may leave the modal for it.
66
+ useFocusTrap(contentRef, isOpen, !isTop);
67
+ // Only the overlay on top of the stack closes on Escape.
68
+ const onEscape = useCallback(() => dismissRef.current(), []);
69
+ useEscapeKey(onEscape, isOpen && isTop && !preventClose);
128
70
  if (!isOpen || !portalTarget.current)
129
71
  return null;
130
- const w = typeof width === "number" ? `${width}px` : (width ?? "min(540px, 95vw)");
131
- const h = typeof height === "number" ? `${height}px` : height;
132
- const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
133
- const scrimStyle = {
134
- position: "fixed",
135
- inset: 0,
136
- zIndex: 9999,
137
- display: "grid",
138
- placeItems: "center",
139
- padding: "1rem",
140
- backdropFilter: "brightness(0.45) blur(2px)",
141
- WebkitBackdropFilter: "brightness(0.45) blur(2px)",
142
- animation: prefersReducedMotion ? "none" : "modalFade 150ms ease-out",
143
- };
144
72
  const cardStyle = {
145
- width: w,
146
- height: h,
147
- maxWidth: "95vw",
148
- maxHeight: "85vh",
149
- display: "flex",
150
- flexDirection: "column",
151
- overflow: "hidden",
152
- outline: "none",
153
- backgroundColor: "var(--color-surface)",
154
- border: "1px solid var(--color-border)",
155
- borderRadius: "12px",
156
- animation: prefersReducedMotion ? "none" : "modalPop 160ms ease-out",
73
+ width: typeof width === "number" ? `${width}px` : (width ?? "min(540px, 95vw)"),
74
+ height: typeof height === "number" ? `${height}px` : height,
75
+ ...style,
157
76
  };
158
- const keyframes = (_jsx("style", { children: `
159
- @keyframes modalFade { from { opacity: 0 } to { opacity: 1 } }
160
- @keyframes modalPop {
161
- from { opacity: 0; transform: translateY(4px) scale(0.98) }
162
- to { opacity: 1; transform: translateY(0) scale(1) }
163
- }
164
- ` }));
165
77
  const framed = title !== undefined || footer !== undefined;
166
- return createPortal(_jsxs(_Fragment, { children: [keyframes, _jsx("div", { style: scrimStyle, onMouseDown: e => {
167
- if (!preventClose && e.target === e.currentTarget)
168
- dismissRef.current();
169
- }, children: _jsxs("div", { ref: contentRef, role: "dialog", "aria-modal": "true", "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, tabIndex: -1, className: className, style: { ...cardStyle, ...style }, onMouseDown: e => e.stopPropagation(), children: [title !== undefined && (_jsxs("div", { style: headerStyle, children: [_jsx("span", { style: titleStyle, children: title }), _jsx("button", { type: "button", style: closeStyle, onClick: dismiss, "aria-label": closeLabel, children: "\u2715" })] })), _jsx("div", { style: framed ? bodyStyle : bareBodyStyle, children: children }), footer && _jsx("div", { style: footerStyle, children: footer })] }) })] }), portalTarget.current);
170
- }
171
- const headerStyle = {
172
- display: "flex",
173
- alignItems: "center",
174
- justifyContent: "space-between",
175
- padding: "1rem 1.25rem",
176
- borderBottom: "1px solid var(--color-border)",
177
- };
178
- const titleStyle = { fontSize: "0.95rem", fontWeight: 600, color: "var(--color-text)" };
179
- const closeStyle = {
180
- border: "none",
181
- background: "none",
182
- cursor: "pointer",
183
- color: "var(--color-text-muted)",
184
- fontSize: "0.95rem",
185
- lineHeight: 1,
186
- };
187
- const bodyStyle = { padding: "1.25rem", overflowY: "auto", flex: 1 };
188
- const bareBodyStyle = { overflowY: "auto", flex: 1 };
189
- const footerStyle = {
190
- display: "flex",
191
- justifyContent: "flex-end",
192
- gap: "0.75rem",
193
- padding: "1rem 1.25rem",
194
- borderTop: "1px solid var(--color-border)",
195
- };
78
+ return createPortal(_jsx("div", { className: "rui-modal-scrim", style: { zIndex }, onMouseDown: e => {
79
+ if (!preventClose && e.target === e.currentTarget)
80
+ dismissRef.current();
81
+ }, children: _jsxs("div", { ref: contentRef, role: "dialog", "aria-modal": "true", "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, tabIndex: -1, className: className ? `rui-modal ${className}` : "rui-modal", "data-framed": framed || undefined, style: cardStyle, onMouseDown: e => e.stopPropagation(), children: [title !== undefined && (_jsxs("div", { className: "rui-modal-header", children: [_jsx("span", { className: "rui-modal-title", children: title }), _jsx("button", { type: "button", className: "rui-modal-close", onClick: dismiss, "aria-label": closeLabel, children: "\u2715" })] })), _jsx("div", { className: "rui-modal-body", children: children }), footer && _jsx("div", { className: "rui-modal-footer", children: footer })] }) }), portalTarget.current);
82
+ }
83
+ // Every selector is wrapped in :where() so it has zero specificity:
84
+ // any class or style set by the consuming project wins without !important.
85
+ const modalCss = `
86
+ :where(.rui-modal-scrim) {
87
+ position: fixed;
88
+ inset: 0;
89
+ display: grid;
90
+ place-items: center;
91
+ padding: 1rem;
92
+ -webkit-backdrop-filter: brightness(0.45) blur(2px);
93
+ backdrop-filter: brightness(0.45) blur(2px);
94
+ animation: rui-modal-fade 150ms ease-out;
95
+ }
96
+ :where(.rui-modal) {
97
+ max-width: 95vw;
98
+ max-height: 85vh;
99
+ display: flex;
100
+ flex-direction: column;
101
+ overflow: hidden;
102
+ outline: none;
103
+ background-color: var(--color-surface);
104
+ border: 1px solid var(--color-border);
105
+ border-radius: 12px;
106
+ animation: rui-modal-pop 160ms ease-out;
107
+ }
108
+ :where(.rui-modal-header) {
109
+ display: flex;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ padding: 1rem 1.25rem;
113
+ border-bottom: 1px solid var(--color-border);
114
+ }
115
+ :where(.rui-modal-title) {
116
+ font-size: 0.95rem;
117
+ font-weight: 600;
118
+ color: var(--color-text);
119
+ }
120
+ :where(.rui-modal-close) {
121
+ border: none;
122
+ border-radius: 4px;
123
+ background: none;
124
+ cursor: pointer;
125
+ color: var(--color-text-muted);
126
+ font-size: 0.95rem;
127
+ line-height: 1;
128
+ }
129
+ :where(.rui-modal-close:hover) {
130
+ color: var(--color-text);
131
+ }
132
+ :where(.rui-modal-close:focus-visible) {
133
+ outline: 2px solid var(--color-primary);
134
+ outline-offset: 2px;
135
+ }
136
+ :where(.rui-modal-body) {
137
+ flex: 1;
138
+ overflow-y: auto;
139
+ }
140
+ :where(.rui-modal[data-framed] > .rui-modal-body) {
141
+ padding: 1.25rem;
142
+ }
143
+ :where(.rui-modal-footer) {
144
+ display: flex;
145
+ justify-content: flex-end;
146
+ gap: 0.75rem;
147
+ padding: 1rem 1.25rem;
148
+ border-top: 1px solid var(--color-border);
149
+ }
150
+ @media (prefers-reduced-motion: reduce) {
151
+ :where(.rui-modal-scrim, .rui-modal) {
152
+ animation: none;
153
+ }
154
+ }
155
+ @keyframes rui-modal-fade {
156
+ from { opacity: 0; }
157
+ to { opacity: 1; }
158
+ }
159
+ @keyframes rui-modal-pop {
160
+ from { opacity: 0; transform: translateY(4px) scale(0.98); }
161
+ to { opacity: 1; transform: translateY(0) scale(1); }
162
+ }
163
+ `;
@@ -1,20 +1,17 @@
1
- import type { ElementType, ReactNode } from "react";
2
- export type NavLink = {
3
- label: string;
4
- to: string;
5
- };
1
+ import type { CSSProperties, ReactNode } from "react";
6
2
  type NavBarProps = {
7
- brand: string;
8
- links?: NavLink[];
9
- /** Rendered on the right of the links: sign out, theme select, whatever the page adds. */
10
- actions?: ReactNode;
11
3
  /**
12
- * Link component to render with. Defaults to a plain anchor so the bar works
13
- * without a router; pass a router Link to keep client-side navigation.
4
+ * Anything: logo, router links, buttons, selects. Direct `a` / `button` children get
5
+ * a default look; mark the current one with `aria-current="page"` (react-router's
6
+ * NavLink does it for you) or `aria-pressed="true"` to render it pressed.
14
7
  */
15
- linkAs?: ElementType;
16
- homeTo?: string;
8
+ children: ReactNode;
9
+ className?: string;
10
+ style?: CSSProperties;
17
11
  };
18
- export declare function NavBar({ brand, links, actions, linkAs, homeTo }: NavBarProps): import("react").JSX.Element;
12
+ export declare function NavBar({ children, className, style }: NavBarProps): import("react").JSX.Element;
13
+ export declare namespace NavBar {
14
+ var Spacer: () => import("react").JSX.Element;
15
+ }
19
16
  export {};
20
17
  //# sourceMappingURL=NavBar.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"NavBar.d.ts","sourceRoot":"","sources":["../../src/components/NavBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,WAAW,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAElE,MAAM,MAAM,OAAO,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAA;AAEnD,KAAK,WAAW,GAAG;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,OAAO,EAAE,CAAA;IACjB,0FAA0F;IAC1F,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,wBAAgB,MAAM,CAAC,EAAE,KAAK,EAAE,KAAU,EAAE,OAAO,EAAE,MAAM,EAAE,MAAY,EAAE,EAAE,WAAW,+BAmBvF"}
1
+ {"version":3,"file":"NavBar.d.ts","sourceRoot":"","sources":["../../src/components/NavBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGrD,KAAK,WAAW,GAAG;IACjB;;;;OAIG;IACH,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,aAAa,CAAA;CACtB,CAAA;AAED,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,WAAW,+BAOjE;yBAPe,MAAM"}