@bunnix/components 0.9.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 (152) hide show
  1. package/@types/index.d.ts +269 -0
  2. package/LICENSE +7 -0
  3. package/README.md +184 -0
  4. package/package.json +53 -0
  5. package/src/components/AccordionGroup.mjs +37 -0
  6. package/src/components/Badge.mjs +49 -0
  7. package/src/components/Button.mjs +76 -0
  8. package/src/components/Checkbox.mjs +36 -0
  9. package/src/components/ComboBox.mjs +44 -0
  10. package/src/components/Container.mjs +27 -0
  11. package/src/components/DatePicker.mjs +251 -0
  12. package/src/components/Dialog.mjs +166 -0
  13. package/src/components/DropdownMenu.mjs +110 -0
  14. package/src/components/Grid.mjs +40 -0
  15. package/src/components/HStack.mjs +34 -0
  16. package/src/components/Icon.mjs +32 -0
  17. package/src/components/InputField.mjs +78 -0
  18. package/src/components/NavigationBar.mjs +47 -0
  19. package/src/components/PageHeader.mjs +13 -0
  20. package/src/components/PageSection.mjs +20 -0
  21. package/src/components/PopoverMenu.mjs +87 -0
  22. package/src/components/RadioCheckbox.mjs +36 -0
  23. package/src/components/SearchBox.mjs +207 -0
  24. package/src/components/Sidebar.mjs +187 -0
  25. package/src/components/Table.mjs +254 -0
  26. package/src/components/Text.mjs +38 -0
  27. package/src/components/TimePicker.mjs +172 -0
  28. package/src/components/ToastNotification.mjs +105 -0
  29. package/src/components/ToggleSwitch.mjs +26 -0
  30. package/src/components/VStack.mjs +35 -0
  31. package/src/icons/add-circle.svg +1 -0
  32. package/src/icons/add.svg +1 -0
  33. package/src/icons/alt.svg +1 -0
  34. package/src/icons/archive.svg +1 -0
  35. package/src/icons/at.svg +1 -0
  36. package/src/icons/attestation.svg +1 -0
  37. package/src/icons/bell.svg +4 -0
  38. package/src/icons/bookmark.svg +1 -0
  39. package/src/icons/bot.svg +1 -0
  40. package/src/icons/button.svg +1 -0
  41. package/src/icons/calculate.svg +1 -0
  42. package/src/icons/calendar.svg +1 -0
  43. package/src/icons/chart.svg +1 -0
  44. package/src/icons/check.svg +1 -0
  45. package/src/icons/chevron-down.svg +1 -0
  46. package/src/icons/chevron-left.svg +1 -0
  47. package/src/icons/chevron-right.svg +1 -0
  48. package/src/icons/clip.svg +1 -0
  49. package/src/icons/clock.svg +4 -0
  50. package/src/icons/close-circle.svg +4 -0
  51. package/src/icons/close.svg +1 -0
  52. package/src/icons/cloud-download.svg +1 -0
  53. package/src/icons/cloud-upload.svg +1 -0
  54. package/src/icons/cloud.svg +1 -0
  55. package/src/icons/columns-layout.svg +1 -0
  56. package/src/icons/command.svg +1 -0
  57. package/src/icons/cube.svg +1 -0
  58. package/src/icons/delete.svg +4 -0
  59. package/src/icons/dollar.svg +4 -0
  60. package/src/icons/download.svg +1 -0
  61. package/src/icons/draw.svg +1 -0
  62. package/src/icons/duplicate.svg +4 -0
  63. package/src/icons/edit.svg +1 -0
  64. package/src/icons/exclamation-mark.svg +1 -0
  65. package/src/icons/eye-open.svg +1 -0
  66. package/src/icons/eye.svg +1 -0
  67. package/src/icons/file-html.svg +1 -0
  68. package/src/icons/file.svg +4 -0
  69. package/src/icons/finger.svg +1 -0
  70. package/src/icons/flag.svg +1 -0
  71. package/src/icons/folder.svg +1 -0
  72. package/src/icons/function.svg +1 -0
  73. package/src/icons/gear.svg +1 -0
  74. package/src/icons/gift.svg +1 -0
  75. package/src/icons/globe.svg +4 -0
  76. package/src/icons/grid.svg +1 -0
  77. package/src/icons/hand.svg +1 -0
  78. package/src/icons/heart.svg +4 -0
  79. package/src/icons/home.svg +4 -0
  80. package/src/icons/image.svg +1 -0
  81. package/src/icons/inbox.svg +4 -0
  82. package/src/icons/info.svg +1 -0
  83. package/src/icons/key.svg +1 -0
  84. package/src/icons/lamp.svg +1 -0
  85. package/src/icons/link.svg +1 -0
  86. package/src/icons/location.svg +1 -0
  87. package/src/icons/locker.svg +1 -0
  88. package/src/icons/login.svg +1 -0
  89. package/src/icons/logout.svg +4 -0
  90. package/src/icons/mail.svg +4 -0
  91. package/src/icons/map.svg +4 -0
  92. package/src/icons/markup.svg +1 -0
  93. package/src/icons/merge.svg +1 -0
  94. package/src/icons/more-horizontal.svg +5 -0
  95. package/src/icons/more-vertical.svg +5 -0
  96. package/src/icons/mouse.svg +1 -0
  97. package/src/icons/palette.svg +1 -0
  98. package/src/icons/password.svg +1 -0
  99. package/src/icons/pencil.svg +1 -0
  100. package/src/icons/people.svg +4 -0
  101. package/src/icons/person-add.svg +1 -0
  102. package/src/icons/person-remove.svg +1 -0
  103. package/src/icons/person.svg +5 -0
  104. package/src/icons/pin.svg +1 -0
  105. package/src/icons/question-circle.svg +4 -0
  106. package/src/icons/remove-circle.svg +1 -0
  107. package/src/icons/return-arrow.svg +2 -0
  108. package/src/icons/save.svg +1 -0
  109. package/src/icons/search.svg +1 -0
  110. package/src/icons/sections.svg +1 -0
  111. package/src/icons/send.svg +1 -0
  112. package/src/icons/share.svg +1 -0
  113. package/src/icons/shine.svg +1 -0
  114. package/src/icons/sliders.svg +1 -0
  115. package/src/icons/star.svg +4 -0
  116. package/src/icons/storage.svg +1 -0
  117. package/src/icons/success-circle.svg +4 -0
  118. package/src/icons/swap.svg +1 -0
  119. package/src/icons/switch.svg +1 -0
  120. package/src/icons/sync.svg +4 -0
  121. package/src/icons/table.svg +4 -0
  122. package/src/icons/tag.svg +4 -0
  123. package/src/icons/terminal.svg +1 -0
  124. package/src/icons/text.svg +1 -0
  125. package/src/icons/thumb-down.svg +1 -0
  126. package/src/icons/thumb-up.svg +1 -0
  127. package/src/icons/timer.svg +4 -0
  128. package/src/icons/toggle.svg +1 -0
  129. package/src/icons/trash.svg +1 -0
  130. package/src/icons/update-page.svg +1 -0
  131. package/src/icons/upload.svg +1 -0
  132. package/src/icons/video.svg +1 -0
  133. package/src/icons/wallet.svg +1 -0
  134. package/src/icons/window.svg +1 -0
  135. package/src/index.mjs +29 -0
  136. package/src/styles/accordion.css +70 -0
  137. package/src/styles/buttons.css +118 -0
  138. package/src/styles/colors.css +131 -0
  139. package/src/styles/controls.css +504 -0
  140. package/src/styles/datepicker.css +140 -0
  141. package/src/styles/interactable.css +16 -0
  142. package/src/styles/layout.css +444 -0
  143. package/src/styles/links.css +38 -0
  144. package/src/styles/main.css +16 -0
  145. package/src/styles/media.css +155 -0
  146. package/src/styles/menu.css +168 -0
  147. package/src/styles/motion.css +66 -0
  148. package/src/styles/table.css +78 -0
  149. package/src/styles/timepicker.css +87 -0
  150. package/src/styles/typography.css +94 -0
  151. package/src/styles/variables.css +218 -0
  152. package/src/styles.css +1 -0
