bitwrench 2.0.12 → 2.0.14

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.
@@ -176,8 +176,11 @@ export function makeCard(props = {}) {
176
176
  * variant: "success",
177
177
  * onclick: () => console.log("saved")
178
178
  * });
179
+ * // String shorthand:
180
+ * const ok = makeButton("OK");
179
181
  */
180
182
  export function makeButton(props = {}) {
183
+ if (typeof props === 'string') props = { text: props };
181
184
  const {
182
185
  text,
183
186
  variant = 'primary',
@@ -482,6 +485,7 @@ export function makeTabs(props = {}) {
482
485
  class: `bw-nav-link ${index === actualActiveIndex ? 'active' : ''}`,
483
486
  type: 'button',
484
487
  role: 'tab',
488
+ tabindex: index === actualActiveIndex ? '0' : '-1',
485
489
  'aria-selected': index === actualActiveIndex ? 'true' : 'false',
486
490
  'data-tab-index': index,
487
491
  onclick: (e) => {
@@ -492,11 +496,13 @@ export function makeTabs(props = {}) {
492
496
  allTabs.forEach(t => {
493
497
  t.classList.remove('active');
494
498
  t.setAttribute('aria-selected', 'false');
499
+ t.setAttribute('tabindex', '-1');
495
500
  });
496
501
  allPanes.forEach(p => p.classList.remove('active'));
497
502
 
498
503
  e.target.classList.add('active');
499
504
  e.target.setAttribute('aria-selected', 'true');
505
+ e.target.setAttribute('tabindex', '0');
500
506
  const targetIndex = parseInt(e.target.getAttribute('data-tab-index'));
501
507
  allPanes[targetIndex].classList.add('active');
502
508
  }
@@ -520,7 +526,39 @@ export function makeTabs(props = {}) {
520
526
  ],
521
527
  o: {
522
528
  type: 'tabs',
523
- state: { activeIndex: actualActiveIndex }
529
+ state: { activeIndex: actualActiveIndex },
530
+ mounted: function(el) {
531
+ var tablist = el.querySelector('[role="tablist"]');
532
+ if (!tablist) return;
533
+ tablist.addEventListener('keydown', function(e) {
534
+ var tabButtons = tablist.querySelectorAll('[role="tab"]');
535
+ var currentIndex = -1;
536
+ for (var i = 0; i < tabButtons.length; i++) {
537
+ if (tabButtons[i] === e.target) { currentIndex = i; break; }
538
+ }
539
+ if (currentIndex === -1) return;
540
+
541
+ var newIndex = -1;
542
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
543
+ e.preventDefault();
544
+ newIndex = currentIndex > 0 ? currentIndex - 1 : tabButtons.length - 1;
545
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
546
+ e.preventDefault();
547
+ newIndex = currentIndex < tabButtons.length - 1 ? currentIndex + 1 : 0;
548
+ } else if (e.key === 'Home') {
549
+ e.preventDefault();
550
+ newIndex = 0;
551
+ } else if (e.key === 'End') {
552
+ e.preventDefault();
553
+ newIndex = tabButtons.length - 1;
554
+ }
555
+
556
+ if (newIndex >= 0) {
557
+ tabButtons[newIndex].focus();
558
+ tabButtons[newIndex].click();
559
+ }
560
+ });
561
+ }
524
562
  }
525
563
  };
526
564
  }
@@ -541,8 +579,11 @@ export function makeTabs(props = {}) {
541
579
  * variant: "success",
542
580
  * dismissible: true
543
581
  * });
582
+ * // String shorthand:
583
+ * const msg = makeAlert("Something happened");
544
584
  */
