fvn-ui 0.1.0-alpha.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 (44) hide show
  1. package/README.md +57 -0
  2. package/package.json +63 -0
  3. package/src/fvn-ui/LLM.md +312 -0
  4. package/src/fvn-ui/components/avatar.css +53 -0
  5. package/src/fvn-ui/components/avatar.js +69 -0
  6. package/src/fvn-ui/components/button.css +143 -0
  7. package/src/fvn-ui/components/button.js +136 -0
  8. package/src/fvn-ui/components/card.css +6 -0
  9. package/src/fvn-ui/components/card.js +63 -0
  10. package/src/fvn-ui/components/checkbox.css +5 -0
  11. package/src/fvn-ui/components/checkbox.js +82 -0
  12. package/src/fvn-ui/components/collapsible.css +22 -0
  13. package/src/fvn-ui/components/collapsible.js +72 -0
  14. package/src/fvn-ui/components/confirm.js +109 -0
  15. package/src/fvn-ui/components/dashboard.css +25 -0
  16. package/src/fvn-ui/components/dashboard.js +130 -0
  17. package/src/fvn-ui/components/dialog.css +79 -0
  18. package/src/fvn-ui/components/dialog.js +302 -0
  19. package/src/fvn-ui/components/form.css +99 -0
  20. package/src/fvn-ui/components/image.css +21 -0
  21. package/src/fvn-ui/components/image.js +70 -0
  22. package/src/fvn-ui/components/index.js +73 -0
  23. package/src/fvn-ui/components/input.css +30 -0
  24. package/src/fvn-ui/components/input.js +81 -0
  25. package/src/fvn-ui/components/radio.css +3 -0
  26. package/src/fvn-ui/components/radio.js +99 -0
  27. package/src/fvn-ui/components/select.css +160 -0
  28. package/src/fvn-ui/components/select.js +366 -0
  29. package/src/fvn-ui/components/svg.css +5 -0
  30. package/src/fvn-ui/components/svg.js +85 -0
  31. package/src/fvn-ui/components/switch.css +34 -0
  32. package/src/fvn-ui/components/switch.js +85 -0
  33. package/src/fvn-ui/components/tabs.css +168 -0
  34. package/src/fvn-ui/components/tabs.js +181 -0
  35. package/src/fvn-ui/components/text.css +62 -0
  36. package/src/fvn-ui/components/text.js +105 -0
  37. package/src/fvn-ui/components/toggle.css +46 -0
  38. package/src/fvn-ui/components/toggle.js +60 -0
  39. package/src/fvn-ui/dom.js +495 -0
  40. package/src/fvn-ui/helpers.js +29 -0
  41. package/src/fvn-ui/index.js +53 -0
  42. package/src/fvn-ui/style.css +432 -0
  43. package/src/fvn-ui/template.js +135 -0
  44. package/src/fvn-ui/template.md +26 -0