@@ -0,0 +1,34 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { div } = Bunnix;
3
+
4
+ export default function HStack(props = {}, children) {
5
+ if (props === null || props === undefined || Array.isArray(props) || typeof props !== "object") {
6
+ children = props;
7
+ props = {};
8
+ }
9
+
10
+ const {
11
+ alignment = "leading",
12
+ gap = "regular",
13
+ class: className = "",
14
+ ...rest
15
+ } = props;
16
+ const alignmentMap = {
17
+ leading: "justify-start",
18
+ middle: "justify-center",
19
+ trailing: "justify-end"
20
+ };
21
+
22
+ const gapMap = {
23
+ small: "gap-sm",
24
+ regular: "gap-md",
25
+ large: "gap-lg"
26
+ };
27
+
28
+ const combinedClass = `row-container ${alignmentMap[alignment]} ${gapMap[gap]} ${className}`.trim();
29
+
30
+ return div({
31
+ class: combinedClass,
32
+ ...rest
33
+ }, children);
34
+ }
@@ -0,0 +1,32 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { span } = Bunnix;
3
+
4
+ export default function Icon({
5
+ name,
6
+ fill = "base",
7
+ size,
8
+ class: className = "",
9
+ ...rest
10
+ } = {}) {
11
+ // name is expected to be just the slug, e.g. "add" or "person"
12
+ // but we handle "icon-name" too for backward compatibility
13
+ const safeName = typeof name === "string" ? name : "";
14
+ const iconName = safeName.startsWith("icon-") ? safeName : `icon-${safeName}`;
15
+
16
+ // fill mapping: "base" -> "icon-base", "white" -> "icon-white", etc.
17
+ const fillClass = fill.startsWith("icon-") ? fill : `icon-${fill}`;
18
+
19
+ const normalizeSize = (value) => {
20
+ if (!value || value === "default" || value === "regular" || value === "md") return "";
21
+ if (typeof value === "string" && value.startsWith("icon-")) return value;
22
+ return `icon-${value}`;
23
+ };
24
+ const sizeClass = normalizeSize(size);
25
+
26
+ const combinedClass = `icon ${iconName} ${fillClass} ${sizeClass} ${className}`.trim();
27
+
28
+ return span({
29
+ class: combinedClass,
30
+ ...rest
31
+ });
32
+ }
@@ -0,0 +1,78 @@
1
+ import Bunnix, { useRef, useEffect } from "@bunnix/core";
2
+ const { div, label, input: inputEl, datalist, option, span } = Bunnix;
3
+
4
+ export default function InputField({
5
+ class: className = "",
6
+ type = "text",
7
+ variant = "regular",
8
+ value,
9
+ placeholder,
10
+ label: labelText,
11
+ disabled = false,
12
+ suggestions = [],
13
+ onInput,
14
+ onChange,
15
+ onFocus,
16
+ onBlur,
17
+ onKeyDown,
18
+ input,
19
+ change,
20
+ focus,
21
+ blur,
22
+ keydown,
23
+ ...rest
24
+ } = {}) {
25
+ const inputRef = useRef(null);
26
+ const listId = suggestions.length > 0 ? `list-${Math.random().toString(36).slice(2, 8)}` : null;
27
+
28
+ useEffect((el) => {
29
+ if (el && listId) {
30
+ el.setAttribute('list', listId);
31
+ }
32
+ }, inputRef);
33
+
34
+ const variantClass = variant === "rounded" ? "rounded-full" : "";
35
+ const combinedClass = `${className} ${variantClass}`.trim();
36
+
37
+ const handleInput = onInput ?? input;
38
+ const handleChange = onChange ?? change;
39
+ const handleFocus = onFocus ?? focus;
40
+ const handleBlur = onBlur ?? blur;
41
+ const handleKeyDown = onKeyDown ?? keydown;
42
+
43
+ const inputElement = inputEl({
44
+ ref: inputRef,
45
+ type,
46
+ value: value ?? "",
47
+ placeholder: placeholder ?? "", // Ensure placeholder is never undefined to avoid "false" text
48
+ disabled,
49
+ class: `input ${combinedClass}`.trim(),
50
+ input: handleInput,
51
+ change: handleChange,
52
+ focus: handleFocus,
53
+ blur: handleBlur,
54
+ keydown: handleKeyDown,
55
+ ...rest
56
+ });
57
+
58
+ const sizeClass = className.includes("input-xl")
59
+ ? "icon-xl"
60
+ : className.includes("input-lg")
61
+ ? "icon-lg"
62
+ : "";
63
+
64
+ const inputBlock = type === "search"
65
+ ? div({ class: "input-search w-full" }, [
66
+ span({ class: `icon icon-search icon-quaternary ${sizeClass}`.trim() }),
67
+ inputElement
68
+ ])
69
+ : inputElement;
70
+
71
+ return div({ class: "column-container no-margin shrink-0" }, [
72
+ labelText && label({ class: "label select-none" }, labelText),
73
+ inputBlock,
74
+ listId && datalist({ id: listId },
75
+ suggestions.map(s => option({ value: s }))
76
+ )
77
+ ]);
78
+ }
@@ -0,0 +1,47 @@
1
+ import Bunnix from "@bunnix/core";
2
+ import SearchBox from "./SearchBox.mjs";
3
+
4
+ const { nav, div, h2 } = Bunnix;
5
+
6
+ const renderNode = (node) => (typeof node === "function" ? node() : node);
7
+
8
+ export default function NavigationBar({
9
+ title,
10
+ leading,
11
+ trailing,
12
+ searchable = false,
13
+ searchData,
14
+ searchValue,
15
+ onSearchInput,
16
+ onSearchSelect,
17
+ searchProps = {},
18
+ class: className = "",
19
+ ...rest
20
+ } = {}) {
21
+ const titleNode = typeof title === "string"
22
+ ? h2({ class: "whitespace-nowrap" }, title)
23
+ : renderNode(title);
24
+
25
+ const leadingNode = renderNode(leading);
26
+ const trailingNode = renderNode(trailing);
27
+
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
+ ]);
47
+ }
@@ -0,0 +1,13 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { div, h1, p } = Bunnix;
3
+
4
+ export default function PageHeader({ title, description } = {}) {
5
+ return div({
6
+ class: "row-container no-margin"
7
+ }, [
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
+ ]);
13
+ }
@@ -0,0 +1,20 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { div, h5 } = Bunnix;
3
+
4
+ export default function PageSection({ title, stickyOffset = 0, gap = "regular" } = {}, children) {
5
+ const gapMap = {
6
+ small: "gap-sm",
7
+ regular: "gap-md",
8
+ large: "gap-lg"
9
+ };
10
+
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)
19
+ ]);
20
+ }
@@ -0,0 +1,87 @@
1
+ import Bunnix, { useRef } from "@bunnix/core";
2
+ const { div, button, hr, span } = Bunnix;
3
+
4
+ let menuCounter = 0;
5
+
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
+ };
21
+ const normalizedSize = normalizeSize(size);
22
+ const popoverRef = useRef(null);
23
+
24
+ const menuId = id || `menu-instance-${++menuCounter}`;
25
+ const anchorName = `--${menuId}`;
26
+
27
+ const handleToggle = (e) => {
28
+ const popover = popoverRef.current;
29
+ if (!popover) return;
30
+
31
+ if (popover.matches(":popover-open")) {
32
+ popover.hidePopover();
33
+ } else {
34
+ popover.showPopover();
35
+ }
36
+ };
37
+
38
+ const handleItemClick = (item) => {
39
+ if (item?.click) item.click();
40
+ if (onSelect) onSelect(item);
41
+ const popover = popoverRef.current;
42
+ if (popover) {
43
+ popover.hidePopover();
44
+ }
45
+ };
46
+
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"
54
+ : "";
55
+
56
+ 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),
63
+
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
+ ])
86
+ ]);
87
+ }
@@ -0,0 +1,36 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { label, input, span } = Bunnix;
3
+
4
+ export default function RadioCheckbox({
5
+ labelText,
6
+ size,
7
+ onCheck,
8
+ check,
9
+ onChange,
10
+ class: className = "",
11
+ ...inputProps
12
+ }) {
13
+ const normalizeSize = (value) => {
14
+ if (!value || value === "default" || value === "regular" || value === "md") return "md";
15
+ if (value === "sm") return "sm";
16
+ if (value === "lg" || value === "xl") return value;
17
+ return value;
18
+ };
19
+ const normalizedSize = normalizeSize(size);
20
+ const sizeClass = normalizedSize === "lg" ? "checkbox-lg" : normalizedSize === "xl" ? "checkbox-xl" : "";
21
+ const nativeChange = onChange ?? inputProps.change;
22
+ const checkHandler = onCheck ?? check;
23
+
24
+ return label({ class: `selection-control ${className}`.trim() }, [
25
+ input({
26
+ type: "radio",
27
+ class: sizeClass,
28
+ ...inputProps,
29
+ change: (e) => {
30
+ if (nativeChange) nativeChange(e);
31
+ if (checkHandler) checkHandler(e.target.checked);
32
+ }
33
+ }),
34
+ labelText ? span({ class: "text-base" }, labelText) : null
35
+ ]);
36
+ }
@@ -0,0 +1,207 @@
1
+ import Bunnix, { ForEach, useEffect, useMemo, useRef, useState } from "@bunnix/core";
2
+ import InputField from "./InputField.mjs";
3
+ import Icon from "./Icon.mjs";
4
+
5
+ const sizeClassMap = {
6
+ sm: "",
7
+ md: "",
8
+ lg: "input-lg",
9
+ xl: "input-xl"
10
+ };
11
+
12
+ const { div, button, span } = Bunnix;
13
+
14
+ export default function SearchBox({
15
+ data,
16
+ value,
17
+ placeholder = "Search",
18
+ onInput,
19
+ input,
20
+ size,
21
+ variant = "regular",
22
+ class: className = "",
23
+ onSelect,
24
+ select,
25
+ ...rest
26
+ } = {}) {
27
+ const normalizeSize = (value) => {
28
+ if (!value || value === "default" || value === "regular" || value === "md") return "md";
29
+ if (value === "sm") return "sm";
30
+ if (value === "lg" || value === "xl") return value;
31
+ return value;
32
+ };
33
+ const normalizedSize = normalizeSize(size);
34
+ const sizeClass = sizeClassMap[normalizedSize] || "";
35
+ const variantClass = variant === "rounded" ? "rounded-full" : "";
36
+ const combinedClass = `${sizeClass} ${variantClass} ${className}`.trim();
37
+
38
+ const handleInputExternal = onInput ?? input;
39
+ const handleSelectExternal = onSelect ?? select;
40
+
41
+ if (!Array.isArray(data)) {
42
+ return InputField({
43
+ type: "search",
44
+ value,
45
+ placeholder,
46
+ onInput: handleInputExternal,
47
+ class: combinedClass,
48
+ ...rest
49
+ });
50
+ }
51
+
52
+ const popoverRef = useRef(null);
53
+ const searchId = `searchbox-${Math.random().toString(36).slice(2, 8)}`;
54
+ const anchorName = `--${searchId}`;
55
+ const internalValue = useState("");
56
+ const activeIndex = useState(-1);
57
+ const valueState = value && typeof value.map === "function" ? value : null;
58
+ const currentValue = valueState ? valueState : (value ?? internalValue);
59
+
60
+ const filteredData = useMemo([data, currentValue], (list, query) => {
61
+ const needle = String(query ?? "").trim().toLowerCase();
62
+ if (!needle) return [];
63
+ return (Array.isArray(list) ? list : [])
64
+ .filter((item) => {
65
+ const title = String(item?.title ?? "").toLowerCase();
66
+ const snippet = String(item?.snippet ?? "").toLowerCase();
67
+ return title.includes(needle) || snippet.includes(needle);
68
+ })
69
+ .slice(0, 5);
70
+ });
71
+
72
+ const indexedData = useMemo([filteredData], (list) =>
73
+ (list || []).map((item, index) => ({ ...item, __index: index }))
74
+ );
75
+
76
+ useEffect((text) => {
77
+ const popover = popoverRef.current;
78
+ if (!popover) return;
79
+ const hasText = String(text ?? "").trim().length > 0;
80
+ const list = indexedData.get();
81
+ const hasItems = Array.isArray(list) && list.length > 0;
82
+ if (hasText && hasItems && !popover.matches(":popover-open")) {
83
+ popover.showPopover();
84
+ } else if ((!hasText || !hasItems) && popover.matches(":popover-open")) {
85
+ popover.hidePopover();
86
+ }
87
+ }, [currentValue]);
88
+
89
+ useEffect((list) => {
90
+ if (!Array.isArray(list) || list.length === 0) {
91
+ activeIndex.set(-1);
92
+ return;
93
+ }
94
+ activeIndex.set(0);
95
+ }, [indexedData]);
96
+
97
+ const handleInput = (event) => {
98
+ const next = event?.target?.value ?? "";
99
+ if (valueState) {
100
+ valueState.set(next);
101
+ } else if (typeof value === "string") {
102
+ // leave to external control
103
+ } else {
104
+ internalValue.set(next);
105
+ }
106
+ if (handleInputExternal) handleInputExternal(event);
107
+ };
108
+
109
+ const handleSelect = (item) => {
110
+ const next = item?.title ?? "";
111
+ if (valueState) {
112
+ valueState.set(next);
113
+ } else if (typeof value === "string") {
114
+ // leave to external control
115
+ } else {
116
+ internalValue.set(next);
117
+ }
118
+ if (handleSelectExternal) handleSelectExternal(item);
119
+ const popover = popoverRef.current;
120
+ if (popover && popover.matches(":popover-open")) {
121
+ popover.hidePopover();
122
+ }
123
+ };
124
+
125
+ const itemSizeClass = normalizedSize === "lg" ? "btn-lg" : normalizedSize === "xl" ? "btn-xl" : "";
126
+ const iconSizeValue = normalizedSize === "sm"
127
+ ? "sm"
128
+ : normalizedSize === "lg"
129
+ ? "lg"
130
+ : normalizedSize === "xl"
131
+ ? "xl"
132
+ : undefined;
133
+ const hasResults = indexedData.map((list) => (list || []).length > 0);
134
+
135
+ const handleKeyDown = (event) => {
136
+ if (!event || !Array.isArray(indexedData.get())) return;
137
+ const list = indexedData.get();
138
+ if (list.length === 0) return;
139
+ if (event.key === "ArrowDown") {
140
+ event.preventDefault();
141
+ const current = activeIndex.get();
142
+ const next = current < 0 ? 0 : Math.min(current + 1, list.length - 1);
143
+ activeIndex.set(next);
144
+ return;
145
+ }
146
+ if (event.key === "ArrowUp") {
147
+ event.preventDefault();
148
+ const current = activeIndex.get();
149
+ const next = current <= 0 ? 0 : current - 1;
150
+ activeIndex.set(next);
151
+ return;
152
+ }
153
+ if (event.key === "Enter") {
154
+ event.preventDefault();
155
+ const current = activeIndex.get();
156
+ const item = list[current];
157
+ if (item) handleSelect(item);
158
+ return;
159
+ }
160
+ if (event.key === "Escape") {
161
+ const popover = popoverRef.current;
162
+ if (popover && popover.matches(":popover-open")) {
163
+ popover.hidePopover();
164
+ }
165
+ }
166
+ };
167
+
168
+ return div({ class: "menu-wrapper w-full" }, [
169
+ div({ class: "w-full" }, [
170
+ InputField({
171
+ type: "search",
172
+ value: currentValue,
173
+ placeholder,
174
+ onInput: handleInput,
175
+ class: combinedClass,
176
+ keydown: handleKeyDown,
177
+ style: `anchor-name: ${anchorName}`,
178
+ ...rest
179
+ })
180
+ ]),
181
+ div({
182
+ ref: popoverRef,
183
+ popover: "auto",
184
+ class: "menu-popover popover-base menu-anchor-left match-anchor",
185
+ style: `--anchor-id: ${anchorName}`
186
+ }, [
187
+ div({ class: "card column-container shadow gap-sm w-full p-sm bg-base" }, [
188
+ ForEach(indexedData, "key", (item) => (
189
+ button({
190
+ class: activeIndex.map((index) =>
191
+ `btn btn-flat justify-start w-full ${itemSizeClass} ${index === item.__index ? "selected" : ""}`.trim()
192
+ ),
193
+ click: () => handleSelect(item)
194
+ }, [
195
+ item.icon
196
+ ? Icon({ name: item.icon, fill: "tertiary", size: iconSizeValue })
197
+ : null,
198
+ div({ class: "column-container gap-xs" }, [
199
+ span({ class: "text-base" }, item.title ?? ""),
200
+ item.snippet ? span({ class: "text-sm text-secondary" }, item.snippet) : null
201
+ ])
202
+ ])
203
+ ))
204
+ ])
205
+ ])
206
+ ]);
207
+ }