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.
- package/README.md +4 -4
- package/dist/bitwrench-code-edit.cjs.js +1 -1
- package/dist/bitwrench-code-edit.es5.js +1 -1
- package/dist/bitwrench-code-edit.es5.min.js +1 -1
- package/dist/bitwrench-code-edit.esm.js +1 -1
- package/dist/bitwrench-code-edit.esm.min.js +1 -1
- package/dist/bitwrench-code-edit.umd.js +1 -1
- package/dist/bitwrench-code-edit.umd.min.js +1 -1
- package/dist/bitwrench-lean.cjs.js +659 -346
- package/dist/bitwrench-lean.cjs.min.js +5 -5
- package/dist/bitwrench-lean.es5.js +960 -347
- package/dist/bitwrench-lean.es5.min.js +3 -3
- package/dist/bitwrench-lean.esm.js +659 -346
- package/dist/bitwrench-lean.esm.min.js +5 -5
- package/dist/bitwrench-lean.umd.js +659 -346
- package/dist/bitwrench-lean.umd.min.js +5 -5
- package/dist/bitwrench.cjs.js +1737 -452
- package/dist/bitwrench.cjs.min.js +6 -6
- package/dist/bitwrench.css +1625 -168
- package/dist/bitwrench.es5.js +4016 -2341
- package/dist/bitwrench.es5.min.js +4 -4
- package/dist/bitwrench.esm.js +1737 -452
- package/dist/bitwrench.esm.min.js +6 -6
- package/dist/bitwrench.umd.js +1737 -452
- package/dist/bitwrench.umd.min.js +6 -6
- package/dist/builds.json +61 -61
- package/dist/sri.json +26 -26
- package/package.json +1 -1
- package/readme.html +5 -5
- package/src/bitwrench-color-utils.js +137 -17
- package/src/bitwrench-components-v2.js +997 -35
- package/src/bitwrench-styles.js +1098 -370
- package/src/bitwrench.js +128 -75
- package/src/version.js +3 -3
|
@@ -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,
|
|
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
|
-
*
|
|
891
|
+
* validation: "invalid",
|
|
892
|
+
* feedback: "Please enter a valid email address."
|
|
845
893
|
* });
|
|
846
894
|
*/
|
|
847
895
|
export function makeFormGroup(props = {}) {
|
|
848
|
-
|
|
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
|
-
|
|
1827
|
-
|
|
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.
|
|
1830
|
-
|
|
1831
|
-
setTimeout(() => {
|
|
1892
|
+
btn.classList.add('bw-code-copy-btn-copied');
|
|
1893
|
+
setTimeout(function() {
|
|
1832
1894
|
btn.textContent = originalText;
|
|
1833
|
-
btn.
|
|
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
|
-
//
|
|
2127
|
-
var
|
|
2128
|
-
var
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
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 =
|
|
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:
|
|
2882
|
-
|
|
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
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
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
|
-
}
|
|
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,
|