@bunnix/components 0.9.0 → 0.9.2

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 (42) hide show
  1. package/@types/index.d.ts +134 -30
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/components/AccordionGroup.mjs +2 -1
  5. package/src/components/Badge.mjs +18 -4
  6. package/src/components/Button.mjs +7 -9
  7. package/src/components/Card.mjs +37 -0
  8. package/src/components/Checkbox.mjs +5 -7
  9. package/src/components/CodeBlock.mjs +31 -0
  10. package/src/components/ComboBox.mjs +22 -14
  11. package/src/components/Container.mjs +8 -10
  12. package/src/components/DatePicker.mjs +13 -15
  13. package/src/components/Dialog.mjs +35 -4
  14. package/src/components/DropdownMenu.mjs +16 -14
  15. package/src/components/HStack.mjs +11 -3
  16. package/src/components/Icon.mjs +9 -5
  17. package/src/components/InputField.mjs +12 -4
  18. package/src/components/NavigationBar.mjs +55 -25
  19. package/src/components/PageHeader.mjs +11 -8
  20. package/src/components/PageSection.mjs +20 -10
  21. package/src/components/PopoverMenu.mjs +94 -50
  22. package/src/components/RadioCheckbox.mjs +5 -7
  23. package/src/components/SearchBox.mjs +12 -21
  24. package/src/components/Sidebar.mjs +142 -67
  25. package/src/components/Table.mjs +145 -96
  26. package/src/components/Text.mjs +52 -21
  27. package/src/components/TimePicker.mjs +13 -15
  28. package/src/components/ToastNotification.mjs +16 -13
  29. package/src/components/ToggleSwitch.mjs +5 -7
  30. package/src/components/VStack.mjs +7 -6
  31. package/src/index.mjs +2 -0
  32. package/src/styles/buttons.css +8 -0
  33. package/src/styles/colors.css +8 -0
  34. package/src/styles/controls.css +61 -0
  35. package/src/styles/layout.css +64 -5
  36. package/src/styles/media.css +11 -0
  37. package/src/styles/menu.css +39 -21
  38. package/src/styles/table.css +2 -2
  39. package/src/styles/typography.css +25 -0
  40. package/src/styles/variables.css +3 -0
  41. package/src/utils/iconUtils.mjs +10 -0
  42. package/src/utils/sizeUtils.mjs +87 -0
@@ -1,4 +1,5 @@
1
1
  import Bunnix, { ForEach, useMemo, useRef, useState } from "@bunnix/core";
2
+ import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
2
3
  import Icon from "./Icon.mjs";
3
4
  const { div, button, span, hr } = Bunnix;
4
5
 
@@ -51,7 +52,7 @@ export default function DatePicker({
51
52
  placeholder,
52
53
  range = false,
53
54
  variant = "regular",
54
- size = "md",
55
+ size = "regular",
55
56
  class: className = ""
56
57
  } = {}) {
57
58
  const popoverRef = useRef(null);
@@ -161,25 +162,22 @@ export default function DatePicker({
161
162
 
162
163
  const hasValue = inputValue.map(v => !!v);
163
164
 
164
- const normalizeSize = (value) => {
165
- if (!value || value === "default" || value === "regular" || value === "md") return "md";
166
- if (value === "sm") return "md";
167
- if (value === "lg" || value === "xl") return value;
168
- return value;
169
- };
165
+ // DatePicker does not support small size (clamps to regular)
166
+ const normalizeSize = (value) => clampSize(value, ["xsmall", "regular", "large", "xlarge"], "regular");
170
167
  const normalizedSize = normalizeSize(size);
168
+ const sizeToken = toSizeToken(normalizedSize);
171
169
  const variantClass = variant === "rounded" ? "rounded-full" : "";
172
- const triggerSizeClass = normalizedSize === "xl"
170
+ const triggerSizeClass = sizeToken === "xl"
173
171
  ? "dropdown-xl"
174
- : normalizedSize === "lg"
172
+ : sizeToken === "lg"
175
173
  ? "dropdown-lg"
176
174
  : "";
177
- const iconSizeValue = normalizedSize === "sm"
178
- ? "sm"
179
- : normalizedSize === "lg"
180
- ? "lg"
181
- : normalizedSize === "xl"
182
- ? "xl"
175
+ const iconSizeValue = normalizedSize === "small"
176
+ ? "small"
177
+ : normalizedSize === "large"
178
+ ? "large"
179
+ : normalizedSize === "xlarge"
180
+ ? "xlarge"
183
181
  : undefined;
184
182
 
185
183
  return div({ class: `datepicker-wrapper ${className}`.trim() }, [
@@ -11,6 +11,8 @@ const defaultDialog = {
11
11
  open: false,
12
12
  title: "",
13
13
  message: "",
14
+ minWidth: 400,
15
+ minHeight: null,
14
16
  confirmation: {
15
17
  text: "",
16
18
  action: null,
@@ -26,11 +28,17 @@ const defaultDialog = {
26
28
 
27
29
  export const dialogState = useState(defaultDialog);
28
30
 
29
- export const showDialog = ({ title, message, confirmation, content } = {}) => {
31
+ export const showDialog = (options = {}) => {
32
+ const { title, message, confirmation, content, minWidth, minHeight } = options;
33
+ const hasMinWidth = Object.prototype.hasOwnProperty.call(options, "minWidth");
34
+ const hasMinHeight = Object.prototype.hasOwnProperty.call(options, "minHeight");
35
+
30
36
  dialogState.set({
31
37
  open: true,
32
38
  title: title ?? "",
33
39
  message: message ?? "",
40
+ minWidth: hasMinWidth ? minWidth : defaultDialog.minWidth,
41
+ minHeight: hasMinHeight ? minHeight : defaultDialog.minHeight,
34
42
  confirmation: {
35
43
  text: confirmation?.text ?? defaultDialog.confirmation.text,
36
44
  action: confirmation?.action ?? null,
@@ -51,6 +59,7 @@ export const hideDialog = () => {
51
59
 
52
60
  export default function Dialog() {
53
61
  const dialogRef = useRef(null);
62
+ const panelRef = useRef(null);
54
63
  const setConfirmDisabled = (disabled) => {
55
64
  const current = dialogState.get();
56
65
  dialogState.set({
@@ -62,6 +71,13 @@ export default function Dialog() {
62
71
  });
63
72
  };
64
73
 
74
+ const resolveSizeValue = (size) => {
75
+ if (size == null || size === "") return "";
76
+ if (size === "auto") return "auto";
77
+ if (typeof size === "number") return `${size}px`;
78
+ return String(size);
79
+ };
80
+
65
81
  useEffect((value) => {
66
82
  const element = dialogRef.current;
67
83
  if (!element) return;
@@ -73,6 +89,14 @@ export default function Dialog() {
73
89
  } else if (element.open) {
74
90
  element.close();
75
91
  }
92
+
93
+ const panel = panelRef.current;
94
+ if (panel) {
95
+ const minWidth = resolveSizeValue(value?.minWidth);
96
+ const minHeight = resolveSizeValue(value?.minHeight);
97
+ panel.style.minWidth = minWidth || "";
98
+ panel.style.minHeight = minHeight || "";
99
+ }
76
100
  }, [dialogState]);
77
101
 
78
102
  const confirmationText = dialogState.map((value) => value.confirmation?.text ?? defaultDialog.confirmation.text);
@@ -109,7 +133,11 @@ export default function Dialog() {
109
133
  hideDialog();
110
134
  }
111
135
  }, [
112
- VStack({ gap: "regular", class: "box-capsule shadow bg-base w-full max-w-400 p-lg items-stretch dialog-appear" }, [
136
+ VStack({
137
+ ref: panelRef,
138
+ gap: "regular",
139
+ class: "box-capsule dialog-panel shadow bg-base p-lg items-stretch dialog-appear"
140
+ }, [
113
141
  HStack({ alignment: "leading", gap: "small", class: "items-center w-full" }, [
114
142
  Text({ type: "heading4", class: "no-margin" }, dialogState.map((value) => value.title)),
115
143
  div({ class: "spacer-h" }),
@@ -127,7 +155,10 @@ export default function Dialog() {
127
155
  Show(showContent, () => {
128
156
  const current = dialogState.get();
129
157
  if (typeof current.content !== "function") return null;
130
- return div({ class: "column-container gap-sm" }, current.content({ setConfirmDisabled }));
158
+ return div(
159
+ { class: "column-container gap-sm w-full h-full flex-1" },
160
+ current.content({ setConfirmDisabled })
161
+ );
131
162
  }),
132
163
  HStack({ alignment: "trailing", gap: "regular", class: "w-full" }, [
133
164
  Show(showExtra, () => Button({
@@ -156,7 +187,7 @@ export default function Dialog() {
156
187
  }, [
157
188
  confirmationText,
158
189
  kbd({ class: "text-white text-sm whitespace-nowrap" }, [
159
- Icon({ name: "return-arrow", fill: "white", size: "xs" }),
190
+ Icon({ name: "return-arrow", fill: "white", size: "xsmall" }),
160
191
  "Enter"
161
192
  ])
162
193
  ]))
@@ -1,4 +1,6 @@
1
1
  import Bunnix, { useRef, useState, useMemo } from "@bunnix/core";
2
+ import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
3
+ import { resolveIconClass } from "../utils/iconUtils.mjs";
2
4
  const { div, button, hr, span } = Bunnix;
3
5
 
4
6
  let dropdownCounter = 0;
@@ -12,13 +14,10 @@ export default function DropdownMenu({
12
14
  onSelect,
13
15
  class: className = ""
14
16
  }) {
15
- const normalizeSize = (value) => {
16
- if (!value || value === "default" || value === "regular" || value === "md") return "md";
17
- if (value === "sm") return "sm";
18
- if (value === "lg" || value === "xl") return value;
19
- return value;
20
- };
17
+ // DropdownMenu supports all sizes
18
+ const normalizeSize = (value) => clampSize(value, ["xsmall", "small", "regular", "large", "xlarge"], "regular");
21
19
  const normalizedSize = normalizeSize(size);
20
+ const sizeToken = toSizeToken(normalizedSize);
22
21
  const popoverRef = useRef(null);
23
22
  const dropdownId = id || `dropdown-instance-${++dropdownCounter}`;
24
23
  const anchorName = `--${dropdownId}`;
@@ -48,13 +47,13 @@ export default function DropdownMenu({
48
47
  }
49
48
  };
50
49
 
51
- const sizeClass = normalizedSize === "lg" ? "dropdown-lg" : normalizedSize === "xl" ? "dropdown-xl" : "";
52
- const itemSizeClass = normalizedSize === "lg" ? "btn-lg" : normalizedSize === "xl" ? "btn-xl" : "";
53
- const iconSizeClass = normalizedSize === "sm"
50
+ const sizeClass = sizeToken === "lg" ? "dropdown-lg" : sizeToken === "xl" ? "dropdown-xl" : "";
51
+ const itemSizeClass = sizeToken === "lg" ? "btn-lg" : sizeToken === "xl" ? "btn-xl" : "";
52
+ const iconSizeClass = sizeToken === "sm"
54
53
  ? "icon-sm"
55
- : normalizedSize === "lg"
54
+ : sizeToken === "lg"
56
55
  ? "icon-lg"
57
- : normalizedSize === "xl"
56
+ : sizeToken === "xl"
58
57
  ? "icon-xl"
59
58
  : "";
60
59
 
@@ -68,9 +67,10 @@ export default function DropdownMenu({
68
67
  // Reactive Icon: stable element, reactive class
69
68
  span({
70
69
  class: selectedItem.map(s => {
71
- if (!s?.icon) return "hidden";
70
+ const resolvedIcon = resolveIconClass(s?.icon);
71
+ if (!resolvedIcon) return "hidden";
72
72
  const tint = s.destructive ? "bg-destructive" : "bg-primary";
73
- return `icon ${iconSizeClass} ${s.icon} ${tint}`.trim();
73
+ return `icon ${iconSizeClass} ${resolvedIcon} ${tint}`.trim();
74
74
  })
75
75
  }),
76
76
  // Reactive Title: text with dimmed style when empty
@@ -97,8 +97,10 @@ export default function DropdownMenu({
97
97
  }, [
98
98
  span({
99
99
  class: isCurrent.map(active => {
100
+ const resolvedIcon = resolveIconClass(item.icon);
101
+ if (!resolvedIcon) return "hidden";
100
102
  const tint = active ? "bg-white" : item.destructive ? "bg-destructive" : "bg-primary";
101
- return `icon ${iconSizeClass} ${item.icon} ${tint}`.trim();
103
+ return `icon ${iconSizeClass} ${resolvedIcon} ${tint}`.trim();
102
104
  })
103
105
  }),
104
106
  item.title
@@ -1,7 +1,7 @@
1
1
  import Bunnix from "@bunnix/core";
2
2
  const { div } = Bunnix;
3
3
 
4
- export default function HStack(props = {}, children) {
4
+ export default function HStack(props = {}, ...children) {
5
5
  if (props === null || props === undefined || Array.isArray(props) || typeof props !== "object") {
6
6
  children = props;
7
7
  props = {};
@@ -9,6 +9,7 @@ export default function HStack(props = {}, children) {
9
9
 
10
10
  const {
11
11
  alignment = "leading",
12
+ verticalAlignment = "center",
12
13
  gap = "regular",
13
14
  class: className = "",
14
15
  ...rest
@@ -19,16 +20,23 @@ export default function HStack(props = {}, children) {
19
20
  trailing: "justify-end"
20
21
  };
21
22
 
23
+ const verticalAlignmentMap = {
24
+ top: "items-start",
25
+ center: "items-center",
26
+ bottom: "items-end"
27
+ };
28
+
22
29
  const gapMap = {
30
+ xsmall: "gap-xs",
23
31
  small: "gap-sm",
24
32
  regular: "gap-md",
25
33
  large: "gap-lg"
26
34
  };
27
35
 
28
- const combinedClass = `row-container ${alignmentMap[alignment]} ${gapMap[gap]} ${className}`.trim();
36
+ const combinedClass = `row-container ${alignmentMap[alignment]} ${verticalAlignmentMap[verticalAlignment]} ${gapMap[gap]} ${className}`.trim();
29
37
 
30
38
  return div({
31
39
  class: combinedClass,
32
40
  ...rest
33
- }, children);
41
+ }, ...children);
34
42
  }
@@ -1,9 +1,10 @@
1
1
  import Bunnix from "@bunnix/core";
2
+ import { normalizeSize, toSizeToken } from "../utils/sizeUtils.mjs";
2
3
  const { span } = Bunnix;
3
4
 
4
5
  export default function Icon({
5
6
  name,
6
- fill = "base",
7
+ fill = "default",
7
8
  size,
8
9
  class: className = "",
9
10
  ...rest
@@ -16,12 +17,15 @@ export default function Icon({
16
17
  // fill mapping: "base" -> "icon-base", "white" -> "icon-white", etc.
17
18
  const fillClass = fill.startsWith("icon-") ? fill : `icon-${fill}`;
18
19
 
19
- const normalizeSize = (value) => {
20
- if (!value || value === "default" || value === "regular" || value === "md") return "";
20
+ const normalizeSizeClass = (value) => {
21
+ if (!value) return "";
21
22
  if (typeof value === "string" && value.startsWith("icon-")) return value;
22
- return `icon-${value}`;
23
+ const normalized = normalizeSize(value);
24
+ if (normalized === "regular") return "";
25
+ const sizeToken = toSizeToken(normalized);
26
+ return sizeToken ? `icon-${sizeToken}` : "";
23
27
  };
24
- const sizeClass = normalizeSize(size);
28
+ const sizeClass = normalizeSizeClass(size);
25
29
 
26
30
  const combinedClass = `icon ${iconName} ${fillClass} ${sizeClass} ${className}`.trim();
27
31
 
@@ -1,10 +1,12 @@
1
1
  import Bunnix, { useRef, useEffect } from "@bunnix/core";
2
+ import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
2
3
  const { div, label, input: inputEl, datalist, option, span } = Bunnix;
3
4
 
4
5
  export default function InputField({
5
6
  class: className = "",
6
7
  type = "text",
7
8
  variant = "regular",
9
+ size,
8
10
  value,
9
11
  placeholder,
10
12
  label: labelText,
@@ -25,6 +27,12 @@ export default function InputField({
25
27
  const inputRef = useRef(null);
26
28
  const listId = suggestions.length > 0 ? `list-${Math.random().toString(36).slice(2, 8)}` : null;
27
29
 
30
+ // InputField supports regular, large, xlarge (no xsmall, small)
31
+ const normalizeSize = (value) => clampSize(value, ["regular", "large", "xlarge"], "regular");
32
+ const normalizedSize = normalizeSize(size);
33
+ const sizeToken = toSizeToken(normalizedSize);
34
+ const sizeClass = sizeToken === "xl" ? "input-xl" : sizeToken === "lg" ? "input-lg" : "";
35
+
28
36
  useEffect((el) => {
29
37
  if (el && listId) {
30
38
  el.setAttribute('list', listId);
@@ -32,7 +40,7 @@ export default function InputField({
32
40
  }, inputRef);
33
41
 
34
42
  const variantClass = variant === "rounded" ? "rounded-full" : "";
35
- const combinedClass = `${className} ${variantClass}`.trim();
43
+ const combinedClass = `${className} ${sizeClass} ${variantClass}`.trim();
36
44
 
37
45
  const handleInput = onInput ?? input;
38
46
  const handleChange = onChange ?? change;
@@ -55,15 +63,15 @@ export default function InputField({
55
63
  ...rest
56
64
  });
57
65
 
58
- const sizeClass = className.includes("input-xl")
66
+ const iconSizeClass = sizeToken === "xl"
59
67
  ? "icon-xl"
60
- : className.includes("input-lg")
68
+ : sizeToken === "lg"
61
69
  ? "icon-lg"
62
70
  : "";
63
71
 
64
72
  const inputBlock = type === "search"
65
73
  ? div({ class: "input-search w-full" }, [
66
- span({ class: `icon icon-search icon-quaternary ${sizeClass}`.trim() }),
74
+ span({ class: `icon icon-search icon-quaternary ${iconSizeClass}`.trim() }),
67
75
  inputElement
68
76
  ])
69
77
  : inputElement;
@@ -3,7 +3,20 @@ import SearchBox from "./SearchBox.mjs";
3
3
 
4
4
  const { nav, div, h2 } = Bunnix;
5
5
 
6
- const renderNode = (node) => (typeof node === "function" ? node() : node);
6
+ const resolveNode = (node) => {
7
+ if (node && typeof node.map === "function") {
8
+ return node.map((value) => resolveNode(value));
9
+ }
10
+ if (node && typeof node.get === "function") {
11
+ return resolveNode(node.get());
12
+ }
13
+ return typeof node === "function" ? node() : node;
14
+ };
15
+
16
+ const isState = (value) =>
17
+ value &&
18
+ typeof value.get === "function" &&
19
+ typeof value.subscribe === "function";
7
20
 
8
21
  export default function NavigationBar({
9
22
  title,
@@ -18,30 +31,47 @@ export default function NavigationBar({
18
31
  class: className = "",
19
32
  ...rest
20
33
  } = {}) {
21
- const titleNode = typeof title === "string"
22
- ? h2({ class: "whitespace-nowrap" }, title)
23
- : renderNode(title);
34
+ const titleContent = isState(title)
35
+ ? title.map((value) => {
36
+ if (value === null || value === undefined) return "";
37
+ return typeof value === "string" || typeof value === "number"
38
+ ? String(value)
39
+ : value;
40
+ })
41
+ : title === null || title === undefined
42
+ ? ""
43
+ : typeof title === "string" || typeof title === "number"
44
+ ? String(title)
45
+ : title;
24
46
 
25
- const leadingNode = renderNode(leading);
26
- const trailingNode = renderNode(trailing);
47
+ const titleNode = h2({ class: "whitespace-nowrap" }, titleContent);
48
+ const leadingNode = resolveNode(leading);
49
+ const trailingNode = resolveNode(trailing);
27
50
 
28
- return nav({
29
- class: `navigation-bar row-container items-center gap-md w-full sticky-top bg-base ${className}`.trim(),
30
- ...rest
31
- }, [
32
- titleNode,
33
- leadingNode && div({ class: "shrink-0" }, leadingNode),
34
- div({ class: "w-full" }),
35
- trailingNode && div({ class: "shrink-0" }, trailingNode),
36
- searchable && SearchBox({
37
- data: searchData,
38
- value: searchValue,
39
- onInput: onSearchInput,
40
- onSelect: onSearchSelect,
41
- placeholder: "Search components",
42
- variant: "rounded",
43
- class: "w-300",
44
- ...searchProps
45
- })
46
- ]);
51
+ return nav(
52
+ {
53
+ class:
54
+ `navigation-bar row-container items-center gap-md w-full sticky-top bg-base ${className}`.trim(),
55
+ ...rest,
56
+ },
57
+ [
58
+ title ? titleNode : null,
59
+ leadingNode &&
60
+ div({ class: "shrink-0 row-container items-center" }, leadingNode),
61
+ div({ class: "w-full" }),
62
+ trailingNode &&
63
+ div({ class: "shrink-0 row-container items-center" }, trailingNode),
64
+ searchable &&
65
+ SearchBox({
66
+ data: searchData,
67
+ value: searchValue,
68
+ onInput: onSearchInput,
69
+ onSelect: onSearchSelect,
70
+ placeholder: "Search components",
71
+ variant: "rounded",
72
+ class: "w-300",
73
+ ...searchProps,
74
+ }),
75
+ ],
76
+ );
47
77
  }
@@ -1,13 +1,16 @@
1
1
  import Bunnix from "@bunnix/core";
2
- const { div, h1, p } = Bunnix;
2
+ const { div, h2, p } = Bunnix;
3
+
4
+ export default function PageHeader({ title, description, trailing } = {}) {
5
+ const trailingContent = typeof trailing === "function" ? trailing() : trailing;
3
6
 
4
- export default function PageHeader({ title, description } = {}) {
5
7
  return div({
6
- class: "row-container no-margin"
8
+ class: "row-container items-center gap-md no-margin"
7
9
  }, [
8
- div({ class: "column-container gap-xs flex-1" }, [
9
- h1({ class: "no-margin" }, title),
10
- p({ class: "text-secondary no-margin" }, description)
11
- ])
12
- ]);
10
+ div({ class: "column-container gap-xs flex-1 w-full" }, [
11
+ h2({ class: "no-margin" }, title),
12
+ description ? p({ class: "text-secondary no-margin" }, description) : null
13
+ ]),
14
+ trailingContent ? div({ class: "shrink-0" }, trailingContent) : null
15
+ ].filter(Boolean));
13
16
  }
@@ -1,20 +1,30 @@
1
1
  import Bunnix from "@bunnix/core";
2
2
  const { div, h5 } = Bunnix;
3
3
 
4
- export default function PageSection({ title, stickyOffset = 0, gap = "regular" } = {}, children) {
4
+ export default function PageSection(
5
+ { title, stickyOffset = 0, gap = "regular", trailing } = {},
6
+ children,
7
+ ) {
5
8
  const gapMap = {
6
9
  small: "gap-sm",
7
10
  regular: "gap-md",
8
- large: "gap-lg"
11
+ large: "gap-lg",
9
12
  };
10
13
 
11
- return div({ class: "column-container no-margin" }, [
12
- div({
13
- class: "pt-sm pb-sm",
14
- style: ` --sticky-offset: ${stickyOffset}`
15
- }, [
16
- h5({ class: "no-margin select-none" }, title)
17
- ]),
18
- div({ class: `column-container py-xs ${gapMap[gap]}`.trim() }, children)
14
+ const trailingContent =
15
+ typeof trailing === "function" ? trailing() : trailing;
16
+
17
+ return div({ class: "column-container no-margin w-full" }, [
18
+ div(
19
+ {
20
+ class: "row-container items-center gap-md pt-sm pb-sm",
21
+ style: ` --sticky-offset: ${stickyOffset}`,
22
+ },
23
+ [
24
+ h5({ class: "no-margin select-none flex-1 w-full" }, title),
25
+ trailingContent ? div({ class: "shrink-0" }, trailingContent) : null,
26
+ ].filter(Boolean),
27
+ ),
28
+ div({ class: `column-container py-xs ${gapMap[gap]}`.trim() }, children),
19
29
  ]);
20
30
  }
@@ -1,24 +1,26 @@
1
1
  import Bunnix, { useRef } from "@bunnix/core";
2
+ import { resolveIconClass } from "../utils/iconUtils.mjs";
3
+ import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
2
4
  const { div, button, hr, span } = Bunnix;
3
5
 
4
6
  let menuCounter = 0;
5
7
 
6
- export default function PopoverMenu({
7
- trigger,
8
- items = [],
9
- id,
10
- align = "left",
11
- size,
12
- onSelect,
13
- class: className = ""
14
- }) {
15
- const normalizeSize = (value) => {
16
- if (!value || value === "default" || value === "regular" || value === "md") return "md";
17
- if (value === "sm") return "sm";
18
- if (value === "lg" || value === "xl") return value;
19
- return value;
20
- };
8
+ export default function PopoverMenu(
9
+ {
10
+ trigger,
11
+ menuItems = [],
12
+ id,
13
+ align = "left",
14
+ size,
15
+ onSelect,
16
+ class: className = "",
17
+ } = {},
18
+ children,
19
+ ) {
20
+ const normalizeSize = (value) =>
21
+ clampSize(value, ["xsmall", "small", "regular", "large", "xlarge"], "regular");
21
22
  const normalizedSize = normalizeSize(size);
23
+ const sizeToken = toSizeToken(normalizedSize);
22
24
  const popoverRef = useRef(null);
23
25
 
24
26
  const menuId = id || `menu-instance-${++menuCounter}`;
@@ -44,44 +46,86 @@ export default function PopoverMenu({
44
46
  }
45
47
  };
46
48
 
47
- const sizeClass = normalizedSize === "lg" ? "btn-lg" : normalizedSize === "xl" ? "btn-xl" : "";
48
- const iconSizeClass = normalizedSize === "sm"
49
- ? "icon-sm"
50
- : normalizedSize === "lg"
51
- ? "icon-lg"
52
- : normalizedSize === "xl"
53
- ? "icon-xl"
49
+ const sizeClass =
50
+ sizeToken === "lg"
51
+ ? "btn-lg"
52
+ : sizeToken === "xl"
53
+ ? "btn-xl"
54
54
  : "";
55
+ const iconSizeClass =
56
+ sizeToken === "sm"
57
+ ? "icon-sm"
58
+ : sizeToken === "lg"
59
+ ? "icon-lg"
60
+ : sizeToken === "xl"
61
+ ? "icon-xl"
62
+ : "";
63
+
64
+ const triggerProps = {
65
+ id: menuId,
66
+ type: 'button',
67
+ style: `anchor-name: ${anchorName}`,
68
+ class: `btn btn-flat ${sizeClass} ${className}`.trim(),
69
+ click: handleToggle,
70
+ };
71
+
72
+ // Determine what to use as trigger:
73
+ // 1. If children are provided, use children as trigger content
74
+ // 2. Otherwise use trigger prop
75
+ const hasTriggerContent =
76
+ children !== undefined &&
77
+ children !== null &&
78
+ (Array.isArray(children) ? children.length > 0 : true);
79
+
80
+ const resolvedTrigger = hasTriggerContent ? children : trigger;
81
+
82
+ // If trigger is a function, call it to get the content, then wrap in button
83
+ // Otherwise use the trigger/children directly as content
84
+ const triggerContent =
85
+ typeof resolvedTrigger === "function" ? resolvedTrigger() : resolvedTrigger;
86
+
87
+ const triggerElement = button(triggerProps, triggerContent);
55
88
 
56
89
  return div({ class: "menu-wrapper" }, [
57
- button({
58
- id: menuId,
59
- style: `anchor-name: ${anchorName}`,
60
- class: `btn btn-flat ${sizeClass} ${className}`.trim(),
61
- click: handleToggle
62
- }, trigger),
90
+ triggerElement,
63
91
 
64
- div({
65
- ref: popoverRef,
66
- popover: "auto",
67
- class: `menu-popover popover-base menu-anchor-${align}`,
68
- style: `--anchor-id: ${anchorName}`
69
- }, [
70
- /* All design system styles go here to avoid overriding popover visibility */
71
- div({ class: "card column-container shadow gap-sm w-min-150 p-sm bg-base" },
72
- items.map((item) => {
73
- if (item.isSeparator) {
74
- return hr({ class: "no-margin" });
75
- }
76
- return button({
77
- class: `btn btn-flat justify-start w-full ${sizeClass}`.trim(),
78
- click: () => handleItemClick(item)
79
- }, [
80
- item.icon ? span({ class: `icon ${iconSizeClass} ${item.icon} ${item.destructive ? 'bg-destructive' : 'bg-primary'}`.trim() }) : null,
81
- item.title
82
- ]);
83
- })
84
- )
85
- ])
92
+ div(
93
+ {
94
+ ref: popoverRef,
95
+ popover: "auto",
96
+ class: `menu-popover popover-base menu-anchor-${align}`,
97
+ style: `--anchor-id: ${anchorName}`,
98
+ },
99
+ [
100
+ /* All design system styles go here to avoid overriding popover visibility */
101
+ div(
102
+ {
103
+ class: "card column-container shadow gap-sm w-min-150 p-sm bg-base",
104
+ },
105
+ menuItems.map((item) => {
106
+ if (item.isSeparator) {
107
+ return hr({ class: "no-margin" });
108
+ }
109
+ return button(
110
+ {
111
+ class: `btn btn-flat justify-start w-full ${sizeClass}`.trim(),
112
+ click: () => handleItemClick(item),
113
+ },
114
+ [
115
+ item.icon
116
+ ? span({
117
+ class: (() => {
118
+ const resolvedIcon = resolveIconClass(item.icon);
119
+ return `icon ${iconSizeClass} ${resolvedIcon} ${item.destructive ? "bg-destructive" : "bg-primary"}`.trim();
120
+ })(),
121
+ })
122
+ : null,
123
+ item.title,
124
+ ],
125
+ );
126
+ }),
127
+ ),
128
+ ],
129
+ ),
86
130
  ]);
87
131
  }