@bunnix/components 0.9.2 → 0.9.4

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.
@@ -1,7 +1,7 @@
1
- import Bunnix, { ForEach, useMemo, useRef, useState } from "@bunnix/core";
1
+ import Bunnix, { ForEach, useMemo, useRef, useState, useEffect } from "@bunnix/core";
2
2
  import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
3
3
  import Icon from "./Icon.mjs";
4
- const { div, button, span, hr } = Bunnix;
4
+ const { div, label, input: inputEl, button, span, hr } = Bunnix;
5
5
 
6
6
  const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
7
7
 
@@ -10,7 +10,44 @@ const isSameDay = (a, b) =>
10
10
 
11
11
  const toMidnight = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
12
12
 
13
- const formatDate = (date, formatter) => (date ? formatter.format(date) : "");
13
+ const formatDate = (date, format = "DD/MM/YYYY") => {
14
+ if (!date) return "";
15
+ const day = String(date.getDate()).padStart(2, "0");
16
+ const month = String(date.getMonth() + 1).padStart(2, "0");
17
+ const year = date.getFullYear();
18
+ return `${day}/${month}/${year}`;
19
+ };
20
+
21
+ const parseDate = (str) => {
22
+ if (!str) return null;
23
+ const parts = str.split("/");
24
+ if (parts.length !== 3) return null;
25
+ const day = parseInt(parts[0], 10);
26
+ const month = parseInt(parts[1], 10) - 1;
27
+ const year = parseInt(parts[2], 10);
28
+ if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
29
+ const date = new Date(year, month, day);
30
+ if (date.getDate() !== day || date.getMonth() !== month || date.getFullYear() !== year) {
31
+ return null;
32
+ }
33
+ return date;
34
+ };
35
+
36
+ const applyDateMask = (value) => {
37
+ // Remove all non-digits
38
+ const digits = value.replace(/\D/g, "");
39
+
40
+ // Apply mask DD/MM/YYYY
41
+ let masked = "";
42
+ for (let i = 0; i < digits.length && i < 8; i++) {
43
+ if (i === 2 || i === 4) {
44
+ masked += "/";
45
+ }
46
+ masked += digits[i];
47
+ }
48
+
49
+ return masked;
50
+ };
14
51
 
15
52
  const buildCalendar = (viewDate) => {
16
53
  if (!viewDate) return [];
@@ -49,25 +86,33 @@ const buildCalendar = (viewDate) => {
49
86
 
50
87
  export default function DatePicker({
51
88
  id,
52
- placeholder,
89
+ placeholder = "DD/MM/YYYY",
53
90
  range = false,
54
91
  variant = "regular",
55
92
  size = "regular",
93
+ label: labelText,
94
+ disabled = false,
95
+ value,
96
+ onInput,
97
+ onChange,
98
+ onFocus,
99
+ onBlur,
100
+ input,
101
+ change,
102
+ focus,
103
+ blur,
56
104
  class: className = ""
57
105
  } = {}) {
58
106
  const popoverRef = useRef(null);
107
+ const inputRef = useRef(null);
59
108
  const pickerId = id || `datepicker-${Math.random().toString(36).slice(2, 8)}`;
60
109
  const anchorName = `--${pickerId}`;
61
110
 
62
- const formatter = useMemo([], () => {
63
- return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
64
- });
65
-
66
- const selectedStart = useState(null);
111
+ const selectedStart = useState(value || null);
67
112
  const selectedEnd = useState(null);
68
- const inputValue = useState("");
113
+ const inputValue = useState(value ? formatDate(value) : "");
69
114
 
70
- const viewDate = useState(new Date());
115
+ const viewDate = useState(value || new Date());
71
116
 
72
117
  const calendar = useMemo([viewDate], (value) => buildCalendar(value));
73
118
  const monthLabel = useMemo([viewDate], (value) => {
@@ -92,15 +137,47 @@ export default function DatePicker({
92
137
  }
93
138
  };
94
139
 
140
+ // Handle click outside and escape key for manual popover
141
+ useEffect((popoverElement) => {
142
+ if (!popoverElement) return;
143
+
144
+ const handleClickOutside = (e) => {
145
+ if (!popoverElement.matches(":popover-open")) return;
146
+
147
+ const input = inputRef.current;
148
+ const isClickInside = popoverElement.contains(e.target);
149
+ const isClickOnInput = input && input.contains(e.target);
150
+
151
+ if (!isClickInside && !isClickOnInput) {
152
+ closePopover();
153
+ }
154
+ };
155
+
156
+ const handleEscape = (e) => {
157
+ if (e.key === "Escape" && popoverElement.matches(":popover-open")) {
158
+ closePopover();
159
+ inputRef.current?.focus();
160
+ }
161
+ };
162
+
163
+ document.addEventListener("click", handleClickOutside, true);
164
+ document.addEventListener("keydown", handleEscape);
165
+
166
+ return () => {
167
+ document.removeEventListener("click", handleClickOutside, true);
168
+ document.removeEventListener("keydown", handleEscape);
169
+ };
170
+ }, popoverRef);
171
+
95
172
  const setSelection = (start, end = null) => {
96
173
  selectedStart.set(start);
97
174
  selectedEnd.set(end);
98
175
  if (range) {
99
- const startText = formatDate(start, formatter.get());
100
- const endText = end ? formatDate(end, formatter.get()) : "";
176
+ const startText = formatDate(start);
177
+ const endText = end ? formatDate(end) : "";
101
178
  inputValue.set(endText ? `${startText} - ${endText}` : startText);
102
179
  } else {
103
- inputValue.set(formatDate(start, formatter.get()));
180
+ inputValue.set(formatDate(start));
104
181
  }
105
182
  };
106
183
 
@@ -118,6 +195,12 @@ export default function DatePicker({
118
195
  } else {
119
196
  setSelection(date, null);
120
197
  closePopover();
198
+
199
+ // Trigger change handlers
200
+ const handleChange = onChange ?? change;
201
+ if (handleChange) {
202
+ handleChange({ target: { value: formatDate(date) }, date });
203
+ }
121
204
  }
122
205
  viewDate.set(new Date(date.getFullYear(), date.getMonth(), 1));
123
206
  };
@@ -143,66 +226,159 @@ export default function DatePicker({
143
226
  selectedEnd.set(null);
144
227
  inputValue.set("");
145
228
  closePopover();
229
+
230
+ const handleChange = onChange ?? change;
231
+ if (handleChange) {
232
+ handleChange({ target: { value: "" }, date: null });
233
+ }
146
234
  };
147
235
 
148
- // Single reactive source for the label text
149
- const displayLabel = inputValue.map(v => {
150
- if (v) return v;
151
- if (placeholder) return placeholder;
152
-
153
- const today = new Date();
154
- const fmt = formatter.get();
236
+ const handleOK = () => {
237
+ // Update the main input with the currently selected date when OK is clicked
238
+ const start = selectedStart.get();
239
+ const end = selectedEnd.get();
240
+
155
241
  if (range) {
156
- const tomorrow = new Date(today);
157
- tomorrow.setDate(today.getDate() + 1);
158
- return `${formatDate(today, fmt)} - ${formatDate(tomorrow, fmt)}`;
242
+ if (start && end) {
243
+ const startText = formatDate(start);
244
+ const endText = formatDate(end);
245
+ const value = `${startText} - ${endText}`;
246
+ inputValue.set(value);
247
+
248
+ const handleChange = onChange ?? change;
249
+ if (handleChange) {
250
+ handleChange({ target: { value }, dateStart: start, dateEnd: end });
251
+ }
252
+ } else if (start) {
253
+ const value = formatDate(start);
254
+ inputValue.set(value);
255
+
256
+ const handleChange = onChange ?? change;
257
+ if (handleChange) {
258
+ handleChange({ target: { value }, dateStart: start, dateEnd: null });
259
+ }
260
+ }
261
+ } else {
262
+ if (start) {
263
+ const value = formatDate(start);
264
+ inputValue.set(value);
265
+
266
+ const handleChange = onChange ?? change;
267
+ if (handleChange) {
268
+ handleChange({ target: { value }, date: start });
269
+ }
270
+ }
159
271
  }
160
- return formatDate(today, fmt);
161
- });
272
+
273
+ closePopover();
274
+ };
162
275
 
163
- const hasValue = inputValue.map(v => !!v);
276
+ const handleInputChange = (e) => {
277
+ const rawValue = e.target.value;
278
+ const maskedValue = applyDateMask(rawValue);
279
+ inputValue.set(maskedValue);
280
+
281
+ // Try to parse the date if complete
282
+ if (maskedValue.length === 10) {
283
+ const parsedDate = parseDate(maskedValue);
284
+ if (parsedDate) {
285
+ selectedStart.set(parsedDate);
286
+ viewDate.set(new Date(parsedDate.getFullYear(), parsedDate.getMonth(), 1));
287
+
288
+ const handleChange = onChange ?? change;
289
+ if (handleChange) {
290
+ handleChange({ target: { value: maskedValue }, date: parsedDate });
291
+ }
292
+ }
293
+ }
294
+
295
+ const handleInput = onInput ?? input;
296
+ if (handleInput) {
297
+ handleInput(e);
298
+ }
299
+ };
164
300
 
165
- // DatePicker does not support small size (clamps to regular)
166
- const normalizeSize = (value) => clampSize(value, ["xsmall", "regular", "large", "xlarge"], "regular");
301
+ const handleInputFocus = (e) => {
302
+ openPopover();
303
+
304
+ const handleFocus = onFocus ?? focus;
305
+ if (handleFocus) {
306
+ handleFocus(e);
307
+ }
308
+ };
309
+
310
+ const handleInputBlur = (e) => {
311
+ // Don't close popover on blur - let it handle its own dismissal
312
+ // The popover will close when clicking outside or pressing escape
313
+
314
+ const handleBlur = onBlur ?? blur;
315
+ if (handleBlur) {
316
+ handleBlur(e);
317
+ }
318
+ };
319
+
320
+ const handleCalendarIconClick = (e) => {
321
+ e.preventDefault();
322
+ e.stopPropagation();
323
+ const input = inputRef.current;
324
+ if (input) {
325
+ input.focus();
326
+ }
327
+ };
328
+
329
+ // DatePicker supports regular, large, xlarge (no xsmall, small)
330
+ const normalizeSize = (value) => clampSize(value, ["regular", "large", "xlarge"], "regular");
167
331
  const normalizedSize = normalizeSize(size);
168
332
  const sizeToken = toSizeToken(normalizedSize);
333
+ const sizeClass = sizeToken === "xl" ? "input-xl" : sizeToken === "lg" ? "input-lg" : "";
169
334
  const variantClass = variant === "rounded" ? "rounded-full" : "";
170
- const triggerSizeClass = sizeToken === "xl"
171
- ? "dropdown-xl"
172
- : sizeToken === "lg"
173
- ? "dropdown-lg"
174
- : "";
175
- const iconSizeValue = normalizedSize === "small"
176
- ? "small"
177
- : normalizedSize === "large"
178
- ? "large"
179
- : normalizedSize === "xlarge"
180
- ? "xlarge"
181
- : undefined;
182
-
183
- return div({ class: `datepicker-wrapper ${className}`.trim() }, [
184
- button({
185
- id: pickerId,
186
- class: `dropdown-trigger datepicker-trigger justify-start ${variantClass} ${triggerSizeClass} no-chevron`.trim(),
187
- style: `anchor-name: ${anchorName}`,
188
- click: openPopover
189
- }, [
190
- span({ class: hasValue.map(h => h ? "" : "text-tertiary") }, displayLabel),
191
- Icon({ name: "calendar", fill: "quaternary", size: iconSizeValue, class: "ml-auto" })
335
+ const iconSizeValue = normalizedSize === "large"
336
+ ? "large"
337
+ : normalizedSize === "xlarge"
338
+ ? "xlarge"
339
+ : undefined;
340
+
341
+ return div({ class: `column-container no-margin shrink-0 gap-0 ${className}`.trim() }, [
342
+ labelText && label({ class: "label select-none" }, labelText),
343
+ div({ class: "datepicker-input-wrapper w-full relative" }, [
344
+ inputEl({
345
+ ref: inputRef,
346
+ id: pickerId,
347
+ type: "text",
348
+ value: inputValue,
349
+ placeholder,
350
+ disabled,
351
+ autocomplete: "off",
352
+ class: `input ${sizeClass} ${variantClass} pr-xl`.trim(),
353
+ style: `anchor-name: ${anchorName}`,
354
+ input: handleInputChange,
355
+ focus: handleInputFocus,
356
+ blur: handleInputBlur,
357
+ maxlength: "10"
358
+ }),
359
+ button({
360
+ class: "datepicker-icon-button",
361
+ type: "button",
362
+ disabled,
363
+ click: handleCalendarIconClick,
364
+ tabindex: "-1"
365
+ }, [
366
+ Icon({ name: "calendar", fill: "quaternary", size: iconSizeValue })
367
+ ])
192
368
  ]),
193
369
  div({
194
370
  ref: popoverRef,
195
- popover: "auto",
371
+ popover: "manual",
196
372
  class: "datepicker-popover popover-base",
197
373
  style: `--anchor-id: ${anchorName}`
198
374
  }, [
199
375
  div({ class: "card column-container shadow gap-0 p-0 bg-base datepicker-card" }, [
200
376
  div({ class: "row-container items-center justify-between datepicker-header p-sm no-margin" }, [
201
- button({ class: "btn btn-flat datepicker-nav", click: handlePrevMonth }, [
377
+ button({ type: "button", class: "btn btn-flat datepicker-nav", click: handlePrevMonth }, [
202
378
  span({ class: "icon icon-chevron-left icon-base" })
203
379
  ]),
204
380
  span({ class: "datepicker-title" }, monthLabel),
205
- button({ class: "btn btn-flat datepicker-nav", click: handleNextMonth }, [
381
+ button({ type: "button", class: "btn btn-flat datepicker-nav", click: handleNextMonth }, [
206
382
  span({ class: "icon icon-chevron-right icon-base" })
207
383
  ])
208
384
  ]),
@@ -231,6 +407,7 @@ export default function DatePicker({
231
407
  ].filter(Boolean).join(" ");
232
408
 
233
409
  return button({
410
+ type: "button",
234
411
  class: classNames,
235
412
  click: () => handleDayClick(cell.date)
236
413
  }, cell.date.getDate().toString());
@@ -239,9 +416,9 @@ export default function DatePicker({
239
416
  ]),
240
417
  hr({ class: "no-margin" }),
241
418
  div({ class: "row-container justify-center items-center gap-md p-base shrink-0 datepicker-footer" }, [
242
- button({ class: "btn btn-flat", click: handleClear }, "Clear"),
243
- button({ class: "btn btn-flat", click: handleToday }, "Today"),
244
- button({ class: "btn", click: closePopover }, "OK")
419
+ button({ type: "button", class: "btn btn-flat", click: handleClear }, "Clear"),
420
+ button({ type: "button", class: "btn btn-flat", click: handleToday }, "Today"),
421
+ button({ type: "button", class: "btn", click: handleOK }, "OK")
245
422
  ])
246
423
  ])
247
424
  ])
@@ -136,7 +136,7 @@ export default function Dialog() {
136
136
  VStack({
137
137
  ref: panelRef,
138
138
  gap: "regular",
139
- class: "box-capsule dialog-panel shadow bg-base p-lg items-stretch dialog-appear"
139
+ class: "box-capsule dialog-panel shadow bg-base border-solid p-lg items-stretch dialog-appear"
140
140
  }, [
141
141
  HStack({ alignment: "leading", gap: "small", class: "items-center w-full" }, [
142
142
  Text({ type: "heading4", class: "no-margin" }, dialogState.map((value) => value.title)),
@@ -1,5 +1,6 @@
1
- import Bunnix, { useRef, useEffect } from "@bunnix/core";
1
+ import Bunnix, { useRef, useEffect, useState } from "@bunnix/core";
2
2
  import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
3
+ import { applyMask, getMaskMaxLength } from "../utils/maskUtils.mjs";
3
4
  const { div, label, input: inputEl, datalist, option, span } = Bunnix;
4
5
 
5
6
  export default function InputField({
@@ -11,6 +12,8 @@ export default function InputField({
11
12
  placeholder,
12
13
  label: labelText,
13
14
  disabled = false,
15
+ autocomplete,
16
+ mask,
14
17
  suggestions = [],
15
18
  onInput,
16
19
  onChange,
@@ -26,6 +29,10 @@ export default function InputField({
26
29
  } = {}) {
27
30
  const inputRef = useRef(null);
28
31
  const listId = suggestions.length > 0 ? `list-${Math.random().toString(36).slice(2, 8)}` : null;
32
+
33
+ // Initialize masked value based on initial value and mask
34
+ const initialMaskedValue = mask && value ? applyMask(value, mask) : (value || "");
35
+ const maskedValue = useState(initialMaskedValue);
29
36
 
30
37
  // InputField supports regular, large, xlarge (no xsmall, small)
31
38
  const normalizeSize = (value) => clampSize(value, ["regular", "large", "xlarge"], "regular");
@@ -48,19 +55,61 @@ export default function InputField({
48
55
  const handleBlur = onBlur ?? blur;
49
56
  const handleKeyDown = onKeyDown ?? keydown;
50
57
 
58
+ const handleMaskedInput = (e) => {
59
+ if (mask) {
60
+ const rawValue = e.target.value;
61
+ const masked = applyMask(rawValue, mask);
62
+ maskedValue.set(masked);
63
+
64
+ // Update the input element value
65
+ e.target.value = masked;
66
+
67
+ // Create a new event with the masked value
68
+ const maskedEvent = {
69
+ ...e,
70
+ target: { ...e.target, value: masked }
71
+ };
72
+
73
+ if (handleInput) {
74
+ handleInput(maskedEvent);
75
+ }
76
+ if (handleChange) {
77
+ handleChange(maskedEvent);
78
+ }
79
+ } else {
80
+ if (handleInput) {
81
+ handleInput(e);
82
+ }
83
+ }
84
+ };
85
+
86
+ const handleMaskedChange = (e) => {
87
+ if (!mask && handleChange) {
88
+ handleChange(e);
89
+ }
90
+ };
91
+
92
+ // Determine maxlength based on mask
93
+ const maxLength = mask ? getMaskMaxLength(mask) : rest.maxlength;
94
+
95
+ // Remove maxlength from rest to avoid override
96
+ const { maxlength: _maxlength, ...restWithoutMaxLength } = rest;
97
+
51
98
  const inputElement = inputEl({
52
99
  ref: inputRef,
53
100
  type,
54
- value: value ?? "",
101
+ value: mask ? maskedValue : (value ?? ""),
55
102
  placeholder: placeholder ?? "", // Ensure placeholder is never undefined to avoid "false" text
56
103
  disabled,
104
+ autocomplete: autocomplete ?? "off", // Default to off to prevent browser autocomplete suggestions
105
+ maxlength: maxLength,
57
106
  class: `input ${combinedClass}`.trim(),
58
- input: handleInput,
59
- change: handleChange,
107
+ input: handleMaskedInput,
108
+ change: handleMaskedChange,
60
109
  focus: handleFocus,
61
110
  blur: handleBlur,
62
111
  keydown: handleKeyDown,
63
- ...rest
112
+ ...restWithoutMaxLength
64
113
  });
65
114
 
66
115
  const iconSizeClass = sizeToken === "xl"
@@ -76,7 +125,7 @@ export default function InputField({
76
125
  ])
77
126
  : inputElement;
78
127
 
79
- return div({ class: "column-container no-margin shrink-0" }, [
128
+ return div({ class: "column-container no-margin shrink-0 gap-0" }, [
80
129
  labelText && label({ class: "label select-none" }, labelText),
81
130
  inputBlock,
82
131
  listId && datalist({ id: listId },
@@ -0,0 +1,81 @@
1
+ import Bunnix from "@bunnix/core";
2
+ import { clampSize, toSizeToken } from "../utils/sizeUtils.mjs";
3
+
4
+ const { div } = Bunnix;
5
+
6
+ export default function ProgressBar({
7
+ value = 0,
8
+ min = 0,
9
+ max = 100,
10
+ size,
11
+ color = "default",
12
+ class: className = "",
13
+ ...rest
14
+ } = {}) {
15
+ const isState = (val) => val && typeof val.map === "function";
16
+ const normalizeSize = (val) =>
17
+ clampSize(val, ["xsmall", "small", "regular", "large", "xlarge"], "regular");
18
+
19
+ const sizeState = isState(size) ? size : null;
20
+ const classState = isState(className) ? className : null;
21
+ const colorState = isState(color) ? color : null;
22
+ const valueState = isState(value) ? value : null;
23
+ const minState = isState(min) ? min : null;
24
+ const maxState = isState(max) ? max : null;
25
+
26
+ const buildClass = (sizeValue, classValue) => {
27
+ const normalizedSize = normalizeSize(sizeValue);
28
+ const sizeToken = toSizeToken(normalizedSize);
29
+ const sizeClass = sizeToken ? `progress-bar-${sizeToken}` : "";
30
+ return `progress-bar ${sizeClass} ${classValue || ""}`.trim();
31
+ };
32
+
33
+ const buildFillClass = (colorValue) => {
34
+ const resolvedColor = colorValue || "default";
35
+ return `progress-bar-fill text-${resolvedColor}`.trim();
36
+ };
37
+
38
+ const clampPercent = (val, minValue, maxValue) => {
39
+ const safeMin = Number.isFinite(Number(minValue)) ? Number(minValue) : 0;
40
+ const safeMax = Number.isFinite(Number(maxValue)) ? Number(maxValue) : 100;
41
+ const safeValue = Number.isFinite(Number(val)) ? Number(val) : 0;
42
+
43
+ if (safeMax <= safeMin) return 0;
44
+
45
+ const rawPercent = ((safeValue - safeMin) / (safeMax - safeMin)) * 100;
46
+ return Math.min(100, Math.max(0, rawPercent));
47
+ };
48
+
49
+ const buildFillStyle = (val, minValue, maxValue) =>
50
+ `width: ${clampPercent(val, minValue, maxValue)}%;`;
51
+
52
+ const combinedClass = sizeState
53
+ ? sizeState.map((value) => buildClass(value, classState ? classState.get() : className))
54
+ : classState
55
+ ? classState.map((value) => buildClass(size, value))
56
+ : buildClass(size, className);
57
+
58
+ const fillClass = colorState ? colorState.map((value) => buildFillClass(value)) : buildFillClass(color);
59
+
60
+ const fillStyle = valueState
61
+ ? valueState.map((val) =>
62
+ buildFillStyle(val, minState ? minState.get() : min, maxState ? maxState.get() : max)
63
+ )
64
+ : minState
65
+ ? minState.map((val) => buildFillStyle(value, val, maxState ? maxState.get() : max))
66
+ : maxState
67
+ ? maxState.map((val) => buildFillStyle(value, min, val))
68
+ : buildFillStyle(value, min, max);
69
+
70
+ return div(
71
+ {
72
+ class: combinedClass,
73
+ role: "progressbar",
74
+ "aria-valuemin": min,
75
+ "aria-valuemax": max,
76
+ "aria-valuenow": value,
77
+ ...rest,
78
+ },
79
+ [div({ class: fillClass, style: fillStyle })],
80
+ );
81
+ }