@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,28 +1,75 @@
1
- import Bunnix, { useRef, useState, useMemo } from "@bunnix/core";
1
+ import Bunnix, { useRef, useState, useMemo, 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, input } = Bunnix;
4
+ const { div, label, input: inputEl, button, span, hr } = Bunnix;
5
5
 
6
6
  const formatSegment = (val) => val.toString().padStart(2, '0');
7
7
 
8
+ const applyTimeMask = (value) => {
9
+ // Remove all non-digits
10
+ const digits = value.replace(/\D/g, "");
11
+
12
+ // Apply mask HH:MM
13
+ let masked = "";
14
+ for (let i = 0; i < digits.length && i < 4; i++) {
15
+ if (i === 2) {
16
+ masked += ":";
17
+ }
18
+ masked += digits[i];
19
+ }
20
+
21
+ return masked;
22
+ };
23
+
24
+ const parseTime = (str) => {
25
+ if (!str) return null;
26
+ const parts = str.split(":");
27
+ if (parts.length !== 2) return null;
28
+ const hours = parseInt(parts[0], 10);
29
+ const minutes = parseInt(parts[1], 10);
30
+ if (isNaN(hours) || isNaN(minutes)) return null;
31
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
32
+ return { hours, minutes };
33
+ };
34
+
35
+ const formatTime = (hours, minutes) => {
36
+ if (hours === null || minutes === null) return "";
37
+ return `${formatSegment(hours)}:${formatSegment(minutes)}`;
38
+ };
39
+
8
40
  export default function TimePicker({
9
41
  id,
10
- placeholder,
42
+ placeholder = "HH:MM",
11
43
  variant = "regular",
12
44
  size = "regular",
45
+ label: labelText,
46
+ disabled = false,
47
+ value,
48
+ onInput,
49
+ onChange,
50
+ onFocus,
51
+ onBlur,
52
+ input,
53
+ change,
54
+ focus,
55
+ blur,
13
56
  class: className = ""
14
57
  } = {}) {
15
58
  const popoverRef = useRef(null);
59
+ const inputRef = useRef(null);
16
60
  const hourInputRef = useRef(null);
17
61
  const minuteInputRef = useRef(null);
18
62
  const pickerId = id || `timepicker-${Math.random().toString(36).slice(2, 8)}`;
19
63
  const anchorName = `--${pickerId}`;
20
64
 
21
- // State as strings for better input handling
65
+ // Initialize from value prop if provided
22
66
  const now = new Date();
23
- const hour = useState(formatSegment(now.getHours()));
24
- const minute = useState(formatSegment(now.getMinutes()));
25
- const isModified = useState(false);
67
+ const initialHours = value?.hours ?? now.getHours();
68
+ const initialMinutes = value?.minutes ?? now.getMinutes();
69
+
70
+ const hour = useState(formatSegment(initialHours));
71
+ const minute = useState(formatSegment(initialMinutes));
72
+ const inputValue = useState(value ? formatTime(initialHours, initialMinutes) : "");
26
73
 
27
74
  const openPopover = () => {
28
75
  const popover = popoverRef.current;
@@ -38,11 +85,82 @@ export default function TimePicker({
38
85
  }
39
86
  };
40
87
 
88
+ // Handle click outside and escape key for manual popover
89
+ useEffect((popoverElement) => {
90
+ if (!popoverElement) return;
91
+
92
+ const handleClickOutside = (e) => {
93
+ if (!popoverElement.matches(":popover-open")) return;
94
+
95
+ const input = inputRef.current;
96
+ const isClickInside = popoverElement.contains(e.target);
97
+ const isClickOnInput = input && input.contains(e.target);
98
+
99
+ if (!isClickInside && !isClickOnInput) {
100
+ closePopover();
101
+ }
102
+ };
103
+
104
+ const handleEscape = (e) => {
105
+ if (e.key === "Escape" && popoverElement.matches(":popover-open")) {
106
+ closePopover();
107
+ inputRef.current?.focus();
108
+ }
109
+ };
110
+
111
+ document.addEventListener("click", handleClickOutside, true);
112
+ document.addEventListener("keydown", handleEscape);
113
+
114
+ return () => {
115
+ document.removeEventListener("click", handleClickOutside, true);
116
+ document.removeEventListener("keydown", handleEscape);
117
+ };
118
+ }, popoverRef);
119
+
120
+ const updateTime = (hours, minutes) => {
121
+ hour.set(formatSegment(hours));
122
+ minute.set(formatSegment(minutes));
123
+ inputValue.set(formatTime(hours, minutes));
124
+
125
+ const handleChange = onChange ?? change;
126
+ if (handleChange) {
127
+ handleChange({ target: { value: formatTime(hours, minutes) }, time: { hours, minutes } });
128
+ }
129
+ };
130
+
41
131
  const handleNow = () => {
42
132
  const d = new Date();
43
- hour.set(formatSegment(d.getHours()));
44
- minute.set(formatSegment(d.getMinutes()));
45
- isModified.set(true);
133
+ updateTime(d.getHours(), d.getMinutes());
134
+ };
135
+
136
+ const handleClear = () => {
137
+ hour.set("00");
138
+ minute.set("00");
139
+ inputValue.set("");
140
+ closePopover();
141
+
142
+ const handleChange = onChange ?? change;
143
+ if (handleChange) {
144
+ handleChange({ target: { value: "" }, time: null });
145
+ }
146
+ };
147
+
148
+ const handleOK = () => {
149
+ // Update the main input with current segment values when OK is clicked
150
+ const h = hour.get().padStart(2, '0');
151
+ const m = minute.get().padStart(2, '0');
152
+ const time = formatTime(parseInt(h, 10), parseInt(m, 10));
153
+ inputValue.set(time);
154
+
155
+ const handleChange = onChange ?? change;
156
+ if (handleChange) {
157
+ handleChange({
158
+ target: { value: time },
159
+ time: { hours: parseInt(h, 10), minutes: parseInt(m, 10) }
160
+ });
161
+ }
162
+
163
+ closePopover();
46
164
  };
47
165
 
48
166
  const handleHourInput = (e) => {
@@ -52,7 +170,12 @@ export default function TimePicker({
52
170
  if (val > 23) raw = "23";
53
171
  }
54
172
  hour.set(raw);
55
- isModified.set(true);
173
+
174
+ // Update main input value
175
+ const currentMinute = minute.get();
176
+ if (raw && currentMinute) {
177
+ inputValue.set(formatTime(parseInt(raw, 10), parseInt(currentMinute, 10)));
178
+ }
56
179
 
57
180
  // Auto-focus minutes if we have 2 digits or a digit that can't be leading (3-9)
58
181
  if (raw.length === 2 || (raw.length === 1 && parseInt(raw, 10) > 2)) {
@@ -68,91 +191,158 @@ export default function TimePicker({
68
191
  if (val > 59) raw = "59";
69
192
  }
70
193
  minute.set(raw);
71
- isModified.set(true);
194
+
195
+ // Update main input value
196
+ const currentHour = hour.get();
197
+ if (currentHour && raw) {
198
+ inputValue.set(formatTime(parseInt(currentHour, 10), parseInt(raw, 10)));
199
+ }
72
200
  };
73
201
 
74
- const handleBlur = (type) => {
202
+ const handleSegmentBlur = (type) => {
75
203
  const state = type === 'hour' ? hour : minute;
76
204
  let val = state.get();
77
205
  if (val === "") val = "00";
78
- state.set(val.padStart(2, '0'));
206
+ const padded = val.padStart(2, '0');
207
+ state.set(padded);
208
+
209
+ // Update main input with padded values
210
+ const h = type === 'hour' ? padded : hour.get();
211
+ const m = type === 'minute' ? padded : minute.get();
212
+ if (h && m) {
213
+ const time = formatTime(parseInt(h, 10), parseInt(m, 10));
214
+ inputValue.set(time);
215
+
216
+ const handleChange = onChange ?? change;
217
+ if (handleChange) {
218
+ handleChange({
219
+ target: { value: time },
220
+ time: { hours: parseInt(h, 10), minutes: parseInt(m, 10) }
221
+ });
222
+ }
223
+ }
224
+ };
225
+
226
+ const handleMainInputChange = (e) => {
227
+ const rawValue = e.target.value;
228
+ const maskedValue = applyTimeMask(rawValue);
229
+ inputValue.set(maskedValue);
230
+
231
+ // Try to parse the time if complete
232
+ if (maskedValue.length === 5) {
233
+ const parsedTime = parseTime(maskedValue);
234
+ if (parsedTime) {
235
+ hour.set(formatSegment(parsedTime.hours));
236
+ minute.set(formatSegment(parsedTime.minutes));
237
+
238
+ const handleChange = onChange ?? change;
239
+ if (handleChange) {
240
+ handleChange({ target: { value: maskedValue }, time: parsedTime });
241
+ }
242
+ }
243
+ }
244
+
245
+ const handleInput = onInput ?? input;
246
+ if (handleInput) {
247
+ handleInput(e);
248
+ }
249
+ };
250
+
251
+ const handleMainInputFocus = (e) => {
252
+ openPopover();
253
+
254
+ const handleFocus = onFocus ?? focus;
255
+ if (handleFocus) {
256
+ handleFocus(e);
257
+ }
258
+ };
259
+
260
+ const handleMainInputBlur = (e) => {
261
+ // Don't close popover on blur - let it handle its own dismissal
262
+
263
+ const handleBlur = onBlur ?? blur;
264
+ if (handleBlur) {
265
+ handleBlur(e);
266
+ }
267
+ };
268
+
269
+ const handleClockIconClick = (e) => {
270
+ e.preventDefault();
271
+ e.stopPropagation();
272
+ const input = inputRef.current;
273
+ if (input) {
274
+ input.focus();
275
+ }
79
276
  };
80
277
 
81
- // Reactive display label for the trigger
82
- const displayLabel = useMemo([hour, minute, isModified], (h, m, mod) => {
83
- if (!mod && placeholder) return placeholder;
84
- // Ensure we show padded values in the trigger even if input is mid-edit
85
- const hh = h === "" ? "00" : h.padStart(2, '0');
86
- const mm = m === "" ? "00" : m.padStart(2, '0');
87
- return `${hh}:${hh === h ? '' : ''}${mm}`; // Trigger re-render correctly
88
- });
89
-
90
- // Refined display label using state directly for consistency
91
- const finalLabel = useMemo([hour, minute, isModified], (h, m, mod) => {
92
- if (!mod && placeholder) return placeholder;
93
- const hh = h.padStart(2, '0');
94
- const mm = m.padStart(2, '0');
95
- return `${hh}:${mm}`;
96
- });
97
-
98
- const hasValue = isModified.map(m => !!m);
99
-
100
- // TimePicker does not support small size (clamps to regular)
101
- const normalizeSize = (value) => clampSize(value, ["xsmall", "regular", "large", "xlarge"], "regular");
278
+ // TimePicker supports regular, large, xlarge (no xsmall, small)
279
+ const normalizeSize = (value) => clampSize(value, ["regular", "large", "xlarge"], "regular");
102
280
  const normalizedSize = normalizeSize(size);
103
281
  const sizeToken = toSizeToken(normalizedSize);
282
+ const sizeClass = sizeToken === "xl" ? "input-xl" : sizeToken === "lg" ? "input-lg" : "";
104
283
  const variantClass = variant === "rounded" ? "rounded-full" : "";
105
- const triggerSizeClass = sizeToken === "xl"
106
- ? "dropdown-xl"
107
- : sizeToken === "lg"
108
- ? "dropdown-lg"
109
- : "";
110
- const iconSizeValue = normalizedSize === "small"
111
- ? "small"
112
- : normalizedSize === "large"
113
- ? "large"
114
- : normalizedSize === "xlarge"
115
- ? "xlarge"
116
- : undefined;
117
-
118
- return div({ class: `timepicker-wrapper ${className}`.trim() }, [
119
- button({
120
- id: pickerId,
121
- class: `dropdown-trigger timepicker-trigger justify-start ${variantClass} ${triggerSizeClass} no-chevron`.trim(),
122
- style: `anchor-name: ${anchorName}`,
123
- click: openPopover
124
- }, [
125
- span({ class: hasValue.map(h => h ? "" : "text-tertiary") }, finalLabel),
126
- Icon({ name: "clock", fill: "quaternary", size: iconSizeValue, class: "ml-auto" })
284
+ const iconSizeValue = normalizedSize === "large"
285
+ ? "large"
286
+ : normalizedSize === "xlarge"
287
+ ? "xlarge"
288
+ : undefined;
289
+
290
+ return div({ class: `column-container no-margin shrink-0 gap-0 ${className}`.trim() }, [
291
+ labelText && label({ class: "label select-none" }, labelText),
292
+ div({ class: "timepicker-input-wrapper w-full relative" }, [
293
+ inputEl({
294
+ ref: inputRef,
295
+ id: pickerId,
296
+ type: "text",
297
+ value: inputValue,
298
+ placeholder,
299
+ disabled,
300
+ autocomplete: "off",
301
+ class: `input ${sizeClass} ${variantClass} pr-xl`.trim(),
302
+ style: `anchor-name: ${anchorName}`,
303
+ input: handleMainInputChange,
304
+ focus: handleMainInputFocus,
305
+ blur: handleMainInputBlur,
306
+ maxlength: "5"
307
+ }),
308
+ button({
309
+ class: "timepicker-icon-button",
310
+ type: "button",
311
+ disabled,
312
+ click: handleClockIconClick,
313
+ tabindex: "-1"
314
+ }, [
315
+ Icon({ name: "clock", fill: "quaternary", size: iconSizeValue })
316
+ ])
127
317
  ]),
128
318
 
129
319
  div({
130
320
  ref: popoverRef,
131
- popover: "auto",
321
+ popover: "manual",
132
322
  class: "timepicker-popover popover-base",
133
323
  style: `--anchor-id: ${anchorName}`
134
324
  }, [
135
325
  div({ class: "card column-container shadow gap-0 p-0 bg-base timepicker-card" }, [
136
326
  div({ class: "timepicker-display" }, [
137
- input({
327
+ inputEl({
138
328
  ref: hourInputRef,
139
329
  type: "text",
140
330
  class: "time-segment",
141
331
  value: hour,
142
332
  placeholder: "00",
143
333
  input: handleHourInput,
144
- blur: () => handleBlur('hour'),
334
+ blur: () => handleSegmentBlur('hour'),
145
335
  focus: (e) => e.target.select()
146
336
  }),
147
337
  span({ class: "time-separator" }, ":"),
148
- input({
338
+ inputEl({
149
339
  ref: minuteInputRef,
150
340
  type: "text",
151
341
  class: "time-segment",
152
342
  value: minute,
153
343
  placeholder: "00",
154
344
  input: handleMinuteInput,
155
- blur: () => handleBlur('minute'),
345
+ blur: () => handleSegmentBlur('minute'),
156
346
  focus: (e) => e.target.select()
157
347
  })
158
348
  ]),
@@ -160,9 +350,9 @@ export default function TimePicker({
160
350
  hr({ class: "no-margin" }),
161
351
 
162
352
  div({ class: "row-container justify-center items-center gap-md p-base shrink-0" }, [
163
- button({ class: "btn btn-flat", click: () => { isModified.set(false); closePopover(); } }, "Clear"),
164
- button({ class: "btn btn-flat", click: handleNow }, "Now"),
165
- button({ class: "btn", click: closePopover }, "OK")
353
+ button({ type: "button", class: "btn btn-flat", click: handleClear }, "Clear"),
354
+ button({ type: "button", class: "btn btn-flat", click: handleNow }, "Now"),
355
+ button({ type: "button", class: "btn", click: handleOK }, "OK")
166
356
  ])
167
357
  ])
168
358
  ])
package/src/index.mjs CHANGED
@@ -17,6 +17,7 @@ export { default as NavigationBar } from "./components/NavigationBar.mjs";
17
17
  export { default as PageHeader } from "./components/PageHeader.mjs";
18
18
  export { default as PageSection } from "./components/PageSection.mjs";
19
19
  export { default as PopoverMenu } from "./components/PopoverMenu.mjs";
20
+ export { default as ProgressBar } from "./components/ProgressBar.mjs";
20
21
  export { default as RadioCheckbox } from "./components/RadioCheckbox.mjs";
21
22
  export { default as SearchBox } from "./components/SearchBox.mjs";
22
23
  export { default as Sidebar } from "./components/Sidebar.mjs";
@@ -29,3 +30,6 @@ export { default as VStack } from "./components/VStack.mjs";
29
30
 
30
31
  export { dialogState, showDialog, hideDialog } from "./components/Dialog.mjs";
31
32
  export { toastState, showToast, hideToast } from "./components/ToastNotification.mjs";
33
+
34
+ // Mask utilities
35
+ export { applyMask, validateMask, getMaskMaxLength } from "./utils/maskUtils.mjs";
@@ -201,6 +201,41 @@ textarea::placeholder {
201
201
  color: white;
202
202
  }
203
203
 
204
+ /* Progress Bar */
205
+ .progress-bar {
206
+ width: 100%;
207
+ background-color: var(--alternate-background-color);
208
+ border-radius: var(--min-control-radius);
209
+ overflow: hidden;
210
+ }
211
+
212
+ .progress-bar-fill {
213
+ height: 100%;
214
+ width: 0%;
215
+ background-color: currentColor;
216
+ transition: width 0.2s ease;
217
+ }
218
+
219
+ .progress-bar-xs {
220
+ height: 0.25rem;
221
+ }
222
+
223
+ .progress-bar-sm {
224
+ height: 0.375rem;
225
+ }
226
+
227
+ .progress-bar-md {
228
+ height: 0.5rem;
229
+ }
230
+
231
+ .progress-bar-lg {
232
+ height: 0.75rem;
233
+ }
234
+
235
+ .progress-bar-xl {
236
+ height: 1rem;
237
+ }
238
+
204
239
  .badge-solid.badge-dimmed {
205
240
  background-color: var(--highlight-background-color);
206
241
  color: var(--color-secondary);
@@ -301,6 +336,43 @@ input::-webkit-calendar-picker-indicator {
301
336
  -webkit-appearance: none;
302
337
  }
303
338
 
339
+ /* DatePicker & TimePicker Input Wrapper */
340
+ .datepicker-input-wrapper,
341
+ .timepicker-input-wrapper {
342
+ position: relative;
343
+ display: flex;
344
+ align-items: center;
345
+ }
346
+
347
+ .datepicker-icon-button,
348
+ .timepicker-icon-button {
349
+ position: absolute;
350
+ right: var(--base-padding);
351
+ top: 50%;
352
+ transform: translateY(-50%);
353
+ background: none;
354
+ border: none;
355
+ padding: 0;
356
+ cursor: pointer;
357
+ display: flex;
358
+ align-items: center;
359
+ justify-content: center;
360
+ color: var(--color-quaternary);
361
+ transition: color 0.2s;
362
+ z-index: 1;
363
+ }
364
+
365
+ .datepicker-icon-button:hover:not(:disabled),
366
+ .timepicker-icon-button:hover:not(:disabled) {
367
+ color: var(--color-tertiary);
368
+ }
369
+
370
+ .datepicker-icon-button:disabled,
371
+ .timepicker-icon-button:disabled {
372
+ cursor: not-allowed;
373
+ opacity: 0.5;
374
+ }
375
+
304
376
  textarea {
305
377
  min-height: 100px;
306
378
  resize: vertical;