545
585
  export function makeAlert(props = {}) {
586
+ if (typeof props === 'string') props = { content: props };
546
587
  const {
547
588
  content,
548
589
  variant = 'info',
@@ -589,8 +630,11 @@ export function makeAlert(props = {}) {
589
630
  * @example
590
631
  * const badge = makeBadge({ text: "New", variant: "danger", pill: true });
591
632
  * const small = makeBadge({ text: "3", variant: "info", size: "sm" });
633
+ * // String shorthand:
634
+ * const tag = makeBadge("New");
592
635
  */
593
636
  export function makeBadge(props = {}) {
637
+ if (typeof props === 'string') props = { text: props };
594
638
  const {
595
639
  text,
596
640
  variant = 'primary',
@@ -827,13 +871,16 @@ export function makeForm(props = {}) {
827
871
  }
828
872
 
829
873
  /**
830
- * Create a form group with label, input, and optional help text
874
+ * Create a form group with label, input, optional help text and validation feedback
831
875
  *
832
876
  * @param {Object} [props] - Form group configuration
833
877
  * @param {string} [props.label] - Label text
834
878
  * @param {Object} [props.input] - Input TACO object (from makeInput, makeSelect, etc.)
835
879
  * @param {string} [props.help] - Help text displayed below the input
836
880
  * @param {string} [props.id] - Input ID (links label to input via for/id)
881
+ * @param {string} [props.validation] - Validation state ("valid" or "invalid")
882
+ * @param {string} [props.feedback] - Validation feedback text shown below input
883
+ * @param {boolean} [props.required=false] - Show required indicator (*) on label
837
884
  * @returns {Object} TACO object representing a form group
838
885
  * @category Component Builders
839
886
  * @example
@@ -841,11 +888,22 @@ export function makeForm(props = {}) {
841
888
  * label: "Email",
842
889
  * id: "email",
843
890
  * input: makeInput({ type: "email", id: "email", placeholder: "you@example.com" }),
844
- * help: "We'll never share your email."
891
+ * validation: "invalid",
892
+ * feedback: "Please enter a valid email address."
845
893
  * });
846
894
  */
847
895
  export function makeFormGroup(props = {}) {
848
- const { label, input, help, id } = props;
896
+ var { label, input, help, id, validation, feedback, required } = props;
897
+
898
+ // Shallow-clone input TACO to add validation class without mutating original
899
+ var styledInput = input;
900
+ if (validation && input && input.a) {
901
+ styledInput = { t: input.t, a: Object.assign({}, input.a), c: input.c, o: input.o };
902
+ var validClass = validation === 'valid' ? 'bw-is-valid' : validation === 'invalid' ? 'bw-is-invalid' : '';
903
+ if (validClass) {
904
+ styledInput.a.class = ((styledInput.a.class || '') + ' ' + validClass).trim();
905
+ }
906
+ }
849
907
 
850
908
  return {
851
909
  t: 'div',
@@ -854,9 +912,14 @@ export function makeFormGroup(props = {}) {
854
912
  label && {
855
913
  t: 'label',
856
914
  a: { for: id, class: 'bw-form-label' },
857
- c: label
915
+ c: required ? [label, { t: 'span', a: { class: 'bw-text-danger', style: 'margin-left: 0.25rem' }, c: '*' }] : label
916
+ },
917
+ styledInput,
918
+ feedback && validation && {
919
+ t: 'div',
920
+ a: { class: validation === 'valid' ? 'bw-valid-feedback' : 'bw-invalid-feedback' },
921
+ c: feedback
858
922
  },
859
- input,
860
923
  help && {
861
924
  t: 'small',
862
925
  a: { class: 'bw-form-text bw-text-muted' },
@@ -1821,17 +1884,15 @@ export function makeCodeDemo(props = {}) {
1821
1884
  t: 'button',
1822
1885
  a: {
1823
1886
  class: 'bw-copy-btn bw-code-copy-btn',
1824
- onclick: (e) => {
1825
- navigator.clipboard.writeText(code).then(() => {
1826
- const btn = e.target;
1827
- const originalText = btn.textContent;
1887
+ onclick: function(e) {
1888
+ navigator.clipboard.writeText(code).then(function() {
1889
+ var btn = e.target;
1890
+ var originalText = btn.textContent;
1828
1891
  btn.textContent = 'Copied!';
1829
- btn.style.background = '#006666';
1830
- btn.style.color = '#fff';
1831
- setTimeout(() => {
1892
+ btn.classList.add('bw-code-copy-btn-copied');
1893
+ setTimeout(function() {
1832
1894
  btn.textContent = originalText;
1833
- btn.style.background = 'rgba(255,255,255,0.12)';
1834
- btn.style.color = '#aaa';
1895
+ btn.classList.remove('bw-code-copy-btn-copied');
1835
1896
  }, 2000);
1836
1897
  });
1837
1898
  }
@@ -2123,29 +2184,47 @@ export function makeAccordion(props = {}) {
2123
2184
  var isOpen = collapse.classList.contains('bw-collapse-show');
2124
2185
 
2125
2186
  if (!multiOpen) {
2126
- // Close all siblings
2127
- var allCollapses = accordionEl.querySelectorAll('.bw-accordion-collapse');
2128
- var allButtons = accordionEl.querySelectorAll('.bw-accordion-button');
2129
- for (var j = 0; j < allCollapses.length; j++) {
2130
- allCollapses[j].classList.remove('bw-collapse-show');
2131
- allCollapses[j].style.maxHeight = null;
2132
- }
2133
- for (var k = 0; k < allButtons.length; k++) {
2134
- allButtons[k].classList.add('bw-collapsed');
2135
- allButtons[k].setAttribute('aria-expanded', 'false');
2187
+ // Animate-close all other open siblings
2188
+ var allItems = accordionEl.querySelectorAll('.bw-accordion-item');
2189
+ for (var j = 0; j < allItems.length; j++) {
2190
+ if (allItems[j] === accordionItem) continue;
2191
+ var sibCollapse = allItems[j].querySelector('.bw-accordion-collapse');
2192
+ var sibBtn = allItems[j].querySelector('.bw-accordion-button');
2193
+ if (sibCollapse.classList.contains('bw-collapse-show')) {
2194
+ sibCollapse.style.maxHeight = sibCollapse.scrollHeight + 'px';
2195
+ sibCollapse.offsetHeight; // force reflow
2196
+ sibCollapse.style.maxHeight = '0px';
2197
+ sibCollapse.classList.remove('bw-collapse-show');
2198
+ sibBtn.classList.add('bw-collapsed');
2199
+ sibBtn.setAttribute('aria-expanded', 'false');
2200
+ }
2136
2201
  }
2137
2202
  }
2138
2203
 
2139
2204
  if (isOpen) {
2205
+ // Animate close
2206
+ collapse.style.maxHeight = collapse.scrollHeight + 'px';
2207
+ collapse.offsetHeight; // force reflow
2208
+ collapse.style.maxHeight = '0px';
2140
2209
  collapse.classList.remove('bw-collapse-show');
2141
- collapse.style.maxHeight = null;
2142
2210
  btn.classList.add('bw-collapsed');
2143
2211
  btn.setAttribute('aria-expanded', 'false');
2144
2212
  } else {
2213
+ // Animate open
2145
2214
  collapse.classList.add('bw-collapse-show');
2215
+ collapse.style.maxHeight = '0px';
2216
+ collapse.offsetHeight; // force reflow
2146
2217
  collapse.style.maxHeight = collapse.scrollHeight + 'px';
2147
2218
  btn.classList.remove('bw-collapsed');
2148
2219
  btn.setAttribute('aria-expanded', 'true');
2220
+ // After transition, allow dynamic content sizing
2221
+ var onEnd = function(ev) {
2222
+ if (ev.propertyName === 'max-height' && collapse.classList.contains('bw-collapse-show')) {
2223
+ collapse.style.maxHeight = 'none';
2224
+ }
2225
+ collapse.removeEventListener('transitionend', onEnd);
2226
+ };
2227
+ collapse.addEventListener('transitionend', onEnd);
2149
2228
  }
2150
2229
  }
2151
2230
  },
@@ -2162,7 +2241,7 @@ export function makeAccordion(props = {}) {
2162
2241
  },
2163
2242
  o: item.open ? {
2164
2243
  mounted: function(el) {
2165
- el.style.maxHeight = el.scrollHeight + 'px';
2244
+ el.style.maxHeight = 'none';
2166
2245
  }
2167
2246
  } : undefined
2168
2247
  }
@@ -2872,28 +2951,911 @@ export function makeCarousel(props = {}) {
2872
2951
  a: {
2873
2952
  class: ('bw-carousel ' + className).trim(),
2874
2953
  style: 'height: ' + height,
2954
+ tabindex: '0',
2955
+ 'aria-roledescription': 'carousel',
2875
2956
  'data-carousel-index': startIndex
2876
2957
  },
2877
2958
  c: children,
2878
2959
  o: {
2879
2960
  type: 'carousel',
2880
2961
  state: { activeIndex: startIndex, autoPlay: autoPlay, interval: interval },
2881
- mounted: autoPlay ? function(el) {
2882
- var intervalId = setInterval(function() {
2962
+ mounted: function(el) {
2963
+ // Keyboard navigation
2964
+ el.addEventListener('keydown', function(e) {
2883
2965
  var idx = parseInt(el.getAttribute('data-carousel-index') || '0');
2884
- goToSlide(el, idx + 1);
2885
- }, interval);
2886
- el._bw_carouselInterval = intervalId;
2887
- } : undefined,
2888
- unmount: autoPlay ? function(el) {
2966
+ if (e.key === 'ArrowLeft') {
2967
+ e.preventDefault();
2968
+ goToSlide(el, idx - 1);
2969
+ } else if (e.key === 'ArrowRight') {
2970
+ e.preventDefault();
2971
+ goToSlide(el, idx + 1);
2972
+ }
2973
+ });
2974
+ // Auto-play
2975
+ if (autoPlay) {
2976
+ var intervalId = setInterval(function() {
2977
+ var idx = parseInt(el.getAttribute('data-carousel-index') || '0');
2978
+ goToSlide(el, idx + 1);
2979
+ }, interval);
2980
+ el._bw_carouselInterval = intervalId;
2981
+ // Pause on hover/focus for usability
2982
+ el.addEventListener('mouseenter', function() {
2983
+ if (el._bw_carouselInterval) clearInterval(el._bw_carouselInterval);
2984
+ });
2985
+ el.addEventListener('mouseleave', function() {
2986
+ el._bw_carouselInterval = setInterval(function() {
2987
+ var idx = parseInt(el.getAttribute('data-carousel-index') || '0');
2988
+ goToSlide(el, idx + 1);
2989
+ }, interval);
2990
+ });
2991
+ }
2992
+ },
2993
+ unmount: function(el) {
2889
2994
  if (el._bw_carouselInterval) {
2890
2995
  clearInterval(el._bw_carouselInterval);
2891
2996
  }
2892
- } : undefined
2997
+ }
2998
+ }
2999
+ };
3000
+ }
3001
+
3002
+ // =========================================================================
3003
+ // Phase 4: Dashboard & Data Display
3004
+ // =========================================================================
3005
+
3006
+ /**
3007
+ * Create a stat card for dashboard metrics display
3008
+ *
3009
+ * Shows a large value with a label and optional change indicator.
3010
+ * Designed for dashboard grid layouts with left-border accent.
3011
+ *
3012
+ * @param {Object|string} [props] - Stat card configuration (string shorthand sets label)
3013
+ * @param {string|number} [props.value=0] - The main stat value to display
3014
+ * @param {string} [props.label] - Descriptive label below the value
3015
+ * @param {number} [props.change] - Percentage change indicator (positive = green arrow, negative = red)
3016
+ * @param {string} [props.format] - Value format ("number", "currency", "percent")
3017
+ * @param {string} [props.prefix] - Custom prefix (e.g. "$")
3018
+ * @param {string} [props.suffix] - Custom suffix (e.g. "%")
3019
+ * @param {string} [props.icon] - Icon content (emoji or text) shown above value
3020
+ * @param {string} [props.variant] - Left-border color variant ("primary", "success", "danger", etc.)
3021
+ * @param {string} [props.className] - Additional CSS classes
3022
+ * @param {Object} [props.style] - Inline style object
3023
+ * @returns {Object} TACO object representing a stat card
3024
+ * @category Component Builders
3025
+ * @example
3026
+ * const stat = makeStatCard({
3027
+ * value: 2345,
3028
+ * label: 'Active Users',
3029
+ * change: 5.3,
3030
+ * format: 'number',
3031
+ * variant: 'primary'
3032
+ * });
3033
+ */
3034
+ export function makeStatCard(props = {}) {
3035
+ if (typeof props === 'string') props = { label: props };
3036
+ var {
3037
+ value = 0,
3038
+ label,
3039
+ change,
3040
+ format,
3041
+ prefix,
3042
+ suffix,
3043
+ icon,
3044
+ variant,
3045
+ className = '',
3046
+ style
3047
+ } = props;
3048
+
3049
+ function formatValue(val, fmt) {
3050
+ if (prefix || suffix) return (prefix || '') + val + (suffix || '');
3051
+ switch (fmt) {
3052
+ case 'currency': return '$' + Number(val).toLocaleString();
3053
+ case 'percent': return val + '%';
3054
+ case 'number': return Number(val).toLocaleString();
3055
+ default: return '' + val;
3056
+ }
3057
+ }
3058
+
3059
+ var classes = [
3060
+ 'bw-stat-card',
3061
+ variant ? 'bw-stat-card-' + variant : '',
3062
+ className
3063
+ ].filter(Boolean).join(' ').trim();
3064
+
3065
+ var children = [];
3066
+
3067
+ if (icon) {
3068
+ children.push({
3069
+ t: 'div',
3070
+ a: { class: 'bw-stat-icon' },
3071
+ c: icon
3072
+ });
3073
+ }
3074
+
3075
+ children.push({
3076
+ t: 'div',
3077
+ a: { class: 'bw-stat-value' },
3078
+ c: formatValue(value, format)
3079
+ });
3080
+
3081
+ if (label) {
3082
+ children.push({
3083
+ t: 'div',
3084
+ a: { class: 'bw-stat-label' },
3085
+ c: label
3086
+ });
3087
+ }
3088
+
3089
+ if (change !== undefined && change !== null) {
3090
+ children.push({
3091
+ t: 'div',
3092
+ a: {
3093
+ class: 'bw-stat-change ' + (change >= 0 ? 'bw-stat-change-up' : 'bw-stat-change-down')
3094
+ },
3095
+ c: (change >= 0 ? '\u2191 +' : '\u2193 ') + change + '%'
3096
+ });
3097
+ }
3098
+
3099
+ return {
3100
+ t: 'div',
3101
+ a: { class: classes, style: style },
3102
+ c: children,
3103
+ o: { type: 'stat-card' }
3104
+ };
3105
+ }
3106
+
3107
+ // =========================================================================
3108
+ // Phase 5: Overlays & Popovers
3109
+ // =========================================================================
3110
+
3111
+ /**
3112
+ * Create a tooltip wrapper around trigger content
3113
+ *
3114
+ * Wraps the trigger element in a container that shows tooltip text
3115
+ * on hover and focus. Pure CSS-driven show/hide with JS lifecycle
3116
+ * for event binding.
3117
+ *
3118
+ * @param {Object} [props] - Tooltip configuration
3119
+ * @param {string|Object|Array} [props.content] - Trigger content (what the user hovers/focuses)
3120
+ * @param {string} [props.text=""] - Tooltip text to display
3121
+ * @param {string} [props.placement="top"] - Tooltip placement ("top", "bottom", "left", "right")
3122
+ * @param {string} [props.className] - Additional CSS classes
3123
+ * @returns {Object} TACO object representing a tooltip wrapper
3124
+ * @category Component Builders
3125
+ * @example
3126
+ * const tip = makeTooltip({
3127
+ * content: makeButton({ text: 'Hover me' }),
3128
+ * text: 'This is a tooltip!',
3129
+ * placement: 'top'
3130
+ * });
3131
+ */
3132
+ export function makeTooltip(props = {}) {
3133
+ var {
3134
+ content,
3135
+ text = '',
3136
+ placement = 'top',
3137
+ className = ''
3138
+ } = props;
3139
+
3140
+ return {
3141
+ t: 'span',
3142
+ a: { class: ('bw-tooltip-wrapper ' + className).trim() },
3143
+ c: [
3144
+ content,
3145
+ {
3146
+ t: 'span',
3147
+ a: {
3148
+ class: 'bw-tooltip bw-tooltip-' + placement,
3149
+ role: 'tooltip'
3150
+ },
3151
+ c: text
3152
+ }
3153
+ ],
3154
+ o: {
3155
+ type: 'tooltip',
3156
+ mounted: function(el) {
3157
+ var tip = el.querySelector('.bw-tooltip');
3158
+ el.addEventListener('mouseenter', function() {
3159
+ tip.classList.add('bw-tooltip-show');
3160
+ });
3161
+ el.addEventListener('mouseleave', function() {
3162
+ tip.classList.remove('bw-tooltip-show');
3163
+ });
3164
+ el.addEventListener('focusin', function() {
3165
+ tip.classList.add('bw-tooltip-show');
3166
+ });
3167
+ el.addEventListener('focusout', function() {
3168
+ tip.classList.remove('bw-tooltip-show');
3169
+ });
3170
+ }
3171
+ }
3172
+ };
3173
+ }
3174
+
3175
+ /**
3176
+ * Create a popover wrapper around trigger content
3177
+ *
3178
+ * Like a tooltip but richer — supports title + body content and is
3179
+ * triggered by click rather than hover. Dismisses on click outside.
3180
+ *
3181
+ * @param {Object} [props] - Popover configuration
3182
+ * @param {string|Object|Array} [props.trigger] - Trigger content (what the user clicks)
3183
+ * @param {string} [props.title] - Popover header title
3184
+ * @param {string|Object|Array} [props.content] - Popover body content
3185
+ * @param {string} [props.placement="top"] - Placement ("top", "bottom", "left", "right")
3186
+ * @param {string} [props.className] - Additional CSS classes
3187
+ * @returns {Object} TACO object representing a popover wrapper
3188
+ * @category Component Builders
3189
+ * @example
3190
+ * const pop = makePopover({
3191
+ * trigger: makeButton({ text: 'Click me' }),
3192
+ * title: 'Popover Title',
3193
+ * content: 'Some helpful information here.',
3194
+ * placement: 'bottom'
3195
+ * });
3196
+ */
3197
+ export function makePopover(props = {}) {
3198
+ var {
3199
+ trigger,
3200
+ title,
3201
+ content,
3202
+ placement = 'top',
3203
+ className = ''
3204
+ } = props;
3205
+
3206
+ var popoverContent = [
3207
+ title && {
3208
+ t: 'div',
3209
+ a: { class: 'bw-popover-header' },
3210
+ c: title
3211
+ },
3212
+ content && {
3213
+ t: 'div',
3214
+ a: { class: 'bw-popover-body' },
3215
+ c: content
3216
+ }
3217
+ ].filter(Boolean);
3218
+
3219
+ return {
3220
+ t: 'span',
3221
+ a: { class: ('bw-popover-wrapper ' + className).trim() },
3222
+ c: [
3223
+ {
3224
+ t: 'span',
3225
+ a: {
3226
+ class: 'bw-popover-trigger',
3227
+ onclick: function(e) {
3228
+ var wrapper = e.target.closest('.bw-popover-wrapper');
3229
+ var pop = wrapper.querySelector('.bw-popover');
3230
+ pop.classList.toggle('bw-popover-show');
3231
+ }
3232
+ },
3233
+ c: trigger
3234
+ },
3235
+ {
3236
+ t: 'div',
3237
+ a: {
3238
+ class: 'bw-popover bw-popover-' + placement
3239
+ },
3240
+ c: popoverContent
3241
+ }
3242
+ ],
3243
+ o: {
3244
+ type: 'popover',
3245
+ mounted: function(el) {
3246
+ // Click outside to close
3247
+ var outsideHandler = function(e) {
3248
+ if (!el.contains(e.target)) {
3249
+ var pop = el.querySelector('.bw-popover');
3250
+ if (pop) pop.classList.remove('bw-popover-show');
3251
+ }
3252
+ };
3253
+ document.addEventListener('click', outsideHandler);
3254
+ el._bw_outsideHandler = outsideHandler;
3255
+ },
3256
+ unmount: function(el) {
3257
+ if (el._bw_outsideHandler) {
3258
+ document.removeEventListener('click', el._bw_outsideHandler);
3259
+ }
3260
+ }
3261
+ }
3262
+ };
3263
+ }
3264
+
3265
+ // =========================================================================
3266
+ // Phase 6: Form Enhancements & Layout
3267
+ // =========================================================================
3268
+
3269
+ /**
3270
+ * Create a search input with clear button
3271
+ *
3272
+ * Wraps a text input with a clear (×) button that appears when
3273
+ * the field has content. Calls onSearch on Enter key.
3274
+ *
3275
+ * @param {Object} [props] - Search input configuration
3276
+ * @param {string} [props.placeholder="Search..."] - Placeholder text
3277
+ * @param {string} [props.value] - Initial value
3278
+ * @param {Function} [props.onSearch] - Callback when Enter is pressed, receives value
3279
+ * @param {Function} [props.onInput] - Callback on each keystroke, receives value
3280
+ * @param {string} [props.id] - Element ID
3281
+ * @param {string} [props.name] - Input name attribute
3282
+ * @param {string} [props.className] - Additional CSS classes
3283
+ * @returns {Object} TACO object representing a search input
3284
+ * @category Component Builders
3285
+ * @example
3286
+ * const search = makeSearchInput({
3287
+ * placeholder: 'Search users...',
3288
+ * onSearch: (val) => filterUsers(val)
3289
+ * });
3290
+ */
3291
+ export function makeSearchInput(props = {}) {
3292
+ if (typeof props === 'string') props = { placeholder: props };
3293
+ var {
3294
+ placeholder = 'Search...',
3295
+ value,
3296
+ onSearch,
3297
+ onInput,
3298
+ id,
3299
+ name,
3300
+ className = ''
3301
+ } = props;
3302
+
3303
+ return {
3304
+ t: 'div',
3305
+ a: { class: ('bw-search-input ' + className).trim() },
3306
+ c: [
3307
+ {
3308
+ t: 'input',
3309
+ a: {
3310
+ type: 'search',
3311
+ class: 'bw-form-control bw-search-field',
3312
+ placeholder: placeholder,
3313
+ value: value,
3314
+ id: id,
3315
+ name: name,
3316
+ onkeydown: function(e) {
3317
+ if (e.key === 'Enter' && onSearch) {
3318
+ e.preventDefault();
3319
+ onSearch(e.target.value);
3320
+ }
3321
+ },
3322
+ oninput: function(e) {
3323
+ var wrapper = e.target.closest('.bw-search-input');
3324
+ var clearBtn = wrapper.querySelector('.bw-search-clear');
3325
+ if (clearBtn) {
3326
+ clearBtn.style.display = e.target.value ? 'flex' : 'none';
3327
+ }
3328
+ if (onInput) onInput(e.target.value);
3329
+ }
3330
+ }
3331
+ },
3332
+ {
3333
+ t: 'button',
3334
+ a: {
3335
+ type: 'button',
3336
+ class: 'bw-search-clear',
3337
+ 'aria-label': 'Clear search',
3338
+ style: value ? undefined : 'display: none',
3339
+ onclick: function(e) {
3340
+ var wrapper = e.target.closest('.bw-search-input');
3341
+ var input = wrapper.querySelector('.bw-search-field');
3342
+ input.value = '';
3343
+ e.target.style.display = 'none';
3344
+ input.focus();
3345
+ if (onInput) onInput('');
3346
+ if (onSearch) onSearch('');
3347
+ }
3348
+ },
3349
+ c: '\u00D7'
3350
+ }
3351
+ ],
3352
+ o: { type: 'search-input' }
3353
+ };
3354
+ }
3355
+
3356
+ /**
3357
+ * Create a styled range slider input
3358
+ *
3359
+ * @param {Object} [props] - Range configuration
3360
+ * @param {number} [props.min=0] - Minimum value
3361
+ * @param {number} [props.max=100] - Maximum value
3362
+ * @param {number} [props.step=1] - Step increment
3363
+ * @param {number} [props.value=50] - Current value
3364
+ * @param {string} [props.label] - Label text
3365
+ * @param {boolean} [props.showValue=false] - Show current value display
3366
+ * @param {string} [props.id] - Element ID
3367
+ * @param {string} [props.name] - Input name attribute
3368
+ * @param {boolean} [props.disabled=false] - Whether the slider is disabled
3369
+ * @param {string} [props.className] - Additional CSS classes
3370
+ * @returns {Object} TACO object representing a range input
3371
+ * @category Component Builders
3372
+ * @example
3373
+ * const slider = makeRange({
3374
+ * min: 0, max: 100, value: 50,
3375
+ * label: 'Volume',
3376
+ * showValue: true,
3377
+ * oninput: (e) => setVolume(e.target.value)
3378
+ * });
3379
+ */
3380
+ export function makeRange(props = {}) {
3381
+ var {
3382
+ min = 0,
3383
+ max = 100,
3384
+ step = 1,
3385
+ value = 50,
3386
+ label,
3387
+ showValue = false,
3388
+ id,
3389
+ name,
3390
+ disabled = false,
3391
+ className = '',
3392
+ ...eventHandlers
3393
+ } = props;
3394
+
3395
+ var children = [];
3396
+
3397
+ if (label || showValue) {
3398
+ var labelContent = [];
3399
+ if (label) {
3400
+ labelContent.push({
3401
+ t: 'span',
3402
+ c: label
3403
+ });
3404
+ }
3405
+ if (showValue) {
3406
+ labelContent.push({
3407
+ t: 'span',
3408
+ a: { class: 'bw-range-value' },
3409
+ c: '' + value
3410
+ });
3411
+ }
3412
+ children.push({
3413
+ t: 'div',
3414
+ a: { class: 'bw-range-label' },
3415
+ c: labelContent
3416
+ });
3417
+ }
3418
+
3419
+ // Wrap oninput to update value display
3420
+ var userOnInput = eventHandlers.oninput;
3421
+ if (showValue) {
3422
+ eventHandlers.oninput = function(e) {
3423
+ var wrapper = e.target.closest('.bw-range-wrapper');
3424
+ var valDisplay = wrapper.querySelector('.bw-range-value');
3425
+ if (valDisplay) valDisplay.textContent = e.target.value;
3426
+ if (userOnInput) userOnInput(e);
3427
+ };
3428
+ }
3429
+
3430
+ children.push({
3431
+ t: 'input',
3432
+ a: {
3433
+ type: 'range',
3434
+ class: 'bw-range',
3435
+ min: min,
3436
+ max: max,
3437
+ step: step,
3438
+ value: value,
3439
+ id: id,
3440
+ name: name,
3441
+ disabled: disabled,
3442
+ ...eventHandlers
3443
+ }
3444
+ });
3445
+
3446
+ return {
3447
+ t: 'div',
3448
+ a: { class: ('bw-range-wrapper ' + className).trim() },
3449
+ c: children,
3450
+ o: { type: 'range' }
3451
+ };
3452
+ }
3453
+
3454
+ /**
3455
+ * Create a media object layout (image + text side-by-side)
3456
+ *
3457
+ * Classic media object pattern: image/icon on one side, text content
3458
+ * on the other, using flexbox. Supports reversed layout.
3459
+ *
3460
+ * @param {Object} [props] - Media object configuration
3461
+ * @param {string} [props.src] - Image source URL
3462
+ * @param {string} [props.alt=""] - Image alt text
3463
+ * @param {string} [props.title] - Title text
3464
+ * @param {string|Object|Array} [props.content] - Body content
3465
+ * @param {boolean} [props.reverse=false] - Put image on the right
3466
+ * @param {string} [props.imageSize="3rem"] - Image width/height
3467
+ * @param {string} [props.className] - Additional CSS classes
3468
+ * @returns {Object} TACO object representing a media object
3469
+ * @category Component Builders
3470
+ * @example
3471
+ * const media = makeMediaObject({
3472
+ * src: '/avatar.jpg',
3473
+ * title: 'Jane Doe',
3474
+ * content: 'Posted a comment 5 minutes ago.'
3475
+ * });
3476
+ */
3477
+ export function makeMediaObject(props = {}) {
3478
+ var {
3479
+ src,
3480
+ alt = '',
3481
+ title,
3482
+ content,
3483
+ reverse = false,
3484
+ imageSize = '3rem',
3485
+ className = ''
3486
+ } = props;
3487
+
3488
+ var imgEl = src ? {
3489
+ t: 'img',
3490
+ a: {
3491
+ class: 'bw-media-img',
3492
+ src: src,
3493
+ alt: alt,
3494
+ style: 'width:' + imageSize + ';height:' + imageSize
3495
+ }
3496
+ } : null;
3497
+
3498
+ var bodyEl = {
3499
+ t: 'div',
3500
+ a: { class: 'bw-media-body' },
3501
+ c: [
3502
+ title && { t: 'h5', a: { class: 'bw-media-title' }, c: title },
3503
+ content
3504
+ ].filter(Boolean)
3505
+ };
3506
+
3507
+ return {
3508
+ t: 'div',
3509
+ a: { class: ('bw-media ' + (reverse ? 'bw-media-reverse ' : '') + className).trim() },
3510
+ c: reverse
3511
+ ? [bodyEl, imgEl].filter(Boolean)
3512
+ : [imgEl, bodyEl].filter(Boolean),
3513
+ o: { type: 'media-object' }
3514
+ };
3515
+ }
3516
+
3517
+ /**
3518
+ * Create a file upload zone with drag-and-drop support
3519
+ *
3520
+ * Styled drop zone with file input. Supports drag-and-drop visuals
3521
+ * and multiple file selection.
3522
+ *
3523
+ * @param {Object} [props] - File upload configuration
3524
+ * @param {string} [props.accept] - Accepted file types (e.g. "image/*", ".pdf,.doc")
3525
+ * @param {boolean} [props.multiple=false] - Allow multiple file selection
3526
+ * @param {Function} [props.onFiles] - Callback when files are selected, receives FileList
3527
+ * @param {string} [props.text="Drop files here or click to browse"] - Zone label text
3528
+ * @param {string} [props.id] - Element ID
3529
+ * @param {string} [props.className] - Additional CSS classes
3530
+ * @returns {Object} TACO object representing a file upload zone
3531
+ * @category Component Builders
3532
+ * @example
3533
+ * const upload = makeFileUpload({
3534
+ * accept: 'image/*',
3535
+ * multiple: true,
3536
+ * onFiles: (files) => uploadFiles(files)
3537
+ * });
3538
+ */
3539
+ export function makeFileUpload(props = {}) {
3540
+ var {
3541
+ accept,
3542
+ multiple = false,
3543
+ onFiles,
3544
+ text = 'Drop files here or click to browse',
3545
+ id,
3546
+ className = ''
3547
+ } = props;
3548
+
3549
+ return {
3550
+ t: 'div',
3551
+ a: {
3552
+ class: ('bw-file-upload ' + className).trim(),
3553
+ tabindex: '0',
3554
+ role: 'button',
3555
+ 'aria-label': text
3556
+ },
3557
+ c: [
3558
+ { t: 'div', a: { class: 'bw-file-upload-icon' }, c: '\uD83D\uDCC1' },
3559
+ { t: 'div', a: { class: 'bw-file-upload-text' }, c: text },
3560
+ {
3561
+ t: 'input',
3562
+ a: {
3563
+ type: 'file',
3564
+ class: 'bw-file-upload-input',
3565
+ accept: accept,
3566
+ multiple: multiple,
3567
+ id: id,
3568
+ onchange: function(e) {
3569
+ if (onFiles && e.target.files.length) onFiles(e.target.files);
3570
+ }
3571
+ }
3572
+ }
3573
+ ],
3574
+ o: {
3575
+ type: 'file-upload',
3576
+ mounted: function(el) {
3577
+ var input = el.querySelector('.bw-file-upload-input');
3578
+
3579
+ // Click zone to trigger file input
3580
+ el.addEventListener('click', function(e) {
3581
+ if (e.target !== input) input.click();
3582
+ });
3583
+
3584
+ // Keyboard activation
3585
+ el.addEventListener('keydown', function(e) {
3586
+ if (e.key === 'Enter' || e.key === ' ') {
3587
+ e.preventDefault();
3588
+ input.click();
3589
+ }
3590
+ });
3591
+
3592
+ // Drag-and-drop visuals
3593
+ el.addEventListener('dragover', function(e) {
3594
+ e.preventDefault();
3595
+ el.classList.add('bw-file-upload-active');
3596
+ });
3597
+ el.addEventListener('dragleave', function() {
3598
+ el.classList.remove('bw-file-upload-active');
3599
+ });
3600
+ el.addEventListener('drop', function(e) {
3601
+ e.preventDefault();
3602
+ el.classList.remove('bw-file-upload-active');
3603
+ if (onFiles && e.dataTransfer.files.length) onFiles(e.dataTransfer.files);
3604
+ });
3605
+ }
2893
3606
  }
2894
3607
  };
2895
3608
  }
2896
3609
 
3610
+ // =========================================================================
3611
+ // Phase 7: Data Display & Workflow
3612
+ // =========================================================================
3613
+
3614
+ /**
3615
+ * Create a vertical timeline for chronological event display
3616
+ *
3617
+ * Renders events as a vertical line with markers and content cards.
3618
+ * Each item can have a colored variant marker.
3619
+ *
3620
+ * @param {Object} [props] - Timeline configuration
3621
+ * @param {Array<Object>} [props.items=[]] - Timeline events
3622
+ * @param {string} [props.items[].title] - Event title
3623
+ * @param {string|Object|Array} [props.items[].content] - Event description content
3624
+ * @param {string} [props.items[].date] - Date or time label
3625
+ * @param {string} [props.items[].variant="primary"] - Marker color variant
3626
+ * @param {string} [props.className] - Additional CSS classes
3627
+ * @returns {Object} TACO object representing a timeline
3628
+ * @category Component Builders
3629
+ * @example
3630
+ * const timeline = makeTimeline({
3631
+ * items: [
3632
+ * { title: 'Project Started', date: 'Jan 2026', variant: 'primary' },
3633
+ * { title: 'Beta Release', date: 'Mar 2026', content: 'v2.0 beta shipped' },
3634
+ * { title: 'Stable Release', date: 'Jun 2026', variant: 'success' }
3635
+ * ]
3636
+ * });
3637
+ */
3638
+ export function makeTimeline(props = {}) {
3639
+ var {
3640
+ items = [],
3641
+ className = ''
3642
+ } = props;
3643
+
3644
+ return {
3645
+ t: 'div',
3646
+ a: { class: ('bw-timeline ' + className).trim() },
3647
+ c: items.map(function(item) {
3648
+ return {
3649
+ t: 'div',
3650
+ a: { class: 'bw-timeline-item' },
3651
+ c: [
3652
+ {
3653
+ t: 'div',
3654
+ a: { class: 'bw-timeline-marker bw-timeline-marker-' + (item.variant || 'primary') }
3655
+ },
3656
+ {
3657
+ t: 'div',
3658
+ a: { class: 'bw-timeline-content' },
3659
+ c: [
3660
+ item.date && {
3661
+ t: 'div',
3662
+ a: { class: 'bw-timeline-date' },
3663
+ c: item.date
3664
+ },
3665
+ item.title && {
3666
+ t: 'h5',
3667
+ a: { class: 'bw-timeline-title' },
3668
+ c: item.title
3669
+ },
3670
+ item.content && (typeof item.content === 'string'
3671
+ ? { t: 'p', a: { class: 'bw-timeline-text' }, c: item.content }
3672
+ : item.content)
3673
+ ].filter(Boolean)
3674
+ }
3675
+ ]
3676
+ };
3677
+ }),
3678
+ o: { type: 'timeline' }
3679
+ };
3680
+ }
3681
+
3682
+ /**
3683
+ * Create a multi-step wizard/progress indicator
3684
+ *
3685
+ * Displays numbered steps with active and completed states.
3686
+ * Steps before currentStep are marked completed, the currentStep
3687
+ * is active, and subsequent steps are pending.
3688
+ *
3689
+ * @param {Object} [props] - Stepper configuration
3690
+ * @param {Array<Object>} [props.steps=[]] - Step definitions
3691
+ * @param {string} [props.steps[].label] - Step label text
3692
+ * @param {string} [props.steps[].description] - Optional step description
3693
+ * @param {number} [props.currentStep=0] - Zero-based index of the active step
3694
+ * @param {string} [props.className] - Additional CSS classes
3695
+ * @returns {Object} TACO object representing a stepper
3696
+ * @category Component Builders
3697
+ * @example
3698
+ * const stepper = makeStepper({
3699
+ * currentStep: 1,
3700
+ * steps: [
3701
+ * { label: 'Account', description: 'Create account' },
3702
+ * { label: 'Profile', description: 'Set up profile' },
3703
+ * { label: 'Confirm', description: 'Review & submit' }
3704
+ * ]
3705
+ * });
3706
+ */
3707
+ export function makeStepper(props = {}) {
3708
+ var {
3709
+ steps = [],
3710
+ currentStep = 0,
3711
+ className = ''
3712
+ } = props;
3713
+
3714
+ return {
3715
+ t: 'div',
3716
+ a: { class: ('bw-stepper ' + className).trim(), role: 'list' },
3717
+ c: steps.map(function(step, index) {
3718
+ var state = index < currentStep ? 'completed' : index === currentStep ? 'active' : 'pending';
3719
+ return {
3720
+ t: 'div',
3721
+ a: {
3722
+ class: 'bw-step bw-step-' + state,
3723
+ role: 'listitem',
3724
+ 'aria-current': state === 'active' ? 'step' : undefined
3725
+ },
3726
+ c: [
3727
+ {
3728
+ t: 'div',
3729
+ a: { class: 'bw-step-indicator' },
3730
+ c: state === 'completed' ? '\u2713' : '' + (index + 1)
3731
+ },
3732
+ {
3733
+ t: 'div',
3734
+ a: { class: 'bw-step-body' },
3735
+ c: [
3736
+ { t: 'div', a: { class: 'bw-step-label' }, c: step.label },
3737
+ step.description && { t: 'div', a: { class: 'bw-step-description' }, c: step.description }
3738
+ ].filter(Boolean)
3739
+ }
3740
+ ]
3741
+ };
3742
+ }),
3743
+ o: { type: 'stepper' }
3744
+ };
3745
+ }
3746
+
3747
+ /**
3748
+ * Create a chip/tag input for managing a list of items
3749
+ *
3750
+ * Displays existing chips with remove buttons and an input field
3751
+ * for adding new ones. Chips are added on Enter and removed on
3752
+ * clicking the × button.
3753
+ *
3754
+ * @param {Object} [props] - Chip input configuration
3755
+ * @param {Array<string>} [props.chips=[]] - Initial chip values
3756
+ * @param {string} [props.placeholder="Add..."] - Input placeholder text
3757
+ * @param {Function} [props.onAdd] - Callback when a chip is added, receives value
3758
+ * @param {Function} [props.onRemove] - Callback when a chip is removed, receives value
3759
+ * @param {string} [props.className] - Additional CSS classes
3760
+ * @returns {Object} TACO object representing a chip input
3761
+ * @category Component Builders
3762
+ * @example
3763
+ * const tags = makeChipInput({
3764
+ * chips: ['JavaScript', 'CSS'],
3765
+ * placeholder: 'Add tag...',
3766
+ * onAdd: (val) => addTag(val),
3767
+ * onRemove: (val) => removeTag(val)
3768
+ * });
3769
+ */
3770
+ export function makeChipInput(props = {}) {
3771
+ var {
3772
+ chips = [],
3773
+ placeholder = 'Add...',
3774
+ onAdd,
3775
+ onRemove,
3776
+ className = ''
3777
+ } = props;
3778
+
3779
+ function makeChipEl(text) {
3780
+ return {
3781
+ t: 'span',
3782
+ a: { class: 'bw-chip', 'data-chip-value': text },
3783
+ c: [
3784
+ text,
3785
+ {
3786
+ t: 'button',
3787
+ a: {
3788
+ type: 'button',
3789
+ class: 'bw-chip-remove',
3790
+ 'aria-label': 'Remove ' + text,
3791
+ onclick: function(e) {
3792
+ var chip = e.target.closest('.bw-chip');
3793
+ var val = chip.getAttribute('data-chip-value');
3794
+ chip.parentNode.removeChild(chip);
3795
+ if (onRemove) onRemove(val);
3796
+ }
3797
+ },
3798
+ c: '\u00D7'
3799
+ }
3800
+ ]
3801
+ };
3802
+ }
3803
+
3804
+ return {
3805
+ t: 'div',
3806
+ a: { class: ('bw-chip-input ' + className).trim() },
3807
+ c: [
3808
+ ...chips.map(makeChipEl),
3809
+ {
3810
+ t: 'input',
3811
+ a: {
3812
+ type: 'text',
3813
+ class: 'bw-chip-field',
3814
+ placeholder: placeholder,
3815
+ onkeydown: function(e) {
3816
+ if (e.key === 'Enter' && e.target.value.trim()) {
3817
+ e.preventDefault();
3818
+ var val = e.target.value.trim();
3819
+ var wrapper = e.target.closest('.bw-chip-input');
3820
+ // Insert chip before the input
3821
+ var chipEl = document.createElement('span');
3822
+ chipEl.className = 'bw-chip';
3823
+ chipEl.setAttribute('data-chip-value', val);
3824
+ chipEl.innerHTML = '';
3825
+ chipEl.textContent = val;
3826
+ var removeBtn = document.createElement('button');
3827
+ removeBtn.type = 'button';
3828
+ removeBtn.className = 'bw-chip-remove';
3829
+ removeBtn.setAttribute('aria-label', 'Remove ' + val);
3830
+ removeBtn.textContent = '\u00D7';
3831
+ removeBtn.onclick = function() {
3832
+ chipEl.parentNode.removeChild(chipEl);
3833
+ if (onRemove) onRemove(val);
3834
+ };
3835
+ chipEl.appendChild(removeBtn);
3836
+ wrapper.insertBefore(chipEl, e.target);
3837
+ e.target.value = '';
3838
+ if (onAdd) onAdd(val);
3839
+ }
3840
+ // Backspace on empty input removes last chip
3841
+ if (e.key === 'Backspace' && !e.target.value) {
3842
+ var wrapper = e.target.closest('.bw-chip-input');
3843
+ var chipEls = wrapper.querySelectorAll('.bw-chip');
3844
+ if (chipEls.length) {
3845
+ var last = chipEls[chipEls.length - 1];
3846
+ var removedVal = last.getAttribute('data-chip-value');
3847
+ last.parentNode.removeChild(last);
3848
+ if (onRemove) onRemove(removedVal);
3849
+ }
3850
+ }
3851
+ }
3852
+ }
3853
+ }
3854
+ ],
3855
+ o: { type: 'chip-input' }
3856
+ };
3857
+ }
3858
+
2897
3859
  export const componentHandles = {
2898
3860
  card: CardHandle,
2899
3861
  table: TableHandle,