@@ -0,0 +1,5 @@
1
+ .ui-icon {
2
+ width: var(--icon-size, var(--icon-size-default));
3
+ height: var(--icon-size, var(--icon-size-default));
4
+ stroke-width: var(--stroke-width, 1.5);
5
+ }
@@ -0,0 +1,85 @@
1
+ // https://feathericons.com/
2
+ // https://lucide.dev/icons
3
+
4
+ import './svg.css';
5
+
6
+ const shapes = {
7
+ check: '<polyline points="20 6 9 17 4 12"></polyline>',
8
+ chevronDown: '<polyline points="6 9 12 15 18 9"></polyline>',
9
+ chevronUp: '<polyline points="18 15 12 9 6 15"></polyline>',
10
+ chevronLeft: '<polyline points="15 18 9 12 15 6"></polyline>',
11
+ chevronRight: '<polyline points="9 18 15 12 9 6"></polyline>',
12
+ arrowRight: '<line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline>',
13
+ arrowLeft: '<line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline>',
14
+ arrowUp: '<line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline>',
15
+ arrowDown: '<line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline>',
16
+ settings: '<path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/>',
17
+ x: '<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>',
18
+ dots: '<circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle>',
19
+ dotsHorizontal: '<circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle>',
20
+ menu: '<line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line>',
21
+ code: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m10 8 4 4-4 4"/>',
22
+ enter: '<path d="M11 9a1 1 0 0 0 1-1V5.061a1 1 0 0 1 1.811-.75l6.836 6.836a1.207 1.207 0 0 1 0 1.707l-6.836 6.835a1 1 0 0 1-1.811-.75V16a1 1 0 0 0-1-1H9a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1z"/><path d="M4 9v6"/>',
23
+ logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>',
24
+ chat: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>',
25
+ moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>',
26
+ sun: '<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>',
27
+ hexagon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>',
28
+ circle: '<circle cx="12" cy="12" r="10"></circle>',
29
+ plus: '<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>',
30
+ minus: '<line x1="5" y1="12" x2="19" y2="12"></line>',
31
+ search: '<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>',
32
+ edit: '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>',
33
+ trash: '<polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>',
34
+ home: '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>',
35
+ user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>',
36
+ users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
37
+ mail: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline>',
38
+ calendar: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>',
39
+ clock: '<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>',
40
+ heart: '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>',
41
+ star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>',
42
+ bell: '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path>',
43
+ info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>',
44
+ alertCircle: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line>',
45
+ alertTriangle: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
46
+ checkCircle: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline>',
47
+ xCircle: '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
48
+ eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle>',
49
+ eyeOff: '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>',
50
+ copy: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>',
51
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line>',
52
+ upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line>',
53
+ link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>',
54
+ externalLink: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line>',
55
+ filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>',
56
+ sliders: '<line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line>',
57
+ refresh: '<polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>',
58
+ loader: '<line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>',
59
+ lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
60
+ doc: '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
61
+ unlock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 9.9-1"></path>',
62
+ save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline>',
63
+ file: '<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline>',
64
+ folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>',
65
+ image: '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline>',
66
+ grid: '<rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect>',
67
+ list: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>',
68
+ maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>',
69
+ minimize: '<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"></path>',
70
+ zap: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>',
71
+ play: '<polygon points="5 3 19 12 5 21 5 3"></polygon>',
72
+ pause: '<rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect>',
73
+ rabbit: '<path d="M13 16a3 3 0 0 1 2.24 5"/><path d="M18 12h.01"/><path d="M18 21h-8a4 4 0 0 1-4-4 7 7 0 0 1 7-7h.2L9.6 6.4a1 1 0 1 1 2.8-2.8L15.8 7h.2c3.3 0 6 2.7 6 6v1a2 2 0 0 1-2 2h-1a3 3 0 0 0-3 3"/><path d="M20 8.54V4a2 2 0 1 0-4 0v3"/><path d="M7.612 12.524a3 3 0 1 0-1.6 4.3"/>',
74
+ bird: '<path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3"/><path d="M14 17.75V21"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/>'
75
+ };
76
+
77
+ const wrap = (shape, n) => !shape
78
+ ? ''
79
+ : `
80
+ <svg class="ui-icon ui-icon-${n}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
81
+ ${shape}
82
+ </svg>
83
+ `;
84
+
85
+ export const svg = n => wrap(shapes[n], n);
@@ -0,0 +1,34 @@
1
+ .ui-switch {
2
+ --bg: var(--text);
3
+ display: inline-flex;
4
+ align-items: center;
5
+ gap: var(--space-2);
6
+ }
7
+ .ui-switch__button {
8
+ --switch-size: calc(1.75 * var(--space));
9
+ width: calc(2 * var(--switch-size));
10
+ height: var(--switch-size);
11
+ border-radius: 999px;
12
+ border: none;
13
+ background: hsl(var(--hsl-muted) / .25);
14
+ position: relative;
15
+ cursor: pointer;
16
+ padding: 0;
17
+ transition: var(--core-transition);
18
+ }
19
+ .ui-switch__thumb {
20
+ width: calc(.8 * var(--switch-size));
21
+ height: calc(.8 * var(--switch-size));
22
+ border-radius: 999px;
23
+ background: hsl(var(--hsl-white));
24
+ position: absolute;
25
+ top: 50%;
26
+ left: calc(.1 * var(--switch-size));
27
+ transform: translateY(-50%);
28
+ transition: left var(--core-transition);
29
+ }
30
+ .ui-switch[data-checked="true"] .ui-switch__button {
31
+ background: var(--bg);
32
+ border-color: var(--bg);
33
+ }
34
+ .ui-switch[data-checked="true"] .ui-switch__thumb { left: calc(1.1 * var(--switch-size)); }
@@ -0,0 +1,85 @@
1
+ import { el, getCallback, withValue, parseArgs, configToClasses, bemFactory } from '../dom.js'
2
+ import { label as textLabel } from './text.js'
3
+ import './switch.css'
4
+
5
+ const bem = bemFactory('switch');
6
+
7
+ /**
8
+ * Creates a toggle switch
9
+ * @param {Object} config
10
+ * @param {string} [config.label] - Switch label (clickable)
11
+ * @param {boolean} [config.checked] - Initial checked state
12
+ * @param {boolean} [config.disabled] - Disabled state
13
+ * @param {'default'|'primary'|'red'|'green'|'blue'} [config.color='default'] - Switch color when checked
14
+ * @param {Function} [config.onChange] - Called with (checked, event)
15
+ * @param {string} [config.id] - Registers to dom.switch[id] and dom[id]
16
+ * @returns {HTMLDivElement} Switch element with .value getter/setter
17
+ * @example
18
+ * switchComponent({ label: 'Dark mode', checked: true })
19
+ * switchComponent({ label: 'Notifications', color: 'primary', onChange: (v) => save(v) })
20
+ */
21
+ export function switchComponent(...args) {
22
+ const {
23
+ parent,
24
+ checked,
25
+ disabled,
26
+ color = 'default',
27
+ label,
28
+ id,
29
+ props,
30
+ ...rest
31
+ } = parseArgs(...args);
32
+
33
+ const cb = getCallback('onChange', rest);
34
+ let btnEl;
35
+ let state = !!checked;
36
+
37
+ const setState = (next, e) => {
38
+ state = !!next;
39
+ root.dataset.checked = state;
40
+ btnEl.setAttribute('aria-checked', state);
41
+ e && cb?.(state, e);
42
+ };
43
+
44
+ const toggle = (e) => {
45
+ if (disabled) {
46
+ return;
47
+ }
48
+ setState(!state, e);
49
+ };
50
+
51
+ const onKeydown = (e) => {
52
+ if (e.key !== ' ' && e.key !== 'Enter') {
53
+ return;
54
+ }
55
+ e.preventDefault();
56
+ toggle(e);
57
+ };
58
+
59
+ const root = el('div', parent, {
60
+ ...rest,
61
+ class: [bem(), configToClasses(props), rest.class],
62
+ data: { checked: state, uiCol: color },
63
+ children: [
64
+ el('button', {
65
+ type: 'button',
66
+ class: bem.el('button'),
67
+ attrs: { role: 'switch', 'aria-checked': state },
68
+ id,
69
+ disabled,
70
+ ref: (e) => btnEl = e,
71
+ children: [el('span', { class: bem.el('thumb') })],
72
+ onClick: toggle,
73
+ onKeydown
74
+ }),
75
+ label && textLabel({
76
+ text: label,
77
+ small: true,
78
+ onClick: toggle,
79
+ style: { cursor: disabled ? 'default' : 'pointer' }
80
+ })
81
+ ]
82
+ });
83
+
84
+ return withValue(root, () => state, setState);
85
+ }
@@ -0,0 +1,168 @@
1
+ .ui-tabs .ui-btn[aria-selected="true"] {
2
+ pointer-events: none;
3
+ }
4
+
5
+ .ui-tabs__buttons {
6
+ display: inline-flex;
7
+ gap: var(--space-1);
8
+ flex-wrap: wrap;
9
+ background: var(--shade);
10
+ padding: var(--space-1);
11
+ border-radius: calc(1.5 * var(--radius));
12
+ }
13
+ .ui-tabs__panel {
14
+ width: 100%;
15
+ border-radius: var(--radius);
16
+ }
17
+ .ui-tabs__tab {
18
+ background: transparent;
19
+ cursor: pointer;
20
+ user-select: none;
21
+ transition: var(--core-transition);
22
+ }
23
+
24
+ /* --- */
25
+
26
+ .ui-tabs--shade .ui-tabs__panel {
27
+ background: var(--shade);
28
+ }
29
+ .ui-tabs--shade .ui-btn[aria-selected="false"] {
30
+ --bg: var(--shade);
31
+ }
32
+
33
+ /* --- */
34
+
35
+ .ui-tabs--round .ui-tabs--default {
36
+ border-radius: 999px;
37
+ }
38
+ .ui-tabs--round .ui-tabs--default .ui-btn {
39
+ --input-padding: calc(var(--space) * .5) calc(var(--space) * 1.5);
40
+ --bg: transparent;
41
+ }
42
+
43
+ /* --- */
44
+
45
+ .ui-tabs--default .ui-btn {
46
+ --input-padding: calc(var(--space) * .5) calc(var(--space) * .75);
47
+ }
48
+ .ui-tabs--default .ui-btn[aria-selected="true"] {
49
+ background: hsl(var(--hsl-white)) !important;
50
+ color: hsl(var(--hsl-black)) !important;
51
+ box-shadow: 0 1px 4px rgba(0,0,0,.2);
52
+ }
53
+
54
+ /* --- */
55
+
56
+ .ui-tabs--outline {
57
+ padding: 0;
58
+ background: none;
59
+ gap: 0;
60
+ }
61
+ .ui-tabs--outline:not(.justify-center) + .ui-tabs__panel {
62
+ border-top-left-radius: 0;
63
+ }
64
+ .ui-tabs--outline .ui-btn {
65
+ --core-transition: none;
66
+ border-color: transparent;
67
+ border-radius: 0;
68
+ }
69
+ .ui-tabs--outline .ui-btn:first-child {
70
+ border-top-left-radius: var(--radius);
71
+ }
72
+ .ui-tabs--outline .ui-btn:last-child {
73
+ border-top-right-radius: var(--radius);
74
+ }
75
+ .ui-tabs--outline .ui-btn[aria-selected="false"] {
76
+ --fg: var(--muted);
77
+ --bg: var(--shade);
78
+ }
79
+ .ui-tabs--outline .ui-btn[aria-selected="true"] {
80
+ --fg: var(--text);
81
+ --bg: var(--shade-back, var(--back));
82
+ border-color: var(--border);
83
+ }
84
+ .ui-tabs--outline .ui-btn[aria-selected="true"]:after {
85
+ content: '';
86
+ background: var(--bg);
87
+ bottom: 0;
88
+ left: 0;
89
+ width: 100%;
90
+ height: 3px;
91
+ position: absolute;
92
+ transform: translatey(100%);
93
+ }
94
+
95
+ /* --- */
96
+
97
+ .ui-tabs--ghost {
98
+ gap: 0;
99
+ background: transparent;
100
+ padding: 0;
101
+ }
102
+ .ui-tabs--ghost .ui-btn[aria-selected="true"]:not([data-ui-col]) {
103
+ --bg: var(--hover);
104
+ }
105
+
106
+ /* --- */
107
+
108
+ .ui-tabs--border {
109
+ padding: 0;
110
+ background: none;
111
+ gap: 0;
112
+ }
113
+ .ui-tabs--border:not(.justify-center) + .ui-tabs__panel {
114
+ border-top-left-radius: 0;
115
+ }
116
+ .ui-tabs--border .ui-btn {
117
+ border-color: transparent;
118
+ border-width: 2px;
119
+ border-radius: 0;
120
+ }
121
+
122
+ .ui-tabs--border .ui-btn:after {
123
+ content: '';
124
+ background: var(--bg);
125
+ bottom: 0;
126
+ left: 0;
127
+ width: 100%;
128
+ height: 2px;
129
+ position: absolute;
130
+ transform: translatey(100%);
131
+ pointer-events: none;
132
+ opacity: 0;
133
+ transition: var(--core-transition);
134
+ }
135
+ .ui-tabs--border .ui-btn:hover {
136
+ background: transparent;
137
+ }
138
+ .ui-tabs--border .ui-btn:hover:after {
139
+ background: var(--fg);
140
+ opacity: .5;
141
+ }
142
+ .ui-tabs--border .ui-btn[aria-selected="true"]:after {
143
+ background: var(--fg);
144
+ opacity: 1;
145
+ }
146
+ .ui-tabs--border .ui-btn[aria-selected="false"] {
147
+ --fg: var(--muted);
148
+ }
149
+
150
+ /* --- */
151
+
152
+ .ui-tabs--minimal {
153
+ gap: var(--space-4);
154
+ padding: var(--space-2) 0;
155
+ background: transparent;
156
+ }
157
+ .ui-tabs--minimal .ui-btn {
158
+ --fg: var(--muted);
159
+ background: transparent;
160
+ padding: 0;
161
+ --space: 0px;
162
+ }
163
+ .ui-tabs--minimal .ui-btn[aria-selected="true"] {
164
+ --fg: var(--text);
165
+ &:before {
166
+ opacity: 1;
167
+ }
168
+ }
@@ -0,0 +1,181 @@
1
+ import { el, getCallback, withValue, parseArgs, configToClasses, bemFactory } from '../dom.js'
2
+ import { button } from './button.js'
3
+ import './tabs.css'
4
+
5
+ const bem = bemFactory('tabs');
6
+
7
+ const getItemValue = (item) => {
8
+ if (!item) {
9
+ return '';
10
+ }
11
+ return item.value !== undefined ? String(item.value) : String(item.label);
12
+ };
13
+
14
+ /**
15
+ * Creates a tabbed interface
16
+ * @param {Object} config
17
+ * @param {{label: string, value?: string, icon?: string, render?: Function}[]} config.items - Tab items
18
+ * @param {number} [config.active=0] - Initially active tab index
19
+ * @param {string} [config.value] - Initially active tab value
20
+ * @param {'default'|'outline'|'border'|'minimal'|'ghost'} [config.variant] - Tab style
21
+ * @param {'primary'|'red'|'green'|'blue'} [config.color] - Tab color
22
+ * @param {'round'} [config.shape] - Tab button shape
23
+ * @param {boolean} [config.center] - Center tabs
24
+ * @param {boolean} [config.shade] - Shaded background
25
+ * @param {Function} [config.onChange] - Called with (value, item)
26
+ * @returns {HTMLDivElement} Tabs element with .value getter/setter
27
+ * @example
28
+ * tabs({ variant: 'outline', items: [{ label: 'Tab 1', render: () => content1 }] })
29
+ * @see buttonGroup - Alias for tabs without content panel
30
+ */
31
+ export function tabs(...args) {
32
+ const {
33
+ parent,
34
+ items: passedItems = [],
35
+ active = 0,
36
+ value,
37
+ variant,
38
+ color,
39
+ shape,
40
+ appendButtons,
41
+ appendContent,
42
+ border,
43
+ shade,
44
+ padding,
45
+ width,
46
+ asButtonGroup,
47
+ props,
48
+ ...rest
49
+ } = parseArgs(...args);
50
+
51
+ const cb = getCallback('onChange', rest);
52
+ const items = passedItems.flat();
53
+ const tabBtns = [];
54
+
55
+ const isCentered = rest.align === 'center' || rest.center;
56
+ const isColorizable = ['border', 'ghost'].includes(variant);
57
+ const buttonVariant = variant === 'border' ? 'outline' : (variant || 'none');
58
+ const withBorder = border !== false && !shade;
59
+
60
+ let output;
61
+ let current = value || getItemValue(items[active]) || '';
62
+
63
+ const renderPanel = (v) => {
64
+ output.innerHTML = '';
65
+ const item = items.find((o) => getItemValue(o) === String(v));
66
+ if (!item?.render) {
67
+ return;
68
+ }
69
+
70
+ const out = item.render();
71
+ const tab = el('div', { class: bem.el('tab') });
72
+
73
+ if (typeof out === 'string') {
74
+ tab.innerHTML = out;
75
+ } else if (Array.isArray(out)) {
76
+ for (const o of out) tab.appendChild(o);
77
+ } else {
78
+ tab.appendChild(out);
79
+ }
80
+
81
+ output.appendChild(tab);
82
+ };
83
+
84
+ const setActive = (v, skipCallback) => {
85
+ current = String(v);
86
+ for (const btn of tabBtns) {
87
+ const isActive = btn.dataset.value === current;
88
+ btn.setAttribute('aria-selected', isActive);
89
+ const item = items.find((o) => getItemValue(o) === btn.dataset.value) || {};
90
+ const col = item.color || color;
91
+ btn.dataset.uiCol = isActive && col && isColorizable
92
+ ? `${variant !== 'ghost' ? 'sub-' : ''}${col}`
93
+ : '';
94
+ btn.tabIndex = isActive ? 0 : -1;
95
+ }
96
+ !asButtonGroup && renderPanel(current);
97
+ !skipCallback && cb?.(current);
98
+ };
99
+
100
+ const focusTabAt = (i) => {
101
+ if (!tabBtns.length) {
102
+ return;
103
+ }
104
+ tabBtns[(i + tabBtns.length) % tabBtns.length].focus();
105
+ };
106
+
107
+ const handleKeydown = (e, idx, val) => {
108
+ const keys = {
109
+ ArrowRight: () => focusTabAt(idx + 1),
110
+ ArrowLeft: () => focusTabAt(idx - 1),
111
+ Home: () => focusTabAt(0),
112
+ End: () => focusTabAt(tabBtns.length - 1),
113
+ Enter: () => setActive(val),
114
+ ' ': () => setActive(val)
115
+ };
116
+
117
+ const action = keys[e.key];
118
+ if (!action) {
119
+ return;
120
+ }
121
+ e.preventDefault();
122
+ action();
123
+ };
124
+
125
+ const root = el('div', parent, {
126
+ ...rest,
127
+ class: [
128
+ bem(),
129
+ shape && bem(shape),
130
+ `w-${width || 'full'}`,
131
+ 'flex',
132
+ 'flex-col',
133
+ !withBorder && 'gap-2',
134
+ shade && bem('shade'),
135
+ configToClasses(props, ['shade']),
136
+ rest.class
137
+ ],
138
+ children: [
139
+ el('div', {
140
+ class: [
141
+ bem.el('buttons'),
142
+ bem(variant || 'default'),
143
+ isCentered && ['justify-center', 'ma'],
144
+ ],
145
+ attrs: { role: 'tablist' },
146
+ children: items.map((o, i) => {
147
+ const val = getItemValue(o);
148
+ const btn = button({
149
+ variant: buttonVariant,
150
+ label: o.label,
151
+ icon: o.icon,
152
+ shape,
153
+ dataset: { value: val },
154
+ attrs: { role: 'tab' },
155
+ aria: { selected: 'false' },
156
+ onmousedown: () => setActive(val),
157
+ onkeydown: (e) => handleKeydown(e, i, val)
158
+ });
159
+ tabBtns.push(btn);
160
+ return btn;
161
+ })
162
+ }),
163
+ !asButtonGroup && el('div', {
164
+ class: [
165
+ bem.el('panel'),
166
+ withBorder && 'border',
167
+ Number.isInteger(padding) ? `pad-${padding}` : (withBorder || shade) && 'pad'
168
+ ],
169
+ ref: (e) => output = e
170
+ })
171
+ ]
172
+ });
173
+
174
+ appendButtons?.append(root.children[0]);
175
+ appendContent?.append(root.children[1]);
176
+
177
+ setActive(current, true);
178
+ withValue(root, () => current, setActive);
179
+
180
+ return root;
181
+ }
@@ -0,0 +1,62 @@
1
+ /* ====================================================
2
+ Text Primitives
3
+ Shared typography building blocks
4
+ ==================================================== */
5
+
6
+ /* Title - primary headings */
7
+ .ui-title {
8
+ line-height: 1.2;
9
+ font-weight: 650;
10
+ font-size: 1.1em;
11
+ margin: 0;
12
+ }
13
+
14
+ .ui-title--large {
15
+ font-weight: 700;
16
+ font-size: 1.5em;
17
+ }
18
+
19
+ /* Description - secondary text, usually paired with title */
20
+ .ui-description {
21
+ line-height: 1.4;
22
+ margin: 0;
23
+ }
24
+
25
+ /* Label - form labels and small headings */
26
+ .ui-label {
27
+ display: block;
28
+ font-size: 0.875em;
29
+ font-weight: 500;
30
+ color: var(--text);
31
+ }
32
+
33
+ .ui-label--soft {
34
+ font-weight: 400;
35
+ opacity: 0.7;
36
+ }
37
+
38
+ /* Truncate - single line text with ellipsis */
39
+ .ui-truncate {
40
+ white-space: nowrap;
41
+ overflow: hidden;
42
+ text-overflow: ellipsis;
43
+ min-width: 0;
44
+ }
45
+
46
+ /* Name - bold truncated text (for avatars, lists) */
47
+ .ui-name {
48
+ font-weight: 500;
49
+ font-size: .95em;
50
+ white-space: nowrap;
51
+ overflow: hidden;
52
+ text-overflow: ellipsis;
53
+ }
54
+
55
+ /* Subtitle - muted truncated text */
56
+ .ui-subtitle {
57
+ font-size: .85em;
58
+ color: var(--muted);
59
+ white-space: nowrap;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ }