@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,36 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { label, input, span } = Bunnix;
3
+
4
+ export default function Checkbox({
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: "checkbox",
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,44 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { select, option } = Bunnix;
3
+
4
+ export default function ComboBox({
5
+ options = [],
6
+ selection,
7
+ size,
8
+ class: className = "",
9
+ onChange,
10
+ change,
11
+ ...rest
12
+ } = {}, children) {
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" ? "input-lg" : normalizedSize === "xl" ? "input-xl" : "";
21
+ const selectionState = selection && typeof selection.map === "function" ? selection : null;
22
+ const handleChangeExternal = onChange ?? change;
23
+
24
+ const handleChange = (event) => {
25
+ if (selectionState) {
26
+ selectionState.set(event.target.value);
27
+ }
28
+ if (handleChangeExternal) handleChangeExternal(event);
29
+ };
30
+
31
+ const resolvedChildren = children ?? options.map((opt) => {
32
+ if (typeof opt === "string") {
33
+ return option({ value: opt }, opt);
34
+ }
35
+ return option({ value: opt.value }, opt.label ?? opt.value);
36
+ });
37
+
38
+ return select({
39
+ class: `${sizeClass} ${className}`.trim(),
40
+ value: selection ?? "",
41
+ change: handleChange,
42
+ ...rest
43
+ }, resolvedChildren);
44
+ }
@@ -0,0 +1,27 @@
1
+ import Bunnix from "@bunnix/core";
2
+
3
+ const { div } = Bunnix;
4
+
5
+ const typeClassMap = {
6
+ main: "main-container",
7
+ content: "main-content",
8
+ page: "page-layout"
9
+ };
10
+
11
+ const directionClassMap = {
12
+ horizontal: "row-container",
13
+ vertical: "column-container"
14
+ };
15
+
16
+ export default function Container({
17
+ type,
18
+ direction,
19
+ class: className = "",
20
+ ...rest
21
+ } = {}, children) {
22
+ const typeClass = typeClassMap[type] || "";
23
+ const directionClass = directionClassMap[direction] || "";
24
+ const combinedClass = `${typeClass} ${directionClass} ${className}`.trim();
25
+
26
+ return div({ class: combinedClass, ...rest }, children);
27
+ }
@@ -0,0 +1,251 @@
1
+ import Bunnix, { ForEach, useMemo, useRef, useState } from "@bunnix/core";
2
+ import Icon from "./Icon.mjs";
3
+ const { div, button, span, hr } = Bunnix;
4
+
5
+ const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
6
+
7
+ const isSameDay = (a, b) =>
8
+ a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
9
+
10
+ const toMidnight = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
11
+
12
+ const formatDate = (date, formatter) => (date ? formatter.format(date) : "");
13
+
14
+ const buildCalendar = (viewDate) => {
15
+ if (!viewDate) return [];
16
+ const year = viewDate.getFullYear();
17
+ const month = viewDate.getMonth();
18
+ const startOfMonth = new Date(year, month, 1);
19
+ const startDay = startOfMonth.getDay();
20
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
21
+ const daysInPrev = new Date(year, month, 0).getDate();
22
+
23
+ const cells = [];
24
+ for (let i = 0; i < 42; i += 1) {
25
+ let day;
26
+ let cellMonth = month;
27
+ let outside = false;
28
+
29
+ if (i < startDay) {
30
+ day = daysInPrev - startDay + i + 1;
31
+ cellMonth = month - 1;
32
+ outside = true;
33
+ } else if (i >= startDay + daysInMonth) {
34
+ day = i - (startDay + daysInMonth) + 1;
35
+ cellMonth = month + 1;
36
+ outside = true;
37
+ } else {
38
+ day = i - startDay + 1;
39
+ }
40
+
41
+ const cellDate = new Date(year, cellMonth, day);
42
+ const key = `${cellDate.getFullYear()}-${cellDate.getMonth()}-${cellDate.getDate()}-${outside ? "o" : "i"}`;
43
+ cells.push({ key, date: cellDate, outside });
44
+ }
45
+
46
+ return cells;
47
+ };
48
+
49
+ export default function DatePicker({
50
+ id,
51
+ placeholder,
52
+ range = false,
53
+ variant = "regular",
54
+ size = "md",
55
+ class: className = ""
56
+ } = {}) {
57
+ const popoverRef = useRef(null);
58
+ const pickerId = id || `datepicker-${Math.random().toString(36).slice(2, 8)}`;
59
+ const anchorName = `--${pickerId}`;
60
+
61
+ const formatter = useMemo([], () => {
62
+ return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
63
+ });
64
+
65
+ const selectedStart = useState(null);
66
+ const selectedEnd = useState(null);
67
+ const inputValue = useState("");
68
+
69
+ const viewDate = useState(new Date());
70
+
71
+ const calendar = useMemo([viewDate], (value) => buildCalendar(value));
72
+ const monthLabel = useMemo([viewDate], (value) => {
73
+ try {
74
+ return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(value);
75
+ } catch (error) {
76
+ return new Intl.DateTimeFormat(undefined, { month: "short", year: "numeric" }).format(value);
77
+ }
78
+ });
79
+
80
+ const openPopover = () => {
81
+ const popover = popoverRef.current;
82
+ if (popover && !popover.matches(":popover-open")) {
83
+ popover.showPopover();
84
+ }
85
+ };
86
+
87
+ const closePopover = () => {
88
+ const popover = popoverRef.current;
89
+ if (popover && popover.matches(":popover-open")) {
90
+ popover.hidePopover();
91
+ }
92
+ };
93
+
94
+ const setSelection = (start, end = null) => {
95
+ selectedStart.set(start);
96
+ selectedEnd.set(end);
97
+ if (range) {
98
+ const startText = formatDate(start, formatter.get());
99
+ const endText = end ? formatDate(end, formatter.get()) : "";
100
+ inputValue.set(endText ? `${startText} - ${endText}` : startText);
101
+ } else {
102
+ inputValue.set(formatDate(start, formatter.get()));
103
+ }
104
+ };
105
+
106
+ const handleDayClick = (date) => {
107
+ if (range) {
108
+ const start = selectedStart.get();
109
+ const end = selectedEnd.get();
110
+ if (!start || end) {
111
+ setSelection(date, null);
112
+ } else if (date < start) {
113
+ setSelection(date, start);
114
+ } else {
115
+ setSelection(start, date);
116
+ }
117
+ } else {
118
+ setSelection(date, null);
119
+ closePopover();
120
+ }
121
+ viewDate.set(new Date(date.getFullYear(), date.getMonth(), 1));
122
+ };
123
+
124
+ const handlePrevMonth = () => {
125
+ const current = viewDate.get();
126
+ viewDate.set(new Date(current.getFullYear(), current.getMonth() - 1, 1));
127
+ };
128
+
129
+ const handleNextMonth = () => {
130
+ const current = viewDate.get();
131
+ viewDate.set(new Date(current.getFullYear(), current.getMonth() + 1, 1));
132
+ };
133
+
134
+ const handleToday = () => {
135
+ const today = toMidnight(new Date());
136
+ setSelection(today, null);
137
+ viewDate.set(new Date(today.getFullYear(), today.getMonth(), 1));
138
+ };
139
+
140
+ const handleClear = () => {
141
+ selectedStart.set(null);
142
+ selectedEnd.set(null);
143
+ inputValue.set("");
144
+ closePopover();
145
+ };
146
+
147
+ // Single reactive source for the label text
148
+ const displayLabel = inputValue.map(v => {
149
+ if (v) return v;
150
+ if (placeholder) return placeholder;
151
+
152
+ const today = new Date();
153
+ const fmt = formatter.get();
154
+ if (range) {
155
+ const tomorrow = new Date(today);
156
+ tomorrow.setDate(today.getDate() + 1);
157
+ return `${formatDate(today, fmt)} - ${formatDate(tomorrow, fmt)}`;
158
+ }
159
+ return formatDate(today, fmt);
160
+ });
161
+
162
+ const hasValue = inputValue.map(v => !!v);
163
+
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
+ };
170
+ const normalizedSize = normalizeSize(size);
171
+ const variantClass = variant === "rounded" ? "rounded-full" : "";
172
+ const triggerSizeClass = normalizedSize === "xl"
173
+ ? "dropdown-xl"
174
+ : normalizedSize === "lg"
175
+ ? "dropdown-lg"
176
+ : "";
177
+ const iconSizeValue = normalizedSize === "sm"
178
+ ? "sm"
179
+ : normalizedSize === "lg"
180
+ ? "lg"
181
+ : normalizedSize === "xl"
182
+ ? "xl"
183
+ : undefined;
184
+
185
+ return div({ class: `datepicker-wrapper ${className}`.trim() }, [
186
+ button({
187
+ id: pickerId,
188
+ class: `dropdown-trigger datepicker-trigger justify-start ${variantClass} ${triggerSizeClass} no-chevron`.trim(),
189
+ style: `anchor-name: ${anchorName}`,
190
+ click: openPopover
191
+ }, [
192
+ span({ class: hasValue.map(h => h ? "" : "text-tertiary") }, displayLabel),
193
+ Icon({ name: "calendar", fill: "quaternary", size: iconSizeValue, class: "ml-auto" })
194
+ ]),
195
+ div({
196
+ ref: popoverRef,
197
+ popover: "auto",
198
+ class: "datepicker-popover popover-base",
199
+ style: `--anchor-id: ${anchorName}`
200
+ }, [
201
+ div({ class: "card column-container shadow gap-0 p-0 bg-base datepicker-card" }, [
202
+ div({ class: "row-container items-center justify-between datepicker-header p-sm no-margin" }, [
203
+ button({ class: "btn btn-flat datepicker-nav", click: handlePrevMonth }, [
204
+ span({ class: "icon icon-chevron-left icon-base" })
205
+ ]),
206
+ span({ class: "datepicker-title" }, monthLabel),
207
+ button({ class: "btn btn-flat datepicker-nav", click: handleNextMonth }, [
208
+ span({ class: "icon icon-chevron-right icon-base" })
209
+ ])
210
+ ]),
211
+ div({ class: "datepicker-body" }, [
212
+ div({ class: "datepicker-weekdays" }, WEEKDAYS.map((day) =>
213
+ span({ class: "datepicker-weekday" }, day)
214
+ )),
215
+ div({ class: "datepicker-grid" },
216
+ ForEach(calendar, "key", (cell) => {
217
+ const start = selectedStart.get();
218
+ const end = selectedEnd.get();
219
+ const inRange = range && start && end && cell.date >= start && cell.date <= end;
220
+ const isStart = range && start && isSameDay(cell.date, start);
221
+ const isEnd = range && end && isSameDay(cell.date, end);
222
+ const isSelected = !range && start && isSameDay(cell.date, start);
223
+ const isToday = isSameDay(cell.date, toMidnight(new Date()));
224
+
225
+ const classNames = [
226
+ "datepicker-cell",
227
+ cell.outside ? "is-outside" : "",
228
+ inRange ? "is-in-range" : "",
229
+ isStart ? "is-range-start" : "",
230
+ isEnd ? "is-range-end" : "",
231
+ isSelected ? "is-selected" : "",
232
+ isToday ? "is-today" : ""
233
+ ].filter(Boolean).join(" ");
234
+
235
+ return button({
236
+ class: classNames,
237
+ click: () => handleDayClick(cell.date)
238
+ }, cell.date.getDate().toString());
239
+ })
240
+ )
241
+ ]),
242
+ hr({ class: "no-margin" }),
243
+ div({ class: "row-container justify-center items-center gap-md p-base shrink-0 datepicker-footer" }, [
244
+ button({ class: "btn btn-flat", click: handleClear }, "Clear"),
245
+ button({ class: "btn btn-flat", click: handleToday }, "Today"),
246
+ button({ class: "btn", click: closePopover }, "OK")
247
+ ])
248
+ ])
249
+ ])
250
+ ]);
251
+ }
@@ -0,0 +1,166 @@
1
+ import Bunnix, { useEffect, useRef, useState, Show } from "@bunnix/core";
2
+ import VStack from "./VStack.mjs";
3
+ import HStack from "./HStack.mjs";
4
+ import Button from "./Button.mjs";
5
+ import Text from "./Text.mjs";
6
+ import Icon from "./Icon.mjs";
7
+
8
+ const { div, dialog, kbd, hr } = Bunnix;
9
+
10
+ const defaultDialog = {
11
+ open: false,
12
+ title: "",
13
+ message: "",
14
+ confirmation: {
15
+ text: "",
16
+ action: null,
17
+ variant: "regular",
18
+ disabled: false
19
+ },
20
+ extra: {
21
+ text: "",
22
+ action: null
23
+ },
24
+ content: null
25
+ };
26
+
27
+ export const dialogState = useState(defaultDialog);
28
+
29
+ export const showDialog = ({ title, message, confirmation, content } = {}) => {
30
+ dialogState.set({
31
+ open: true,
32
+ title: title ?? "",
33
+ message: message ?? "",
34
+ confirmation: {
35
+ text: confirmation?.text ?? defaultDialog.confirmation.text,
36
+ action: confirmation?.action ?? null,
37
+ variant: confirmation?.variant ?? defaultDialog.confirmation.variant,
38
+ disabled: confirmation?.disabled ?? defaultDialog.confirmation.disabled
39
+ },
40
+ extra: {
41
+ text: confirmation?.extra?.text ?? defaultDialog.extra.text,
42
+ action: confirmation?.extra?.action ?? null
43
+ },
44
+ content: content ?? null
45
+ });
46
+ };
47
+
48
+ export const hideDialog = () => {
49
+ dialogState.set({ ...dialogState.get(), open: false });
50
+ };
51
+
52
+ export default function Dialog() {
53
+ const dialogRef = useRef(null);
54
+ const setConfirmDisabled = (disabled) => {
55
+ const current = dialogState.get();
56
+ dialogState.set({
57
+ ...current,
58
+ confirmation: {
59
+ ...current.confirmation,
60
+ disabled: !!disabled
61
+ }
62
+ });
63
+ };
64
+
65
+ useEffect((value) => {
66
+ const element = dialogRef.current;
67
+ if (!element) return;
68
+
69
+ if (value.open) {
70
+ if (!element.open) {
71
+ element.showModal();
72
+ }
73
+ } else if (element.open) {
74
+ element.close();
75
+ }
76
+ }, [dialogState]);
77
+
78
+ const confirmationText = dialogState.map((value) => value.confirmation?.text ?? defaultDialog.confirmation.text);
79
+ const confirmationVariant = dialogState.map((value) => value.confirmation?.variant ?? defaultDialog.confirmation.variant);
80
+ const confirmationDisabled = dialogState.map((value) => !!value.confirmation?.disabled);
81
+ const showConfirmation = dialogState.map((value) => !!value.confirmation?.text);
82
+ const extraText = dialogState.map((value) => value.extra?.text ?? "");
83
+ const showExtra = dialogState.map((value) => !!value.extra?.text);
84
+ const showContent = dialogState.map((value) => typeof value.content === "function");
85
+ const showMessage = dialogState.map((value) => !!value.message && typeof value.content !== "function");
86
+ const showBody = dialogState.map((value) => !!value.message || typeof value.content === "function");
87
+
88
+ return dialog({
89
+ ref: dialogRef,
90
+ class: "dialog-base dialog-backdrop fixed inset-0 w-full h-full row-container items-center justify-center",
91
+ cancel: () => {
92
+ hideDialog();
93
+ },
94
+ keydown: (event) => {
95
+ if (event?.key !== "Enter") return;
96
+ const current = dialogState.get();
97
+ const hasConfirmation = !!current?.confirmation?.text;
98
+ const isDisabled = !!current?.confirmation?.disabled;
99
+ if (!hasConfirmation || isDisabled) {
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ return;
103
+ }
104
+ event.preventDefault();
105
+ const action = current?.confirmation?.action;
106
+ if (typeof action === "function") {
107
+ action();
108
+ }
109
+ hideDialog();
110
+ }
111
+ }, [
112
+ VStack({ gap: "regular", class: "box-capsule shadow bg-base w-full max-w-400 p-lg items-stretch dialog-appear" }, [
113
+ HStack({ alignment: "leading", gap: "small", class: "items-center w-full" }, [
114
+ Text({ type: "heading4", class: "no-margin" }, dialogState.map((value) => value.title)),
115
+ div({ class: "spacer-h" }),
116
+ Button({
117
+ variant: "flat",
118
+ class: "p-xs",
119
+ click: hideDialog
120
+ }, Icon({ name: "close" }))
121
+ ]),
122
+ Text({
123
+ type: "paragraph",
124
+ color: "secondary",
125
+ class: showMessage.map((value) => `whitespace-pre-line ${value ? "" : "hidden"}`.trim())
126
+ }, dialogState.map((value) => value.message)),
127
+ Show(showContent, () => {
128
+ const current = dialogState.get();
129
+ if (typeof current.content !== "function") return null;
130
+ return div({ class: "column-container gap-sm" }, current.content({ setConfirmDisabled }));
131
+ }),
132
+ HStack({ alignment: "trailing", gap: "regular", class: "w-full" }, [
133
+ Show(showExtra, () => Button({
134
+ variant: "flat",
135
+ click: () => {
136
+ const current = dialogState.get();
137
+ const action = current.extra?.action;
138
+ if (typeof action === "function") {
139
+ action();
140
+ }
141
+ hideDialog();
142
+ }
143
+ }, extraText)),
144
+ Show(showConfirmation, () => Button({
145
+ autofocus: true,
146
+ variant: confirmationVariant,
147
+ disabled: confirmationDisabled,
148
+ click: () => {
149
+ const current = dialogState.get();
150
+ const action = current.confirmation?.action;
151
+ if (typeof action === "function") {
152
+ action();
153
+ }
154
+ hideDialog();
155
+ }
156
+ }, [
157
+ confirmationText,
158
+ kbd({ class: "text-white text-sm whitespace-nowrap" }, [
159
+ Icon({ name: "return-arrow", fill: "white", size: "xs" }),
160
+ "Enter"
161
+ ])
162
+ ]))
163
+ ])
164
+ ])
165
+ ]);
166
+ }
@@ -0,0 +1,110 @@
1
+ import Bunnix, { useRef, useState, useMemo } from "@bunnix/core";
2
+ const { div, button, hr, span } = Bunnix;
3
+
4
+ let dropdownCounter = 0;
5
+
6
+ export default function DropdownMenu({
7
+ items = [],
8
+ id,
9
+ align = "left",
10
+ placeholder = "Select option...",
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
+ const dropdownId = id || `dropdown-instance-${++dropdownCounter}`;
24
+ const anchorName = `--${dropdownId}`;
25
+
26
+ const initialItem = items.find(item => item.selected) || null;
27
+ const selectedItem = useState(initialItem);
28
+ const hasSelection = selectedItem.map(s => !!s);
29
+ const currentTitle = selectedItem.map(s => s ? s.title : placeholder);
30
+
31
+ const handleToggle = () => {
32
+ const popover = popoverRef.current;
33
+ if (!popover) return;
34
+ if (popover.matches(":popover-open")) {
35
+ popover.hidePopover();
36
+ } else {
37
+ popover.showPopover();
38
+ }
39
+ };
40
+
41
+ const handleItemClick = (item) => {
42
+ selectedItem.set(item);
43
+ if (item?.click) item.click();
44
+ if (onSelect) onSelect(item);
45
+ const popover = popoverRef.current;
46
+ if (popover) {
47
+ popover.hidePopover();
48
+ }
49
+ };
50
+
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"
54
+ ? "icon-sm"
55
+ : normalizedSize === "lg"
56
+ ? "icon-lg"
57
+ : normalizedSize === "xl"
58
+ ? "icon-xl"
59
+ : "";
60
+
61
+ return div({ class: "menu-wrapper" }, [
62
+ button({
63
+ id: dropdownId,
64
+ style: `anchor-name: ${anchorName}`,
65
+ class: `dropdown-trigger justify-start ${sizeClass} ${className}`.trim(),
66
+ click: handleToggle
67
+ }, [
68
+ // Reactive Icon: stable element, reactive class
69
+ span({
70
+ class: selectedItem.map(s => {
71
+ if (!s?.icon) return "hidden";
72
+ const tint = s.destructive ? "bg-destructive" : "bg-primary";
73
+ return `icon ${iconSizeClass} ${s.icon} ${tint}`.trim();
74
+ })
75
+ }),
76
+ // Reactive Title: text with dimmed style when empty
77
+ span({ class: hasSelection.map(selected => selected ? "" : "text-secondary") }, currentTitle)
78
+ ]),
79
+
80
+ div({
81
+ ref: popoverRef,
82
+ popover: "auto",
83
+ class: `menu-popover popover-base menu-anchor-${align}`,
84
+ style: `--anchor-id: ${anchorName}`
85
+ }, [
86
+ div({ class: "card column-container shadow gap-sm w-min-150 p-sm bg-base" },
87
+ items.map((item) => {
88
+ if (item.isSeparator) {
89
+ return hr({ class: "no-margin" });
90
+ }
91
+
92
+ const isCurrent = selectedItem.map(s => s?.title === item.title);
93
+
94
+ return button({
95
+ class: isCurrent.map(active => `btn btn-flat justify-start w-full ${itemSizeClass} ${active ? 'selected' : ''}`.trim()),
96
+ click: () => handleItemClick(item)
97
+ }, [
98
+ span({
99
+ class: isCurrent.map(active => {
100
+ const tint = active ? "bg-white" : item.destructive ? "bg-destructive" : "bg-primary";
101
+ return `icon ${iconSizeClass} ${item.icon} ${tint}`.trim();
102
+ })
103
+ }),
104
+ item.title
105
+ ]);
106
+ })
107
+ )
108
+ ])
109
+ ]);
110
+ }
@@ -0,0 +1,40 @@
1
+ import Bunnix from "@bunnix/core";
2
+ const { div } = Bunnix;
3
+
4
+ export default function Grid({
5
+ type = "flow",
6
+ columns = [],
7
+ gap = "regular",
8
+ class: className = "",
9
+ style: inlineStyle = "",
10
+ ...rest
11
+ } = {}, children) {
12
+ const gapMap = {
13
+ small: "gap-sm",
14
+ regular: "gap-md",
15
+ large: "gap-lg"
16
+ };
17
+
18
+ const isFixed = type === "fixed";
19
+
20
+ // Base structural class
21
+ // 'grid-flow' uses flex-wrap: wrap
22
+ // 'column-container' provides display: flex which we override with display: grid if fixed
23
+ const baseClass = isFixed ? "column-container" : "grid-flow";
24
+
25
+ let gridStyle = inlineStyle;
26
+ if (isFixed && columns.length > 0) {
27
+ const template = columns.map(col => {
28
+ if (col.size === "auto") return "1fr";
29
+ if (col.size === "minmax") return "minmax(min-content, 1fr)";
30
+ return col.size || "1fr";
31
+ }).join(" ");
32
+ gridStyle = `display: grid; grid-template-columns: ${template}; ${inlineStyle}`.trim();
33
+ }
34
+
35
+ return div({
36
+ class: `${baseClass} ${gapMap[gap]} ${className}`.trim(),
37
+ style: gridStyle,
38
+ ...rest
39
+ }, children);
40
+ }