@bunnix/components 0.10.2 → 0.11.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 (150) hide show
  1. package/@types/index.d.ts +179 -15
  2. package/README.md +41 -4
  3. package/package.json +3 -8
  4. package/src/core/buttons.css +1 -0
  5. package/src/core/core.css +86 -4
  6. package/src/core/dialog.css +3 -1
  7. package/src/core/dialog.mjs +101 -16
  8. package/src/core/input.css +202 -0
  9. package/src/core/inputs.mjs +702 -23
  10. package/src/core/layout.mjs +1 -2
  11. package/src/core/media.css +36 -1
  12. package/src/core/media.mjs +13 -13
  13. package/src/core/menu.css +10 -29
  14. package/src/core/menu.mjs +159 -70
  15. package/src/core/outline.mjs +100 -0
  16. package/src/core/sidebar.mjs +189 -68
  17. package/src/core/sliderUtils.mjs +51 -0
  18. package/src/core/table.css +23 -0
  19. package/src/core/table.mjs +35 -20
  20. package/src/core/textareaUtils.mjs +31 -0
  21. package/src/core/utils.mjs +105 -0
  22. package/src/font-face/Framework7Icons-Regular.woff2 +0 -0
  23. package/src/index.mjs +3 -1
  24. package/src/core/core-mobile.css +0 -69
  25. package/src/icons/add-circle.svg +0 -1
  26. package/src/icons/add.svg +0 -1
  27. package/src/icons/alt.svg +0 -1
  28. package/src/icons/archive.svg +0 -1
  29. package/src/icons/arrow-down.svg +0 -1
  30. package/src/icons/arrow-left.svg +0 -1
  31. package/src/icons/arrow-right.svg +0 -1
  32. package/src/icons/arrow-up.svg +0 -1
  33. package/src/icons/at.svg +0 -1
  34. package/src/icons/attestation.svg +0 -1
  35. package/src/icons/battery-25.svg +0 -1
  36. package/src/icons/bell.svg +0 -3
  37. package/src/icons/bookmark.svg +0 -1
  38. package/src/icons/bot.svg +0 -1
  39. package/src/icons/bubble.svg +0 -1
  40. package/src/icons/building.svg +0 -3
  41. package/src/icons/button.svg +0 -1
  42. package/src/icons/calculate.svg +0 -1
  43. package/src/icons/calendar.svg +0 -1
  44. package/src/icons/captions-bubble.svg +0 -1
  45. package/src/icons/cart.svg +0 -1
  46. package/src/icons/chart.svg +0 -1
  47. package/src/icons/check.svg +0 -1
  48. package/src/icons/chevron-down.svg +0 -1
  49. package/src/icons/chevron-left.svg +0 -1
  50. package/src/icons/chevron-right.svg +0 -1
  51. package/src/icons/clip.svg +0 -1
  52. package/src/icons/clock.svg +0 -3
  53. package/src/icons/close-circle.svg +0 -3
  54. package/src/icons/close.svg +0 -1
  55. package/src/icons/cloud-download.svg +0 -1
  56. package/src/icons/cloud-upload.svg +0 -1
  57. package/src/icons/cloud.svg +0 -1
  58. package/src/icons/columns-layout.svg +0 -1
  59. package/src/icons/command.svg +0 -1
  60. package/src/icons/cube.svg +0 -1
  61. package/src/icons/delete.svg +0 -3
  62. package/src/icons/dollar.svg +0 -3
  63. package/src/icons/download.svg +0 -1
  64. package/src/icons/draw.svg +0 -1
  65. package/src/icons/duplicate.svg +0 -3
  66. package/src/icons/ear.svg +0 -1
  67. package/src/icons/edit.svg +0 -1
  68. package/src/icons/exclamation-mark.svg +0 -1
  69. package/src/icons/eye-open.svg +0 -1
  70. package/src/icons/eye.svg +0 -1
  71. package/src/icons/file-html.svg +0 -1
  72. package/src/icons/file.svg +0 -3
  73. package/src/icons/finger.svg +0 -1
  74. package/src/icons/flag.svg +0 -1
  75. package/src/icons/folder.svg +0 -1
  76. package/src/icons/function.svg +0 -1
  77. package/src/icons/gear.svg +0 -1
  78. package/src/icons/gift.svg +0 -1
  79. package/src/icons/globe.svg +0 -3
  80. package/src/icons/grid.svg +0 -1
  81. package/src/icons/hammer.svg +0 -1
  82. package/src/icons/hand.svg +0 -1
  83. package/src/icons/hare.svg +0 -1
  84. package/src/icons/heart.svg +0 -3
  85. package/src/icons/home.svg +0 -3
  86. package/src/icons/image.svg +0 -1
  87. package/src/icons/inbox.svg +0 -3
  88. package/src/icons/info.svg +0 -1
  89. package/src/icons/key.svg +0 -1
  90. package/src/icons/lamp.svg +0 -1
  91. package/src/icons/link.svg +0 -1
  92. package/src/icons/location.svg +0 -1
  93. package/src/icons/locker.svg +0 -1
  94. package/src/icons/login.svg +0 -1
  95. package/src/icons/logout.svg +0 -3
  96. package/src/icons/mail.svg +0 -3
  97. package/src/icons/map.svg +0 -3
  98. package/src/icons/markup.svg +0 -1
  99. package/src/icons/merge.svg +0 -1
  100. package/src/icons/more-horizontal.svg +0 -5
  101. package/src/icons/more-vertical.svg +0 -5
  102. package/src/icons/mouse.svg +0 -1
  103. package/src/icons/music-mic.svg +0 -1
  104. package/src/icons/paintbrush.svg +0 -1
  105. package/src/icons/palette.svg +0 -1
  106. package/src/icons/password.svg +0 -1
  107. package/src/icons/pencil.svg +0 -1
  108. package/src/icons/people.svg +0 -3
  109. package/src/icons/percent.svg +0 -1
  110. package/src/icons/person-add.svg +0 -1
  111. package/src/icons/person-remove.svg +0 -1
  112. package/src/icons/person.svg +0 -4
  113. package/src/icons/phone.svg +0 -1
  114. package/src/icons/pin.svg +0 -1
  115. package/src/icons/question-circle.svg +0 -3
  116. package/src/icons/remove-circle.svg +0 -1
  117. package/src/icons/return-arrow.svg +0 -1
  118. package/src/icons/save.svg +0 -1
  119. package/src/icons/search.svg +0 -1
  120. package/src/icons/sections.svg +0 -1
  121. package/src/icons/send.svg +0 -1
  122. package/src/icons/share.svg +0 -1
  123. package/src/icons/shine.svg +0 -1
  124. package/src/icons/sliders.svg +0 -1
  125. package/src/icons/star.svg +0 -3
  126. package/src/icons/staroflife.svg +0 -1
  127. package/src/icons/storage.svg +0 -1
  128. package/src/icons/success-circle.svg +0 -3
  129. package/src/icons/swap.svg +0 -1
  130. package/src/icons/switch.svg +0 -1
  131. package/src/icons/sync.svg +0 -3
  132. package/src/icons/table.svg +0 -3
  133. package/src/icons/tag.svg +0 -3
  134. package/src/icons/terminal.svg +0 -1
  135. package/src/icons/text.svg +0 -1
  136. package/src/icons/thumb-down.svg +0 -1
  137. package/src/icons/thumb-up.svg +0 -1
  138. package/src/icons/timer.svg +0 -3
  139. package/src/icons/toggle.svg +0 -1
  140. package/src/icons/trash.svg +0 -1
  141. package/src/icons/tv-music.svg +0 -1
  142. package/src/icons/update-page.svg +0 -1
  143. package/src/icons/upload.svg +0 -1
  144. package/src/icons/video.svg +0 -1
  145. package/src/icons/wallet.svg +0 -1
  146. package/src/icons/wand-stars.svg +0 -1
  147. package/src/icons/waveform.svg +0 -1
  148. package/src/icons/window.svg +0 -1
  149. package/src/utils/iconRegistry.generated.mjs +0 -187
  150. package/src/utils/iconRegistry.mjs +0 -34
