@bunnix/components 0.10.3 → 0.11.1

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 (149) 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 +17 -3
  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 +723 -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/icons/add-circle.svg +0 -1
  25. package/src/icons/add.svg +0 -1
  26. package/src/icons/alt.svg +0 -1
  27. package/src/icons/archive.svg +0 -1
  28. package/src/icons/arrow-down.svg +0 -1
  29. package/src/icons/arrow-left.svg +0 -1
  30. package/src/icons/arrow-right.svg +0 -1
  31. package/src/icons/arrow-up.svg +0 -1
  32. package/src/icons/at.svg +0 -1
  33. package/src/icons/attestation.svg +0 -1
  34. package/src/icons/battery-25.svg +0 -1
  35. package/src/icons/bell.svg +0 -3
  36. package/src/icons/bookmark.svg +0 -1
  37. package/src/icons/bot.svg +0 -1
  38. package/src/icons/bubble.svg +0 -1
  39. package/src/icons/building.svg +0 -3
  40. package/src/icons/button.svg +0 -1
  41. package/src/icons/calculate.svg +0 -1
  42. package/src/icons/calendar.svg +0 -1
  43. package/src/icons/captions-bubble.svg +0 -1
  44. package/src/icons/cart.svg +0 -1
  45. package/src/icons/chart.svg +0 -1
  46. package/src/icons/check.svg +0 -1
  47. package/src/icons/chevron-down.svg +0 -1
  48. package/src/icons/chevron-left.svg +0 -1
  49. package/src/icons/chevron-right.svg +0 -1
  50. package/src/icons/clip.svg +0 -1
  51. package/src/icons/clock.svg +0 -3
  52. package/src/icons/close-circle.svg +0 -3
  53. package/src/icons/close.svg +0 -1
  54. package/src/icons/cloud-download.svg +0 -1
  55. package/src/icons/cloud-upload.svg +0 -1
  56. package/src/icons/cloud.svg +0 -1
  57. package/src/icons/columns-layout.svg +0 -1
  58. package/src/icons/command.svg +0 -1
  59. package/src/icons/cube.svg +0 -1
  60. package/src/icons/delete.svg +0 -3
  61. package/src/icons/dollar.svg +0 -3
  62. package/src/icons/download.svg +0 -1
  63. package/src/icons/draw.svg +0 -1
  64. package/src/icons/duplicate.svg +0 -3
  65. package/src/icons/ear.svg +0 -1
  66. package/src/icons/edit.svg +0 -1
  67. package/src/icons/exclamation-mark.svg +0 -1
  68. package/src/icons/eye-open.svg +0 -1
  69. package/src/icons/eye.svg +0 -1
  70. package/src/icons/file-html.svg +0 -1
  71. package/src/icons/file.svg +0 -3
  72. package/src/icons/finger.svg +0 -1
  73. package/src/icons/flag.svg +0 -1
  74. package/src/icons/folder.svg +0 -1
  75. package/src/icons/function.svg +0 -1
  76. package/src/icons/gear.svg +0 -1
  77. package/src/icons/gift.svg +0 -1
  78. package/src/icons/globe.svg +0 -3
  79. package/src/icons/grid.svg +0 -1
  80. package/src/icons/hammer.svg +0 -1
  81. package/src/icons/hand.svg +0 -1
  82. package/src/icons/hare.svg +0 -1
  83. package/src/icons/heart.svg +0 -3
  84. package/src/icons/home.svg +0 -3
  85. package/src/icons/image.svg +0 -1
  86. package/src/icons/inbox.svg +0 -3
  87. package/src/icons/info.svg +0 -1
  88. package/src/icons/key.svg +0 -1
  89. package/src/icons/lamp.svg +0 -1
  90. package/src/icons/link.svg +0 -1
  91. package/src/icons/location.svg +0 -1
  92. package/src/icons/locker.svg +0 -1
  93. package/src/icons/login.svg +0 -1
  94. package/src/icons/logout.svg +0 -3
  95. package/src/icons/mail.svg +0 -3
  96. package/src/icons/map.svg +0 -3
  97. package/src/icons/markup.svg +0 -1
  98. package/src/icons/merge.svg +0 -1
  99. package/src/icons/more-horizontal.svg +0 -5
  100. package/src/icons/more-vertical.svg +0 -5
  101. package/src/icons/mouse.svg +0 -1
  102. package/src/icons/music-mic.svg +0 -1
  103. package/src/icons/paintbrush.svg +0 -1
  104. package/src/icons/palette.svg +0 -1
  105. package/src/icons/password.svg +0 -1
  106. package/src/icons/pencil.svg +0 -1
  107. package/src/icons/people.svg +0 -3
  108. package/src/icons/percent.svg +0 -1
  109. package/src/icons/person-add.svg +0 -1
  110. package/src/icons/person-remove.svg +0 -1
  111. package/src/icons/person.svg +0 -4
  112. package/src/icons/phone.svg +0 -1
  113. package/src/icons/pin.svg +0 -1
  114. package/src/icons/question-circle.svg +0 -3
  115. package/src/icons/remove-circle.svg +0 -1
  116. package/src/icons/return-arrow.svg +0 -1
  117. package/src/icons/save.svg +0 -1
  118. package/src/icons/search.svg +0 -1
  119. package/src/icons/sections.svg +0 -1
  120. package/src/icons/send.svg +0 -1
  121. package/src/icons/share.svg +0 -1
  122. package/src/icons/shine.svg +0 -1
  123. package/src/icons/sliders.svg +0 -1
  124. package/src/icons/star.svg +0 -3
  125. package/src/icons/staroflife.svg +0 -1
  126. package/src/icons/storage.svg +0 -1
  127. package/src/icons/success-circle.svg +0 -3
  128. package/src/icons/swap.svg +0 -1
  129. package/src/icons/switch.svg +0 -1
  130. package/src/icons/sync.svg +0 -3
  131. package/src/icons/table.svg +0 -3
  132. package/src/icons/tag.svg +0 -3
  133. package/src/icons/terminal.svg +0 -1
  134. package/src/icons/text.svg +0 -1
  135. package/src/icons/thumb-down.svg +0 -1
  136. package/src/icons/thumb-up.svg +0 -1
  137. package/src/icons/timer.svg +0 -3
  138. package/src/icons/toggle.svg +0 -1
  139. package/src/icons/trash.svg +0 -1
  140. package/src/icons/tv-music.svg +0 -1
  141. package/src/icons/update-page.svg +0 -1
  142. package/src/icons/upload.svg +0 -1
  143. package/src/icons/video.svg +0 -1
  144. package/src/icons/wallet.svg +0 -1
  145. package/src/icons/wand-stars.svg +0 -1
  146. package/src/icons/waveform.svg +0 -1
  147. package/src/icons/window.svg +0 -1
  148. package/src/utils/iconRegistry.generated.mjs +0 -187
  149. 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,373 @@ 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 || ""}`,
242
+ }),
243
+ );
244
+ };
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
+ },
121
314
  }),
122
315
  );
123
316
  };
124
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
+ const triggerColor = finalTriggerProps.style?.color;
402
+
403
+ return button(
404
+ {
405
+ ...finalTriggerProps,
406
+ type: "button",
407
+ disabled: disabledValue,
408
+ click: () => {
409
+ if (isDisabled) return;
410
+ toggle();
411
+ },
412
+ class: `picker-trigger ${defaultClass} ${focusClass} ${
413
+ isDisabled ? "picker-trigger-disabled" : ""
414
+ } ${finalTriggerProps.class || ""}`.trim(),
415
+ },
416
+ Row(
417
+ { fillWidth: true, alignItems: "center", gap: "small" },
418
+ div(
419
+ { class: "picker-selection" },
420
+ ...(selectedItem?.icon
421
+ ? [
422
+ Icon({
423
+ name: selectedItem.icon,
424
+ size: 16,
425
+ ...(triggerColor ? { color: triggerColor } : {}),
426
+ }),
427
+ ]
428
+ : []),
429
+ ...(selectedItem
430
+ ? [
431
+ Text(
432
+ {
433
+ weight: "heavy",
434
+ ...(triggerColor ? { color: triggerColor } : {}),
435
+ },
436
+ selectedItem.text ?? selectedItem.key,
437
+ ),
438
+ ]
439
+ : []),
440
+ ),
441
+ Spacer(),
442
+ Icon({
443
+ name: "chevron_down",
444
+ size: 16,
445
+ ...(triggerColor ? { color: triggerColor } : { color: "secondary" }),
446
+ }),
447
+ ),
448
+ );
449
+ },
450
+ })
451
+ )({ minHeight: 32, textSize: "1rem", ...triggerProps }),
452
+ ),
453
+ ),
454
+ );
455
+ };
456
+
457
+ const SegmentedPickerCore = (props, _) => {
458
+ const value =
459
+ props.value?.get && props.value?.set
460
+ ? props.value
461
+ : useState(props.value ?? "");
462
+ const itemsValue = resolveCollectionState(props.items, []);
463
+ const focusClass = resolveInputFocusClass(props.outline);
464
+ const segmentedPickerState = Compute(
465
+ [value, itemsValue],
466
+ (selectedKey, resolvedItems) =>
467
+ ({
468
+ selectedKey,
469
+ selectedItem: (resolvedItems ?? []).find((item) => item.key === selectedKey) ?? null,
470
+ segments: (resolvedItems ?? []).map((item) => ({
471
+ ...item,
472
+ selected: item.key === selectedKey,
473
+ })),
474
+ }),
475
+ );
476
+ const segmentedPickerItems = segmentedPickerState.map((state) => state.segments);
477
+
478
+ useEffect(({ selectedKey, selectedItem }) => {
479
+ if (!selectedKey || selectedItem) return;
480
+
481
+ value.set("");
482
+
483
+ const eventLike = {
484
+ target: { value: "" },
485
+ currentTarget: { value: "" },
486
+ item: null,
487
+ };
488
+
489
+ props.change && props.change(eventLike);
490
+ props.input && props.input(eventLike);
491
+ }, segmentedPickerState);
492
+
493
+ delete props.outline;
494
+ delete props.items;
495
+
496
+ return wrapIntoLabel(
497
+ props,
498
+ div(
499
+ {
500
+ class: `segmented-picker border-primary bg-primary-dimmed radius-lg ${
501
+ props.disabled ? "segmented-picker-disabled" : ""
502
+ } ${focusClass} ${props.class || ""}`.trim(),
503
+ },
504
+ ForEach(segmentedPickerItems, "key", (item) =>
505
+ button(
506
+ {
507
+ type: "button",
508
+ disabled: !!props.disabled,
509
+ click: () => {
510
+ if (props.disabled || item.selected) return;
511
+
512
+ value.set(item.key);
513
+
514
+ const eventLike = {
515
+ target: { value: item.key },
516
+ currentTarget: { value: item.key },
517
+ item,
518
+ };
519
+
520
+ props.change && props.change(eventLike);
521
+ props.input && props.input(eventLike);
522
+ },
523
+ class: `segmented-picker-segment ${
524
+ item.selected ? "segmented-picker-segment-selected" : ""
525
+ }`,
526
+ },
527
+ Row(
528
+ {
529
+ class: "segmented-picker-segment-content",
530
+ alignItems: "center",
531
+ justifyContent: "center",
532
+ gap: item.icon ? "small" : 0,
533
+ width: "100%",
534
+ },
535
+ ...(item.icon ? [Icon({ name: item.icon, size: 16 })] : []),
536
+ Text({ weight: "heavy" }, item.text),
537
+ ),
538
+ ),
539
+ ),
540
+ ),
541
+ );
542
+ };
543
+
125
544
  /** Select core component and logic */
126
545
  const SelectCore = (props, _) => {
127
546
  let value =
128
547
  props.value?.get && props.value?.set
129
548
  ? props.value
130
549
  : useState(props.value ?? "");
131
- let options = props.options ?? [];
132
- let outline = props.outline ?? false;
550
+ let optionsValue = resolveCollectionState(props.options, []);
551
+ let focusClass = resolveInputFocusClass(props.outline);
552
+ const selectedOptionState = Compute([value, optionsValue], (selectedKey, resolvedOptions) => {
553
+ const options = resolvedOptions ?? [];
554
+ const selectedOption = options.find((option) => option.key === selectedKey);
555
+ return { selectedKey, selectedOption };
556
+ });
557
+
558
+ useEffect(({ selectedKey, selectedOption }) => {
559
+ if (!selectedKey || selectedOption) return;
560
+
561
+ value.set("");
562
+ props.input &&
563
+ props.input({
564
+ target: { value: "" },
565
+ currentTarget: { value: "" },
566
+ option: null,
567
+ });
568
+ }, selectedOptionState);
133
569
 
134
570
  delete props.options;
135
571
  delete props.outline;
136
572
 
137
- let childrenOptions = options.map((o, index) => {
138
- return option(
573
+ // Use ForEach for reactive option rendering - updates when value changes
574
+ let childrenOptions = ForEach(optionsValue, "key", (o, index) =>
575
+ option(
139
576
  {
140
577
  value: o.key ?? `option ${index + 1}`,
141
578
  selected: o.key === value.get(),
142
579
  },
143
580
  o.content ?? ``,
144
- );
145
- });
581
+ ),
582
+ );
146
583
 
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"}`;
584
+ let defaultClass = `appearance-none padding-sm bg-primary border-primary radius-md flex-grow-1 ${focusClass}`;
148
585
 
149
586
  return wrapIntoLabel(
150
587
  props,
151
588
  select(
152
589
  {
153
590
  ...props,
591
+ value: value,
154
592
  input: (e) => {
155
593
  value.set(e.target.value ?? "");
156
594
  props.input && props.input(e);
@@ -165,14 +603,36 @@ const SelectCore = (props, _) => {
165
603
  /** Checkbox core component and logic */
166
604
  const CheckBoxCore = (props, _) => {
167
605
  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";
606
+ let checked = resolveBooleanState(checkedValue);
607
+ let focusClass = resolveInputFocusClass(props.outline);
173
608
  let defaultClass =
174
- "cursor-pointer border-primary radius-md focus-border-outline bg-primary";
609
+ "cursor-pointer border-primary radius-md bg-primary";
610
+ delete props.outline;
611
+ delete props.checked;
612
+ delete props.value;
175
613
 
614
+ return wrapCheckBoxIntoLabel(
615
+ props,
616
+ input({
617
+ ...props,
618
+ type: "checkbox",
619
+ checked: !!checked.get(),
620
+ change: (e) => {
621
+ checked.set(!!e.target.checked);
622
+ props.change && props.change(e);
623
+ props.input && props.input(e);
624
+ },
625
+ class: `checkbox ${defaultClass} ${focusClass} ${props.class || ""}`,
626
+ }),
627
+ );
628
+ };
629
+
630
+ const SwitchCore = (props, _) => {
631
+ let checkedValue = "checked" in props ? props.checked : props.value;
632
+ let checked = resolveBooleanState(checkedValue);
633
+ let focusClass = resolveInputFocusClass(props.outline);
634
+ let defaultClass =
635
+ "cursor-pointer border-primary bg-primary";
176
636
  delete props.outline;
177
637
  delete props.checked;
178
638
  delete props.value;
@@ -182,17 +642,150 @@ const CheckBoxCore = (props, _) => {
182
642
  input({
183
643
  ...props,
184
644
  type: "checkbox",
645
+ role: "switch",
185
646
  checked: !!checked.get(),
186
647
  change: (e) => {
187
648
  checked.set(!!e.target.checked);
188
649
  props.change && props.change(e);
189
650
  props.input && props.input(e);
190
651
  },
191
- class: `checkbox ${defaultClass} ${outlineClass} ${props.class || ""}`,
652
+ class: `switch ${defaultClass} ${focusClass} ${props.class || ""}`.trim(),
192
653
  }),
193
654
  );
194
655
  };
195
656
 
657
+ const SliderCore = (props, _) => {
658
+ const focusClass = resolveInputFocusClass(props.outline);
659
+ const stepsValue = resolveCollectionState(props.steps, []);
660
+ const customStepsState = Compute(stepsValue, (steps) =>
661
+ isValidSliderSteps(steps) ? steps : null,
662
+ );
663
+ const minState = resolveNumericState(props.min, 0);
664
+ const maxState = resolveNumericState(props.max, 100);
665
+ const stepState = resolveNumericState(props.step, 1);
666
+ const initialCustomSteps = customStepsState.get();
667
+ const initialValue = initialCustomSteps
668
+ ? initialCustomSteps[0].value
669
+ : toSliderNumber(props.value, toSliderNumber(minState.get(), 0));
670
+ const valueState = resolveNumericState(props.value, initialValue);
671
+ const sliderValue = useState(
672
+ initialCustomSteps
673
+ ? findNearestSliderStepIndex(initialCustomSteps, valueState.get())
674
+ : toSliderNumber(valueState.get(), toSliderNumber(minState.get(), 0)),
675
+ );
676
+ const sliderBoundsState = Compute(
677
+ [customStepsState, minState, maxState, stepState],
678
+ (steps, min, max, step) => ({
679
+ min: steps ? 0 : min,
680
+ max: steps ? steps.length - 1 : max,
681
+ step: steps ? 1 : step,
682
+ }),
683
+ );
684
+ const sliderStepLabels = Compute(customStepsState, (steps) =>
685
+ steps && hasSliderStepLabels(steps)
686
+ ? steps.map((step, index) => ({
687
+ ...step,
688
+ position: steps.length === 1 ? 0 : (index / (steps.length - 1)) * 100,
689
+ alignment:
690
+ index === 0
691
+ ? "slider-step-label-start"
692
+ : index === steps.length - 1
693
+ ? "slider-step-label-end"
694
+ : "slider-step-label-center",
695
+ }))
696
+ : [],
697
+ );
698
+ const shouldRenderLabels = sliderStepLabels.map((steps) => steps.length > 0);
699
+
700
+ delete props.outline;
701
+ delete props.min;
702
+ delete props.max;
703
+ delete props.step;
704
+ delete props.steps;
705
+ delete props.value;
706
+
707
+ useEffect((nextValue) => {
708
+ const customSteps = customStepsState.get();
709
+ if (customSteps) {
710
+ sliderValue.set(findNearestSliderStepIndex(customSteps, nextValue));
711
+ return;
712
+ }
713
+
714
+ sliderValue.set(toSliderNumber(nextValue, toSliderNumber(minState.get(), 0)));
715
+ }, valueState);
716
+
717
+ useEffect((customSteps) => {
718
+ const currentValue = toSliderNumber(valueState.get(), toSliderNumber(minState.get(), 0));
719
+
720
+ if (customSteps) {
721
+ const nextStep = customSteps.find((step) => step.value === currentValue) ?? customSteps[0];
722
+ sliderValue.set(findNearestSliderStepIndex(customSteps, nextStep.value));
723
+
724
+ if (nextStep.value !== currentValue) {
725
+ valueState.set(nextStep.value);
726
+ props.input &&
727
+ props.input({
728
+ target: { value: nextStep.value },
729
+ currentTarget: { value: nextStep.value },
730
+ step: nextStep,
731
+ });
732
+ }
733
+ return;
734
+ }
735
+
736
+ sliderValue.set(currentValue);
737
+ }, customStepsState);
738
+
739
+ return wrapIntoLabel(
740
+ props,
741
+ Column(
742
+ { gap: "small", class: "slider-field" },
743
+ input({
744
+ ...props,
745
+ type: "range",
746
+ min: sliderBoundsState.map((bounds) => bounds.min),
747
+ max: sliderBoundsState.map((bounds) => bounds.max),
748
+ step: sliderBoundsState.map((bounds) => bounds.step),
749
+ value: sliderValue,
750
+ disabled: props.disabled,
751
+ class: `slider ${focusClass} ${props.class || ""}`.trim(),
752
+ input: (e) => {
753
+ const rawValue = toSliderNumber(e.target.value, sliderValue.get());
754
+ const customSteps = customStepsState.get();
755
+ const nextValue = customSteps
756
+ ? getSliderStepValue(customSteps, rawValue)
757
+ : rawValue;
758
+
759
+ sliderValue.set(customSteps ? findNearestSliderStepIndex(customSteps, nextValue) : nextValue);
760
+
761
+ if (typeof valueState.set === "function") valueState.set(nextValue);
762
+ props.input && props.input(e);
763
+ },
764
+ }),
765
+ Show(shouldRenderLabels, () =>
766
+ div(
767
+ {
768
+ class: "slider-step-labels",
769
+ },
770
+ ForEach(sliderStepLabels, "value", (step) =>
771
+ div(
772
+ {
773
+ class: `slider-step-label ${step.alignment}`,
774
+ style: {
775
+ left: `${step.position}%`,
776
+ },
777
+ },
778
+ step.label
779
+ ? Text({ weight: "heavy", color: "secondary" }, step.label)
780
+ : "",
781
+ ),
782
+ ),
783
+ ),
784
+ ),
785
+ ),
786
+ );
787
+ };
788
+
196
789
  // Exports
197
790
 
198
791
  /**
@@ -217,6 +810,69 @@ export const TextInput = withNormalizedArgs((props, ...children) =>
217
810
  )({ minHeight: 32, textSize: "1rem", ...props }, ...children),
218
811
  );
219
812
 
813
+ /**
814
+ * Multiline text input with optional auto-growing height.
815
+ *
816
+ * @param {Object} props - Component props
817
+ * @param {Object|string|number} [props.value] - Textarea value (useState object or string)
818
+ * @param {string} [props.placeholder] - Placeholder text
819
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
820
+ * @param {boolean} [props.outline] - Show focus outline (default: false)
821
+ * @param {boolean} [props.disabled] - Disabled state
822
+ * @param {number} [props.minLines=3] - Minimum visible lines
823
+ * @param {number} [props.maxLines=3] - Maximum visible lines before scrolling
824
+ * Shift+Enter inserts a newline. Enter submits the parent form when available.
825
+ * @param {Function} [props.input] - Input event handler
826
+ * @param {string} [props.class] - Additional CSS classes
827
+ * @param {...*} children - Children elements (ignored)
828
+ * @returns {*} TextArea component
829
+ */
830
+ export const TextArea = withNormalizedArgs((props, ...children) =>
831
+ withExtractedStyles((finalProps, ...children) =>
832
+ TextAreaCore(finalProps, ...children),
833
+ )({ textSize: "1rem", border: "primary", ...props }, ...children),
834
+ );
835
+
836
+ /**
837
+ * Menu-backed selector with input-like trigger rendering.
838
+ *
839
+ * @param {Object} props - Component props
840
+ * @param {Object|string} [props.value] - Selected item key (useState object or string)
841
+ * @param {Array<{key: string, text?: string, icon?: string, action?: Function, divider?: boolean}>} [props.options] - Menu-style options array
842
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
843
+ * @param {boolean} [props.outline] - Show focus outline
844
+ * @param {boolean} [props.disabled] - Disabled state
845
+ * @param {string} [props.anchor="bottom-left"] - Menu anchor position
846
+ * @param {Function} [props.input] - Called with an event-like object after selection
847
+ * @param {string} [props.class] - Additional CSS classes
848
+ * @param {...*} children - Children elements (ignored)
849
+ * @returns {*} Picker component
850
+ */
851
+ export const Picker = withNormalizedArgs((props, ...children) =>
852
+ PickerCore(props, ...children),
853
+ );
854
+
855
+ /**
856
+ * iOS-style segmented picker with single keyed selection.
857
+ *
858
+ * @param {Object} props - Component props
859
+ * @param {Object|string} [props.value] - Selected segment key (useState object or string)
860
+ * @param {Array<{key: string, text: string, icon?: string}>} [props.items] - Segmented picker items
861
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
862
+ * @param {boolean} [props.outline] - Show focus outline
863
+ * @param {boolean} [props.disabled] - Disabled state
864
+ * @param {Function} [props.change] - Called with an event-like object after selection
865
+ * @param {Function} [props.input] - Called with an event-like object after selection
866
+ * @param {string} [props.class] - Additional CSS classes
867
+ * @param {...*} children - Children elements (ignored)
868
+ * @returns {*} SegmentedPicker component
869
+ */
870
+ export const SegmentedPicker = withNormalizedArgs((props, ...children) =>
871
+ withExtractedStyles((finalProps, ...children) =>
872
+ SegmentedPickerCore(finalProps, ...children),
873
+ )({ textSize: "1rem", ...props }, ...children),
874
+ );
875
+
220
876
  /**
221
877
  * Dropdown select input with options.
222
878
  *
@@ -257,3 +913,47 @@ export const CheckBox = withNormalizedArgs((props, ...children) =>
257
913
  CheckBoxCore(finalProps, ...children),
258
914
  )({ textSize: "1rem", ...props }, ...children),
259
915
  );
916
+
917
+ /**
918
+ * OS-style switch toggle with state binding.
919
+ *
920
+ * @param {Object} props - Component props
921
+ * @param {Object|boolean} [props.checked] - Checked state (useState object or boolean)
922
+ * @param {Object|boolean} [props.value] - Alias for checked state
923
+ * @param {boolean} [props.outline] - Show focus outline
924
+ * @param {string} [props.label] - Label text (wraps in Row with Heading)
925
+ * @param {boolean} [props.disabled] - Disabled state
926
+ * @param {Function} [props.change] - Change event handler
927
+ * @param {Function} [props.input] - Input event handler
928
+ * @param {string} [props.class] - Additional CSS classes
929
+ * @param {...*} children - Children elements (ignored)
930
+ * @returns {*} Switch component
931
+ */
932
+ export const Switch = withNormalizedArgs((props, ...children) =>
933
+ withExtractedStyles((finalProps, ...children) =>
934
+ SwitchCore(finalProps, ...children),
935
+ )({ textSize: "1rem", ...props }, ...children),
936
+ );
937
+
938
+ /**
939
+ * Range slider input with optional discrete step mapping.
940
+ *
941
+ * @param {Object} props - Component props
942
+ * @param {Object|number} [props.value] - Slider value (useState object or number)
943
+ * @param {Object|number} [props.min=0] - Minimum numeric value in native mode
944
+ * @param {Object|number} [props.max=100] - Maximum numeric value in native mode
945
+ * @param {Object|number} [props.step=1] - Step increment in native mode
946
+ * @param {Array<{value: number, label?: string}>} [props.steps] - Discrete step configuration; ignores min/max/step when valid
947
+ * @param {string} [props.label] - Label text (wraps in Column with Heading)
948
+ * @param {boolean} [props.outline] - Show focus outline
949
+ * @param {boolean} [props.disabled] - Disabled state
950
+ * @param {Function} [props.input] - Input event handler
951
+ * @param {string} [props.class] - Additional CSS classes
952
+ * @param {...*} children - Children elements (ignored)
953
+ * @returns {*} Slider component
954
+ */
955
+ export const Slider = withNormalizedArgs((props, ...children) =>
956
+ withExtractedStyles((finalProps, ...children) =>
957
+ SliderCore(finalProps, ...children),
958
+ )({ fillWidth: true, ...props }, ...children),
959
+ );