@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.
- package/@types/index.d.ts +269 -0
- package/LICENSE +7 -0
- package/README.md +184 -0
- package/package.json +53 -0
- package/src/components/AccordionGroup.mjs +37 -0
- package/src/components/Badge.mjs +49 -0
- package/src/components/Button.mjs +76 -0
- package/src/components/Checkbox.mjs +36 -0
- package/src/components/ComboBox.mjs +44 -0
- package/src/components/Container.mjs +27 -0
- package/src/components/DatePicker.mjs +251 -0
- package/src/components/Dialog.mjs +166 -0
- package/src/components/DropdownMenu.mjs +110 -0
- package/src/components/Grid.mjs +40 -0
- package/src/components/HStack.mjs +34 -0
- package/src/components/Icon.mjs +32 -0
- package/src/components/InputField.mjs +78 -0
- package/src/components/NavigationBar.mjs +47 -0
- package/src/components/PageHeader.mjs +13 -0
- package/src/components/PageSection.mjs +20 -0
- package/src/components/PopoverMenu.mjs +87 -0
- package/src/components/RadioCheckbox.mjs +36 -0
- package/src/components/SearchBox.mjs +207 -0
- package/src/components/Sidebar.mjs +187 -0
- package/src/components/Table.mjs +254 -0
- package/src/components/Text.mjs +38 -0
- package/src/components/TimePicker.mjs +172 -0
- package/src/components/ToastNotification.mjs +105 -0
- package/src/components/ToggleSwitch.mjs +26 -0
- package/src/components/VStack.mjs +35 -0
- package/src/icons/add-circle.svg +1 -0
- package/src/icons/add.svg +1 -0
- package/src/icons/alt.svg +1 -0
- package/src/icons/archive.svg +1 -0
- package/src/icons/at.svg +1 -0
- package/src/icons/attestation.svg +1 -0
- package/src/icons/bell.svg +4 -0
- package/src/icons/bookmark.svg +1 -0
- package/src/icons/bot.svg +1 -0
- package/src/icons/button.svg +1 -0
- package/src/icons/calculate.svg +1 -0
- package/src/icons/calendar.svg +1 -0
- package/src/icons/chart.svg +1 -0
- package/src/icons/check.svg +1 -0
- package/src/icons/chevron-down.svg +1 -0
- package/src/icons/chevron-left.svg +1 -0
- package/src/icons/chevron-right.svg +1 -0
- package/src/icons/clip.svg +1 -0
- package/src/icons/clock.svg +4 -0
- package/src/icons/close-circle.svg +4 -0
- package/src/icons/close.svg +1 -0
- package/src/icons/cloud-download.svg +1 -0
- package/src/icons/cloud-upload.svg +1 -0
- package/src/icons/cloud.svg +1 -0
- package/src/icons/columns-layout.svg +1 -0
- package/src/icons/command.svg +1 -0
- package/src/icons/cube.svg +1 -0
- package/src/icons/delete.svg +4 -0
- package/src/icons/dollar.svg +4 -0
- package/src/icons/download.svg +1 -0
- package/src/icons/draw.svg +1 -0
- package/src/icons/duplicate.svg +4 -0
- package/src/icons/edit.svg +1 -0
- package/src/icons/exclamation-mark.svg +1 -0
- package/src/icons/eye-open.svg +1 -0
- package/src/icons/eye.svg +1 -0
- package/src/icons/file-html.svg +1 -0
- package/src/icons/file.svg +4 -0
- package/src/icons/finger.svg +1 -0
- package/src/icons/flag.svg +1 -0
- package/src/icons/folder.svg +1 -0
- package/src/icons/function.svg +1 -0
- package/src/icons/gear.svg +1 -0
- package/src/icons/gift.svg +1 -0
- package/src/icons/globe.svg +4 -0
- package/src/icons/grid.svg +1 -0
- package/src/icons/hand.svg +1 -0
- package/src/icons/heart.svg +4 -0
- package/src/icons/home.svg +4 -0
- package/src/icons/image.svg +1 -0
- package/src/icons/inbox.svg +4 -0
- package/src/icons/info.svg +1 -0
- package/src/icons/key.svg +1 -0
- package/src/icons/lamp.svg +1 -0
- package/src/icons/link.svg +1 -0
- package/src/icons/location.svg +1 -0
- package/src/icons/locker.svg +1 -0
- package/src/icons/login.svg +1 -0
- package/src/icons/logout.svg +4 -0
- package/src/icons/mail.svg +4 -0
- package/src/icons/map.svg +4 -0
- package/src/icons/markup.svg +1 -0
- package/src/icons/merge.svg +1 -0
- package/src/icons/more-horizontal.svg +5 -0
- package/src/icons/more-vertical.svg +5 -0
- package/src/icons/mouse.svg +1 -0
- package/src/icons/palette.svg +1 -0
- package/src/icons/password.svg +1 -0
- package/src/icons/pencil.svg +1 -0
- package/src/icons/people.svg +4 -0
- package/src/icons/person-add.svg +1 -0
- package/src/icons/person-remove.svg +1 -0
- package/src/icons/person.svg +5 -0
- package/src/icons/pin.svg +1 -0
- package/src/icons/question-circle.svg +4 -0
- package/src/icons/remove-circle.svg +1 -0
- package/src/icons/return-arrow.svg +2 -0
- package/src/icons/save.svg +1 -0
- package/src/icons/search.svg +1 -0
- package/src/icons/sections.svg +1 -0
- package/src/icons/send.svg +1 -0
- package/src/icons/share.svg +1 -0
- package/src/icons/shine.svg +1 -0
- package/src/icons/sliders.svg +1 -0
- package/src/icons/star.svg +4 -0
- package/src/icons/storage.svg +1 -0
- package/src/icons/success-circle.svg +4 -0
- package/src/icons/swap.svg +1 -0
- package/src/icons/switch.svg +1 -0
- package/src/icons/sync.svg +4 -0
- package/src/icons/table.svg +4 -0
- package/src/icons/tag.svg +4 -0
- package/src/icons/terminal.svg +1 -0
- package/src/icons/text.svg +1 -0
- package/src/icons/thumb-down.svg +1 -0
- package/src/icons/thumb-up.svg +1 -0
- package/src/icons/timer.svg +4 -0
- package/src/icons/toggle.svg +1 -0
- package/src/icons/trash.svg +1 -0
- package/src/icons/update-page.svg +1 -0
- package/src/icons/upload.svg +1 -0
- package/src/icons/video.svg +1 -0
- package/src/icons/wallet.svg +1 -0
- package/src/icons/window.svg +1 -0
- package/src/index.mjs +29 -0
- package/src/styles/accordion.css +70 -0
- package/src/styles/buttons.css +118 -0
- package/src/styles/colors.css +131 -0
- package/src/styles/controls.css +504 -0
- package/src/styles/datepicker.css +140 -0
- package/src/styles/interactable.css +16 -0
- package/src/styles/layout.css +444 -0
- package/src/styles/links.css +38 -0
- package/src/styles/main.css +16 -0
- package/src/styles/media.css +155 -0
- package/src/styles/menu.css +168 -0
- package/src/styles/motion.css +66 -0
- package/src/styles/table.css +78 -0
- package/src/styles/timepicker.css +87 -0
- package/src/styles/typography.css +94 -0
- package/src/styles/variables.css +218 -0
- 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
|
+
}
|