@@ -5,8 +5,11 @@
5
5
  *
6
6
  * Components:
7
7
  * - TextInput: Single-line text input with optional placeholder and state binding
8
+ * - Picker: Menu-backed selection input with a selector-style trigger
9
+ * - SegmentedPicker: iOS-style segmented selection control with optional icons
8
10
  * - Select: Dropdown input with mapped options
9
11
  * - CheckBox: Simple checkbox input with optional state binding
12
+ * - Switch: OS-style boolean toggle with optional state binding
10
13
  *
11
14
  * Features:
12
15
  * - Two-way binding with useState objects
@@ -14,13 +17,31 @@
14
17
  * - Flexible props normalization (supports both props object and direct children)
15
18
  * - Outline focus states via core.css utilities
16
19
  */
17
- import Bunnix, { useState, useEffect } from "@bunnix/core";
18
- import { withNormalizedArgs, withExtractedStyles } from "./utils.mjs";
19
- import { Column, Row } from "./layout.mjs";
20
- import { Heading } from "./typography.mjs";
20
+ import Bunnix, { Compute, useState, useEffect, useRef, ForEach, Show } from "@bunnix/core";
21
+ import {
22
+ withNormalizedArgs,
23
+ withExtractedStyles,
24
+ isStateLike,
25
+ resolveCollectionState,
26
+ } from "./utils.mjs";
27
+ import { Column, Row, Spacer } from "./layout.mjs";
28
+ import { Heading, Text } from "./typography.mjs";
29
+ import { Icon } from "./media.mjs";
30
+ import { Menu } from "./menu.mjs";
31
+ import {
32
+ findNearestSliderStepIndex,
33
+ getSliderStepValue,
34
+ hasSliderStepLabels,
35
+ isValidSliderSteps,
36
+ toSliderNumber,
37
+ } from "./sliderUtils.mjs";
38
+ import {
39
+ getTextAreaHeightMetrics,
40
+ resolveTextAreaLines,
41
+ } from "./textareaUtils.mjs";
21
42
  import "./input.css";
22
43
 
23
- const { input, select, option } = Bunnix;
44
+ const { input, textarea, select, option, div, button } = Bunnix;
24
45
 
25
46
  /**
26
47
  * Wraps a component in a Column with a Heading label if props.label exists.
@@ -64,18 +85,104 @@ function wrapCheckBoxIntoLabel(props, component) {
64
85
  return component;
65
86
  }
66
87
 
88
+ function resolveNumericState(propValue, fallback) {
89
+ return isStateLike(propValue)
90
+ ? propValue
91
+ : useState(toSliderNumber(propValue, fallback));
92
+ }
93
+
94
+ function resolveBooleanState(propValue) {
95
+ return isStateLike(propValue)
96
+ ? propValue
97
+ : useState(!!propValue);
98
+ }
99
+
100
+ function resolveInputFocusClass(outline) {
101
+ return outline ? "focus-border-outline focus-outline-dimmed" : "no-outline";
102
+ }
103
+
104
+ function syncFocusedNode(node, shouldFocus) {
105
+ if (!node || typeof node.focus !== "function" || typeof node.blur !== "function") return;
106
+ if (shouldFocus) {
107
+ if (typeof document === "undefined" || document.activeElement !== node) {
108
+ node.focus();
109
+ }
110
+ return;
111
+ }
112
+
113
+ if (typeof document === "undefined" || document.activeElement === node) {
114
+ node.blur();
115
+ }
116
+ }
117
+
118
+ function getLineHeightPx(node) {
119
+ if (!node || typeof window === "undefined") return 20;
120
+
121
+ const computed = window.getComputedStyle(node);
122
+ const lineHeight = Number.parseFloat(computed.lineHeight);
123
+
124
+ if (Number.isFinite(lineHeight)) return lineHeight;
125
+
126
+ const fontSize = Number.parseFloat(computed.fontSize);
127
+ if (Number.isFinite(fontSize)) return fontSize * 1.5;
128
+
129
+ return 20;
130
+ }
131
+
132
+ function getTextAreaVerticalInset(node) {
133
+ if (!node || typeof window === "undefined") return 0;
134
+
135
+ const computed = window.getComputedStyle(node);
136
+ const values = [
137
+ computed.paddingTop,
138
+ computed.paddingBottom,
139
+ computed.borderTopWidth,
140
+ computed.borderBottomWidth,
141
+ ];
142
+
143
+ return values.reduce((total, value) => {
144
+ const nextValue = Number.parseFloat(value);
145
+ return total + (Number.isFinite(nextValue) ? nextValue : 0);
146
+ }, 0);
147
+ }
148
+
149
+ function resizeTextArea(node, minLines, maxLines) {
150
+ if (!node) return;
151
+
152
+ const metrics = getTextAreaHeightMetrics({
153
+ lineHeight: getLineHeightPx(node),
154
+ scrollHeight: node.scrollHeight,
155
+ minLines,
156
+ maxLines,
157
+ verticalInset: getTextAreaVerticalInset(node),
158
+ });
159
+
160
+ node.style.height = "auto";
161
+ node.style.height = `${metrics.nextHeight}px`;
162
+ node.style.overflowY = metrics.shouldScroll ? "auto" : "hidden";
163
+ }
164
+
165
+ function shouldInsertTextAreaNewline(event) {
166
+ if (event.key !== "Enter" || event.isComposing) return true;
167
+ return !!event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey;
168
+ }
169
+
67
170
  /** Text Input core component and logic */
68
171
  const TextInputCore = (props, _) => {
69
172
  let value =
70
173
  props.value?.get && props.value?.set
71
174
  ? props.value
72
175
  : useState(props.value ?? "");
176
+ let focusedValue = isStateLike(props.focused) ? props.focused : null;
177
+ let shouldAutoFocus = !focusedValue && !!props.focused;
178
+ let inputRef = useRef(null);
73
179
  let placeholder = props.placeholder ?? "";
74
- let outlineClass = props.outline ? "focus-outline-dimmed" : "no-outline";
180
+ let focusClass = resolveInputFocusClass(props.outline);
75
181
  let defaultClass =
76
- "padding-sm border-primary radius-md flex-grow-1 focus-border-outline bg-primary text-default";
182
+ "padding-sm border-primary radius-md flex-grow-1 bg-primary text-default";
77
183
 
78
184
  delete props.outline;
185
+ delete props.focused;
79
186
 
80
187
  let rawValue = useState("");
81
188
 
@@ -87,6 +194,12 @@ const TextInputCore = (props, _) => {
87
194
  }
88
195
  }, value);
89
196
 
197
+ if (focusedValue) {
198
+ useEffect((isFocused) => {
199
+ syncFocusedNode(inputRef.current, !!isFocused);
200
+ }, focusedValue);
201
+ }
202
+
90
203
  const convertRawValue = (val) => {
91
204
  rawValue.set(val);
92
205
  let prevVal = value.get();
@@ -109,48 +222,352 @@ const TextInputCore = (props, _) => {
109
222
  props,
110
223
  input({
111
224
  ...props,
112
- value: rawValue.get(),
225
+ ref: inputRef,
226
+ autofocus: shouldAutoFocus,
227
+ value: rawValue,
113
228
  disabled: props.disabled,
229
+ focus: () => {
230
+ if (focusedValue?.set) focusedValue.set(true);
231
+ },
232
+ blur: () => {
233
+ if (focusedValue?.set) focusedValue.set(false);
234
+ },
114
235
  input: (e) => {
115
236
  convertRawValue(e.target.value ?? "");
116
237
  // value.set(e.target.value ?? "");
117
238
  props.input && props.input(e);
118
239
  },
119
240
  placeholder: placeholder,
120
- class: `${defaultClass} ${outlineClass} ${props.class || ""}`,
241
+ class: `${defaultClass} ${focusClass} ${props.class || ""}`,
121
242
  }),
122
243
  );
123
244
  };
124
245
 
246
+ const TextAreaCore = (props, _) => {
247
+ const value =
248
+ props.value?.get && props.value?.set
249
+ ? props.value
250
+ : useState(props.value ?? "");
251
+ const focusedValue = isStateLike(props.focused) ? props.focused : null;
252
+ const shouldAutoFocus = !focusedValue && !!props.focused;
253
+ const rawValue = useState(value.get?.() ?? props.value ?? "");
254
+ const textAreaRef = useRef(null);
255
+ const minLines = resolveTextAreaLines(props.minLines, 3);
256
+ const maxLines = Math.max(minLines, resolveTextAreaLines(props.maxLines, 3));
257
+ const placeholder = props.placeholder ?? "";
258
+ const focusClass = resolveInputFocusClass(props.outline);
259
+ const defaultClass =
260
+ "padding-sm radius-md flex-grow-1 bg-primary text-default";
261
+
262
+ delete props.outline;
263
+ delete props.focused;
264
+ delete props.minLines;
265
+ delete props.maxLines;
266
+
267
+ useEffect((nextValue) => {
268
+ rawValue.set(nextValue ?? "");
269
+ resizeTextArea(textAreaRef.current, minLines, maxLines);
270
+ }, value);
271
+
272
+ useEffect(() => {
273
+ resizeTextArea(textAreaRef.current, minLines, maxLines);
274
+ }, []);
275
+
276
+ if (focusedValue) {
277
+ useEffect((isFocused) => {
278
+ syncFocusedNode(textAreaRef.current, !!isFocused);
279
+ }, focusedValue);
280
+ }
281
+
282
+ return wrapIntoLabel(
283
+ props,
284
+ textarea({
285
+ ...props,
286
+ ref: textAreaRef,
287
+ autofocus: shouldAutoFocus,
288
+ rows: minLines,
289
+ value: rawValue,
290
+ disabled: props.disabled,
291
+ placeholder,
292
+ class: `textarea ${defaultClass} ${focusClass} ${props.class || ""}`.trim(),
293
+ focus: () => {
294
+ if (focusedValue?.set) focusedValue.set(true);
295
+ },
296
+ blur: () => {
297
+ if (focusedValue?.set) focusedValue.set(false);
298
+ },
299
+ keydown: (e) => {
300
+ if (e.key === "Enter" && !shouldInsertTextAreaNewline(e)) {
301
+ e.preventDefault();
302
+ e.target?.form?.requestSubmit?.();
303
+ }
304
+
305
+ props.keydown && props.keydown(e);
306
+ },
307
+ input: (e) => {
308
+ const nextValue = e.target.value ?? "";
309
+ rawValue.set(nextValue);
310
+ value.set(nextValue);
311
+ resizeTextArea(e.target, minLines, maxLines);
312
+ props.input && props.input(e);
313
+ },
314
+ }),
315
+ );
316
+ };
317
+
318
+ const PickerCore = (props, _) => {
319
+ const value =
320
+ props.value?.get && props.value?.set
321
+ ? props.value
322
+ : useState(props.value ?? "");
323
+ const optionsValue = resolveCollectionState(props.options ?? props.items, []);
324
+ const disabledValue = resolveBooleanState(props.disabled);
325
+ const labelProps = props.label ? { label: props.label } : {};
326
+ const anchor = props.anchor;
327
+ const focusClass = resolveInputFocusClass(props.outline);
328
+ const defaultClass =
329
+ "padding-sm border-primary radius-md flex-grow-1 bg-primary text-default";
330
+ const pickerState = Compute([value, optionsValue, disabledValue], (selectedKey, resolvedOptions, isDisabled) => {
331
+ const firstSelectableOption = (resolvedOptions ?? []).find(
332
+ (option) => !option.divider && option.key !== undefined && option.key !== null,
333
+ );
334
+ const selectedItem = (resolvedOptions ?? []).find(
335
+ (option) => !option.divider && option.key === selectedKey,
336
+ );
337
+
338
+ const menuOptions = (resolvedOptions ?? []).map((option) => {
339
+ if (option.divider) return option;
340
+
341
+ return {
342
+ ...option,
343
+ action: () => {
344
+ value.set(option.key);
345
+ props.input &&
346
+ props.input({
347
+ target: { value: option.key },
348
+ currentTarget: { value: option.key },
349
+ option,
350
+ });
351
+ },
352
+ };
353
+ });
354
+
355
+ return { selectedItem, firstSelectableOption, menuOptions, isDisabled: !!isDisabled };
356
+ });
357
+
358
+ useEffect(({ selectedItem, firstSelectableOption }) => {
359
+ const selectedKey = value.get();
360
+ if (!selectedKey || selectedItem) return;
361
+ if (!firstSelectableOption) {
362
+ value.set("");
363
+ props.input &&
364
+ props.input({
365
+ target: { value: "" },
366
+ currentTarget: { value: "" },
367
+ option: null,
368
+ });
369
+ return;
370
+ }
371
+
372
+ value.set(firstSelectableOption.key);
373
+ props.input &&
374
+ props.input({
375
+ target: { value: firstSelectableOption.key },
376
+ currentTarget: { value: firstSelectableOption.key },
377
+ option: firstSelectableOption,
378
+ });
379
+ }, pickerState);
380
+
381
+ const triggerProps = { ...props };
382
+ delete triggerProps.value;
383
+ delete triggerProps.options;
384
+ delete triggerProps.items;
385
+ delete triggerProps.label;
386
+ delete triggerProps.input;
387
+ delete triggerProps.anchor;
388
+ delete triggerProps.disabled;
389
+ delete triggerProps.outline;
390
+
391
+ return wrapIntoLabel(
392
+ labelProps,
393
+ div(
394
+ {},
395
+ Show(pickerState, ({ selectedItem, menuOptions, isDisabled }) =>
396
+ withExtractedStyles((finalTriggerProps) =>
397
+ Menu({
398
+ ...(anchor ? { anchor } : {}),
399
+ items: menuOptions,
400
+ trigger: ({ toggle }) =>
401
+ button(
402
+ {
403
+ ...finalTriggerProps,
404
+ type: "button",
405
+ disabled: disabledValue,
406
+ click: () => {
407
+ if (isDisabled) return;
408
+ toggle();
409
+ },
410
+ class: `picker-trigger ${defaultClass} ${focusClass} ${
411
+ isDisabled ? "picker-trigger-disabled" : ""
412
+ } ${finalTriggerProps.class || ""}`.trim(),
413
+ },
414
+ Row(
415
+ { fillWidth: true, alignItems: "center", gap: "small" },
416
+ div(
417
+ { class: "picker-selection" },
418
+ ...(selectedItem?.icon
419
+ ? [Icon({ name: selectedItem.icon, size: 16 })]
420
+ : []),
421
+ ...(selectedItem
422
+ ? [Text({ weight: "heavy" }, selectedItem.text ?? selectedItem.key)]
423
+ : []),
424
+ ),
425
+ Spacer(),
426
+ Icon({ name: "chevron_down", size: 16, color: "secondary" }),
427
+ ),
428
+ ),
429
+ })
430
+ )({ minHeight: 32, textSize: "1rem", ...triggerProps }),
431
+ ),
432
+ ),
433
+ );
434
+ };
435
+
436
+ const SegmentedPickerCore = (props, _) => {
437
+ const value =
438
+ props.value?.get && props.value?.set
439
+ ? props.value
440
+ : useState(props.value ?? "");
441
+ const itemsValue = resolveCollectionState(props.items, []);
442
+ const focusClass = resolveInputFocusClass(props.outline);
443
+ const segmentedPickerState = Compute(
444
+ [value, itemsValue],
445
+ (selectedKey, resolvedItems) =>
446
+ ({
447
+ selectedKey,
448
+ selectedItem: (resolvedItems ?? []).find((item) => item.key === selectedKey) ?? null,
449
+ segments: (resolvedItems ?? []).map((item) => ({
450
+ ...item,
451
+ selected: item.key === selectedKey,
452
+ })),
453
+ }),
454
+ );
455
+ const segmentedPickerItems = segmentedPickerState.map((state) => state.segments);
456
+
457
+ useEffect(({ selectedKey, selectedItem }) => {
458
+ if (!selectedKey || selectedItem) return;
459
+
460
+ value.set("");
461
+
462
+ const eventLike = {
463
+ target: { value: "" },
464
+ currentTarget: { value: "" },
465
+ item: null,
466
+ };
467
+
468
+ props.change && props.change(eventLike);
469
+ props.input && props.input(eventLike);
470
+ }, segmentedPickerState);
471
+
472
+ delete props.outline;
473
+ delete props.items;
474
+
475
+ return wrapIntoLabel(
476
+ props,
477
+ div(
478
+ {
479
+ class: `segmented-picker border-primary bg-primary-dimmed radius-lg ${
480
+ props.disabled ? "segmented-picker-disabled" : ""
481
+ } ${focusClass} ${props.class || ""}`.trim(),
482
+ },
483
+ ForEach(segmentedPickerItems, "key", (item) =>
484
+ button(
485
+ {
486
+ type: "button",
487
+ disabled: !!props.disabled,
488
+ click: () => {
489
+ if (props.disabled || item.selected) return;
490
+
491
+ value.set(item.key);
492
+
493
+ const eventLike = {
494
+ target: { value: item.key },
495
+ currentTarget: { value: item.key },
496
+ item,
497
+ };
498
+
499
+ props.change && props.change(eventLike);
500
+ props.input && props.input(eventLike);
501
+ },
502
+ class: `segmented-picker-segment ${
503
+ item.selected ? "segmented-picker-segment-selected" : ""
504
+ }`,
505
+ },
506
+ Row(
507
+ {
508
+ class: "segmented-picker-segment-content",
509
+ alignItems: "center",
510
+ justifyContent: "center",
511
+ gap: item.icon ? "small" : 0,
512
+ width: "100%",
513
+ },
514
+ ...(item.icon ? [Icon({ name: item.icon, size: 16 })] : []),
515
+ Text({ weight: "heavy" }, item.text),
516
+ ),
517
+ ),
518
+ ),
519
+ ),
520
+ );
521
+ };
522
+
125
523
  /** Select core component and logic */
126
524
  const SelectCore = (props, _) => {
127
525
  let value =
128
526
  props.value?.get && props.value?.set
129
527
  ? props.value
130
528
  : useState(props.value ?? "");
131
- let options = props.options ?? [];
132
- let outline = props.outline ?? false;
529
+ let optionsValue = resolveCollectionState(props.options, []);
530
+ let focusClass = resolveInputFocusClass(props.outline);
531
+ const selectedOptionState = Compute([value, optionsValue], (selectedKey, resolvedOptions) => {
532
+ const options = resolvedOptions ?? [];
533
+ const selectedOption = options.find((option) => option.key === selectedKey);
534
+ return { selectedKey, selectedOption };
535
+ });
536
+
537
+ useEffect(({ selectedKey, selectedOption }) => {
538
+ if (!selectedKey || selectedOption) return;
539
+
540
+ value.set("");
541
+ props.input &&
542
+ props.input({
543
+ target: { value: "" },
544
+ currentTarget: { value: "" },
545
+ option: null,
546
+ });
547
+ }, selectedOptionState);
133
548
 
134
549
  delete props.options;
135
550
  delete props.outline;
136
551
 
137
- let childrenOptions = options.map((o, index) => {
138
- return option(
552
+ // Use ForEach for reactive option rendering - updates when value changes
553
+ let childrenOptions = ForEach(optionsValue, "key", (o, index) =>
554
+ option(
139
555
  {
140
556
  value: o.key ?? `option ${index + 1}`,
141
557
  selected: o.key === value.get(),
142
558
  },
143
559
  o.content ?? ``,
144
- );
145
- });
560
+ ),
561
+ );
146
562
 
147
- let defaultClass = `appearance-none padding-sm bg-primary border-primary radius-md flex-grow-1 focus-border-outline ${outline ? "focus-outline-dimmed" : "no-outline"}`;
563
+ let defaultClass = `appearance-none padding-sm bg-primary border-primary radius-md flex-grow-1 ${focusClass}`;
148
564
 
149
565
  return wrapIntoLabel(
150
566
  props,
151
567
  select(
152
568
  {
153
569
  ...props,
570
+ value: value,
154
571
  input: (e) => {
155
572
  value.set(e.target.value ?? "");
156
573
  props.input && props.input(e);
@@ -165,14 +582,36 @@ const SelectCore = (props, _) => {
165
582
  /** Checkbox core component and logic */
166
583
  const CheckBoxCore = (props, _) => {
167
584
  let checkedValue = "checked" in props ? props.checked : props.value;
168
- let checked =
169
- checkedValue?.get && checkedValue?.set
170
- ? checkedValue
171
- : useState(!!checkedValue);
172
- let outlineClass = props.outline ? "focus-outline-dimmed" : "no-outline";
585
+ let checked = resolveBooleanState(checkedValue);
586
+ let focusClass = resolveInputFocusClass(props.outline);
173
587
  let defaultClass =
174
- "cursor-pointer border-primary radius-md focus-border-outline bg-primary";
588
+ "cursor-pointer border-primary radius-md bg-primary";
589
+ delete props.outline;
590
+ delete props.checked;
591
+ delete props.value;
592
+
593
+ return wrapCheckBoxIntoLabel(
594
+ props,
595
+ input({
596
+ ...props,
597
+ type: "checkbox",
598
+ checked: !!checked.get(),
599
+ change: (e) => {
600
+ checked.set(!!e.target.checked);
601
+ props.change && props.change(e);
602
+ props.input && props.input(e);
603
+ },
604
+ class: `checkbox ${defaultClass} ${focusClass} ${props.class || ""}`,
605
+ }),
606
+ );
607
+ };
175
608
 
609
+ const SwitchCore = (props, _) => {
610
+ let checkedValue = "checked" in props ? props.checked : props.value;
611
+ let checked = resolveBooleanState(checkedValue);
612
+ let focusClass = resolveInputFocusClass(props.outline);
613
+ let defaultClass =
614
+ "cursor-pointer border-primary bg-primary";
176
615
  delete props.outline;
177
616
  delete props.checked;
178
617
  delete props.value;
@@ -182,17 +621,150 @@ const CheckBoxCore = (props, _) => {
182
621
  input({
183
622
  ...props,
184
623
  type: "checkbox",
624
+ role: "switch",
185
625
  checked: !!checked.get(),
186
626
  change: (e) => {
187
627
  checked.set(!!e.target.checked);
188
628
  props.change && props.change(e);
189
629
  props.input && props.input(e);
190
630
  },
191
- class: `checkbox ${defaultClass} ${outlineClass} ${props.class || ""}`,
631
+ class: `switch ${defaultClass} ${focusClass} ${props.class || ""}`.trim(),
192
632
  }),
193
633
  );
194
634
  };
195
635
 
636
+ const SliderCore = (props, _) => {
637
+ const focusClass = resolveInputFocusClass(props.outline);
638
+ const stepsValue = resolveCollectionState(props.steps, []);
639
+ const customStepsState = Compute(stepsValue, (steps) =>
640
+ isValidSliderSteps(steps) ? steps : null,
641
+ );
642
+ const minState = resolveNumericState(props.min, 0);
643
+ const maxState = resolveNumericState(props.max, 100);
644
+ const stepState = resolveNumericState(props.step, 1);
645
+ const initialCustomSteps = customStepsState.get();
646
+ const initialValue = initialCustomSteps
647
+ ? initialCustomSteps[0].value
648
+ : toSliderNumber(props.value, toSliderNumber(minState.get(), 0));
649
+ const valueState = resolveNumericState(props.value, initialValue);
650
+ const sliderValue = useState(
651
+ initialCustomSteps
652
+ ? findNearestSliderStepIndex(initialCustomSteps, valueState.get())
653
+ : toSliderNumber(valueState.get(), toSliderNumber(minState.get(), 0)),
654
+ );
655
+ const sliderBoundsState = Compute(
656
+ [customStepsState, minState, maxState, stepState],
657
+ (steps, min, max, step) => ({
658
+ min: steps ? 0 : min,
659
+ max: steps ? steps.length - 1 : max,
660
+ step: steps ? 1 : step,
661
+ }),
662
+ );
663
+ const sliderStepLabels = Compute(customStepsState, (steps) =>
664
+ steps && hasSliderStepLabels(steps)
665
+ ? steps.map((step, index) => ({
666
+ ...step,
667
+ position: steps.length === 1 ? 0 : (index / (steps.length - 1)) * 100,
668
+ alignment:
669
+ index === 0
670
+ ? "slider-step-label-start"
671
+ : index === steps.length - 1
672
+ ? "slider-step-label-end"
673
+ : "slider-step-label-center",
674
+ }))
675
+ : [],
676
+ );
677
+ const shouldRenderLabels = sliderStepLabels.map((steps) => steps.length > 0);
678
+
679
+ delete props.outline;
680
+ delete props.min;
681
+ delete props.max;
682
+ delete props.step;
683
+ delete props.steps;
684
+ delete props.value;
685
+
686
+ useEffect((nextValue) => {
687
+ const customSteps = customStepsState.get();
688
+ if (customSteps) {
689
+ sliderValue.set(findNearestSliderStepIndex(customSteps, nextValue));
690
+ return;
691
+ }
692
+
693
+ sliderValue.set(toSliderNumber(nextValue, toSliderNumber(minState.get(), 0)));
694
+ }, valueState);
695
+
696
+ useEffect((customSteps) => {
697
+ const currentValue = toSliderNumber(valueState.get(), toSliderNumber(minState.get(), 0));
698
+
699
+ if (customSteps) {
700
+ const nextStep = customSteps.find((step) => step.value === currentValue) ?? customSteps[0];
701
+ sliderValue.set(findNearestSliderStepIndex(customSteps, nextStep.value));
702
+
703
+ if (nextStep.value !== currentValue) {
704
+ valueState.set(nextStep.value);
705
+ props.input &&
706
+ props.input({
707
+ target: { value: nextStep.value },
708
+ currentTarget: { value: nextStep.value },
709
+ step: nextStep,
710
+ });
711
+ }
712
+ return;
713
+ }
714
+
715
+ sliderValue.set(currentValue);
716
+ }, customStepsState);
717
+
718
+ return wrapIntoLabel(
719
+ props,
720
+ Column(
721
+ { gap: "small", class: "slider-field" },
722
+ input({
723
+ ...props,
724
+ type: "range",
725
+ min: sliderBoundsState.map((bounds) => bounds.min),
726
+ max: sliderBoundsState.map((bounds) => bounds.max),
727
+ step: sliderBoundsState.map((bounds) => bounds.step),
728
+ value: sliderValue,
729
+ disabled: props.disabled,
730
+ class: `slider ${focusClass} ${props.class || ""}`.trim(),
731
+ input: (e) => {
732
+ const rawValue = toSliderNumber(e.target.value, sliderValue.get());
733
+ const customSteps = customStepsState.get();
734
+ const nextValue = customSteps
735
+ ? getSliderStepValue(customSteps, rawValue)
736
+ : rawValue;
737
+
738
+ sliderValue.set(customSteps ? findNearestSliderStepIndex(customSteps, nextValue) : nextValue);
739
+
740
+ if (typeof valueState.set === "function") valueState.set(nextValue);
741
+ props.input && props.input(e);
742
+ },
743
+ }),
744
+ Show(shouldRenderLabels, () =>
745
+ div(
746
+ {
747
+ class: "slider-step-labels",
748
+ },
749
+ ForEach(sliderStepLabels, "value", (step) =>
750
+ div(
751
+ {
752
+ class: `slider-step-label ${step.alignment}`,
753
+ style: {
754
+ left: `${step.position}%`,
755
+ },
756
+ },
757
+ step.label
758
+ ? Text({ weight: "heavy", color: "secondary" }, step.label)
759
+ : "",
760
+ ),
761
+ ),
762
+ ),
763
+ ),
764
+ ),
765
+ );
766
+ };
767
+
196
768
  // Exports
197
769
 
198
770
  /**
@@ -217,6 +789,69 @@ export const TextInput = withNormalizedArgs((props, ...children) =>
217
789
  )({ minHeight: 32, textSize: "1rem", ...props }, ...children),
218
790
  );
219
791
 
792
+ /**
793
+ * Multiline text input with optional auto-growing height.
794
+ *
795
+ * @param {Object} props - Component props
796
+ * @param {Object|string|number} [props.value] - Textarea value (useState object or string)
797
+ * @param {string} [props.placeholder] - Placeholder text
798
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
799
+ * @param {boolean} [props.outline] - Show focus outline (default: false)
800
+ * @param {boolean} [props.disabled] - Disabled state
801
+ * @param {number} [props.minLines=3] - Minimum visible lines
802
+ * @param {number} [props.maxLines=3] - Maximum visible lines before scrolling
803
+ * Shift+Enter inserts a newline. Enter submits the parent form when available.
804
+ * @param {Function} [props.input] - Input event handler
805
+ * @param {string} [props.class] - Additional CSS classes
806
+ * @param {...*} children - Children elements (ignored)
807
+ * @returns {*} TextArea component
808
+ */
809
+ export const TextArea = withNormalizedArgs((props, ...children) =>
810
+ withExtractedStyles((finalProps, ...children) =>
811
+ TextAreaCore(finalProps, ...children),
812
+ )({ textSize: "1rem", border: "primary", ...props }, ...children),
813
+ );
814
+
815
+ /**
816
+ * Menu-backed selector with input-like trigger rendering.
817
+ *
818
+ * @param {Object} props - Component props
819
+ * @param {Object|string} [props.value] - Selected item key (useState object or string)
820
+ * @param {Array<{key: string, text?: string, icon?: string, action?: Function, divider?: boolean}>} [props.options] - Menu-style options array
821
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
822
+ * @param {boolean} [props.outline] - Show focus outline
823
+ * @param {boolean} [props.disabled] - Disabled state
824
+ * @param {string} [props.anchor="bottom-left"] - Menu anchor position
825
+ * @param {Function} [props.input] - Called with an event-like object after selection
826
+ * @param {string} [props.class] - Additional CSS classes
827
+ * @param {...*} children - Children elements (ignored)
828
+ * @returns {*} Picker component
829
+ */
830
+ export const Picker = withNormalizedArgs((props, ...children) =>
831
+ PickerCore(props, ...children),
832
+ );
833
+
834
+ /**
835
+ * iOS-style segmented picker with single keyed selection.
836
+ *
837
+ * @param {Object} props - Component props
838
+ * @param {Object|string} [props.value] - Selected segment key (useState object or string)
839
+ * @param {Array<{key: string, text: string, icon?: string}>} [props.items] - Segmented picker items
840
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
841
+ * @param {boolean} [props.outline] - Show focus outline
842
+ * @param {boolean} [props.disabled] - Disabled state
843
+ * @param {Function} [props.change] - Called with an event-like object after selection
844
+ * @param {Function} [props.input] - Called with an event-like object after selection
845
+ * @param {string} [props.class] - Additional CSS classes
846
+ * @param {...*} children - Children elements (ignored)
847
+ * @returns {*} SegmentedPicker component
848
+ */
849
+ export const SegmentedPicker = withNormalizedArgs((props, ...children) =>
850
+ withExtractedStyles((finalProps, ...children) =>
851
+ SegmentedPickerCore(finalProps, ...children),
852
+ )({ textSize: "1rem", ...props }, ...children),
853
+ );
854
+
220
855
  /**
221
856
  * Dropdown select input with options.
222
857
  *
@@ -257,3 +892,47 @@ export const CheckBox = withNormalizedArgs((props, ...children) =>
257
892
  CheckBoxCore(finalProps, ...children),
258
893
  )({ textSize: "1rem", ...props }, ...children),
259
894
  );
895
+
896
+ /**
897
+ * OS-style switch toggle with state binding.
898
+ *
899
+ * @param {Object} props - Component props
900
+ * @param {Object|boolean} [props.checked] - Checked state (useState object or boolean)
901
+ * @param {Object|boolean} [props.value] - Alias for checked state
902
+ * @param {boolean} [props.outline] - Show focus outline
903
+ * @param {string} [props.label] - Label text (wraps in Row with Heading)
904
+ * @param {boolean} [props.disabled] - Disabled state
905
+ * @param {Function} [props.change] - Change event handler
906
+ * @param {Function} [props.input] - Input event handler
907
+ * @param {string} [props.class] - Additional CSS classes
908
+ * @param {...*} children - Children elements (ignored)
909
+ * @returns {*} Switch component
910
+ */
911
+ export const Switch = withNormalizedArgs((props, ...children) =>
912
+ withExtractedStyles((finalProps, ...children) =>
913
+ SwitchCore(finalProps, ...children),
914
+ )({ textSize: "1rem", ...props }, ...children),
915
+ );
916
+
917
+ /**
918
+ * Range slider input with optional discrete step mapping.
919
+ *
920
+ * @param {Object} props - Component props
921
+ * @param {Object|number} [props.value] - Slider value (useState object or number)
922
+ * @param {Object|number} [props.min=0] - Minimum numeric value in native mode
923
+ * @param {Object|number} [props.max=100] - Maximum numeric value in native mode
924
+ * @param {Object|number} [props.step=1] - Step increment in native mode
925
+ * @param {Array<{value: number, label?: string}>} [props.steps] - Discrete step configuration; ignores min/max/step when valid
926
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
927
+ * @param {boolean} [props.outline] - Show focus outline
928
+ * @param {boolean} [props.disabled] - Disabled state
929
+ * @param {Function} [props.input] - Input event handler
930
+ * @param {string} [props.class] - Additional CSS classes
931
+ * @param {...*} children - Children elements (ignored)
932
+ * @returns {*} Slider component
933
+ */
934
+ export const Slider = withNormalizedArgs((props, ...children) =>
935
+ withExtractedStyles((finalProps, ...children) =>
936
+ SliderCore(finalProps, ...children),
937
+ )({ fillWidth: true, ...props }, ...children),
938
+ );