@pokle/basecoat 0.3.10-beta2.pokle

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 (145) hide show
  1. package/.eleventy.js +47 -0
  2. package/.gitattributes +1 -0
  3. package/.gitignore +12 -0
  4. package/AGENTS.md +112 -0
  5. package/CONTRIBUTING.md +15 -0
  6. package/LICENSE.md +21 -0
  7. package/README.md +29 -0
  8. package/ROADMAP.md +20 -0
  9. package/docs/css/custom.css +92 -0
  10. package/docs/css/highlight.css +151 -0
  11. package/docs/css/styles.css +8 -0
  12. package/docs/css/themes/claude.css +95 -0
  13. package/docs/css/themes/doom-64.css +95 -0
  14. package/docs/css/themes/supabase.css +100 -0
  15. package/docs/src/_data/site.js +10 -0
  16. package/docs/src/_includes/layouts/base.njk +112 -0
  17. package/docs/src/_includes/layouts/layout.njk +15 -0
  18. package/docs/src/_includes/layouts/page.njk +21 -0
  19. package/docs/src/_includes/macros/code_block.njk +23 -0
  20. package/docs/src/_includes/macros/code_preview.njk +26 -0
  21. package/docs/src/_includes/macros/toc.njk +20 -0
  22. package/docs/src/_includes/partials/header.njk +64 -0
  23. package/docs/src/_includes/partials/kitchen-sink/accordion.njk +89 -0
  24. package/docs/src/_includes/partials/kitchen-sink/alert-dialog.njk +26 -0
  25. package/docs/src/_includes/partials/kitchen-sink/alert.njk +72 -0
  26. package/docs/src/_includes/partials/kitchen-sink/avatar.njk +37 -0
  27. package/docs/src/_includes/partials/kitchen-sink/badge.njk +47 -0
  28. package/docs/src/_includes/partials/kitchen-sink/breadcrumb.njk +42 -0
  29. package/docs/src/_includes/partials/kitchen-sink/button.njk +101 -0
  30. package/docs/src/_includes/partials/kitchen-sink/card.njk +147 -0
  31. package/docs/src/_includes/partials/kitchen-sink/checkbox.njk +34 -0
  32. package/docs/src/_includes/partials/kitchen-sink/combobox.njk +83 -0
  33. package/docs/src/_includes/partials/kitchen-sink/dialog.njk +65 -0
  34. package/docs/src/_includes/partials/kitchen-sink/dropdown-menu.njk +294 -0
  35. package/docs/src/_includes/partials/kitchen-sink/form.njk +106 -0
  36. package/docs/src/_includes/partials/kitchen-sink/input.njk +27 -0
  37. package/docs/src/_includes/partials/kitchen-sink/label.njk +30 -0
  38. package/docs/src/_includes/partials/kitchen-sink/pagination.njk +34 -0
  39. package/docs/src/_includes/partials/kitchen-sink/popover.njk +43 -0
  40. package/docs/src/_includes/partials/kitchen-sink/radio-group.njk +33 -0
  41. package/docs/src/_includes/partials/kitchen-sink/select.njk +99 -0
  42. package/docs/src/_includes/partials/kitchen-sink/skeleton.njk +40 -0
  43. package/docs/src/_includes/partials/kitchen-sink/slider.njk +38 -0
  44. package/docs/src/_includes/partials/kitchen-sink/switch.njk +27 -0
  45. package/docs/src/_includes/partials/kitchen-sink/table.njk +74 -0
  46. package/docs/src/_includes/partials/kitchen-sink/tabs.njk +105 -0
  47. package/docs/src/_includes/partials/kitchen-sink/textarea.njk +27 -0
  48. package/docs/src/_includes/partials/kitchen-sink/toast.njk +25 -0
  49. package/docs/src/_includes/partials/kitchen-sink/tooltip.njk +24 -0
  50. package/docs/src/_includes/partials/sidebar.njk +139 -0
  51. package/docs/src/assets/apple-touch-icon.png +0 -0
  52. package/docs/src/assets/favicon.svg +12 -0
  53. package/docs/src/assets/images/avatar-1.png +0 -0
  54. package/docs/src/assets/images/avatar-2.png +0 -0
  55. package/docs/src/assets/images/avatar-3.png +0 -0
  56. package/docs/src/assets/images/screenshot.png +0 -0
  57. package/docs/src/assets/social-screenshot.png +0 -0
  58. package/docs/src/assets/social.png +0 -0
  59. package/docs/src/assets/styles.css +6309 -0
  60. package/docs/src/components/accordion.njk +75 -0
  61. package/docs/src/components/alert-dialog.njk +119 -0
  62. package/docs/src/components/alert.njk +108 -0
  63. package/docs/src/components/avatar.njk +40 -0
  64. package/docs/src/components/badge.njk +93 -0
  65. package/docs/src/components/breadcrumb.njk +71 -0
  66. package/docs/src/components/button-group.njk +290 -0
  67. package/docs/src/components/button.njk +141 -0
  68. package/docs/src/components/card.njk +156 -0
  69. package/docs/src/components/carousel.njk +102 -0
  70. package/docs/src/components/chart.njk +814 -0
  71. package/docs/src/components/checkbox.njk +101 -0
  72. package/docs/src/components/combobox.njk +293 -0
  73. package/docs/src/components/command.njk +288 -0
  74. package/docs/src/components/dialog.njk +177 -0
  75. package/docs/src/components/dropdown-menu.njk +403 -0
  76. package/docs/src/components/empty.njk +157 -0
  77. package/docs/src/components/field.njk +459 -0
  78. package/docs/src/components/form.njk +79 -0
  79. package/docs/src/components/input-group.njk +372 -0
  80. package/docs/src/components/input.njk +90 -0
  81. package/docs/src/components/item.njk +320 -0
  82. package/docs/src/components/kbd.njk +76 -0
  83. package/docs/src/components/label.njk +41 -0
  84. package/docs/src/components/pagination.njk +48 -0
  85. package/docs/src/components/popover.njk +174 -0
  86. package/docs/src/components/progress.njk +44 -0
  87. package/docs/src/components/radio-group.njk +48 -0
  88. package/docs/src/components/select.njk +457 -0
  89. package/docs/src/components/sidebar.njk +219 -0
  90. package/docs/src/components/skeleton.njk +51 -0
  91. package/docs/src/components/slider.njk +47 -0
  92. package/docs/src/components/spinner.njk +214 -0
  93. package/docs/src/components/switch.njk +54 -0
  94. package/docs/src/components/table.njk +87 -0
  95. package/docs/src/components/tabs.njk +232 -0
  96. package/docs/src/components/textarea.njk +90 -0
  97. package/docs/src/components/theme-switcher.njk +111 -0
  98. package/docs/src/components/toast.njk +279 -0
  99. package/docs/src/components/tooltip.njk +53 -0
  100. package/docs/src/fragments/toast/error.njk +6 -0
  101. package/docs/src/fragments/toast/info.njk +5 -0
  102. package/docs/src/fragments/toast/success.njk +7 -0
  103. package/docs/src/fragments/toast/warning.njk +5 -0
  104. package/docs/src/index.njk +336 -0
  105. package/docs/src/installation.njk +275 -0
  106. package/docs/src/introduction.njk +63 -0
  107. package/docs/src/kitchen-sink.njk +52 -0
  108. package/docs/src/llms.txt +506 -0
  109. package/docs/src/robots.njk +7 -0
  110. package/docs/src/sitemap.njk +15 -0
  111. package/docs/src/test.njk +39 -0
  112. package/package.json +51 -0
  113. package/packages/cli/README.md +55 -0
  114. package/packages/cli/index.js +193 -0
  115. package/packages/cli/package.json +44 -0
  116. package/packages/css/README.md +63 -0
  117. package/packages/css/package.json +63 -0
  118. package/scripts/build.js +170 -0
  119. package/src/css/basecoat.cdn.css +2 -0
  120. package/src/css/basecoat.css +1310 -0
  121. package/src/jinja/command.html.jinja +206 -0
  122. package/src/jinja/dialog.html.jinja +94 -0
  123. package/src/jinja/dropdown-menu.html.jinja +124 -0
  124. package/src/jinja/popover.html.jinja +48 -0
  125. package/src/jinja/select.html.jinja +196 -0
  126. package/src/jinja/sidebar.html.jinja +144 -0
  127. package/src/jinja/tabs.html.jinja +78 -0
  128. package/src/jinja/toast.html.jinja +117 -0
  129. package/src/js/basecoat.js +99 -0
  130. package/src/js/command.js +175 -0
  131. package/src/js/dropdown-menu.js +171 -0
  132. package/src/js/popover.js +73 -0
  133. package/src/js/select.js +432 -0
  134. package/src/js/sidebar.js +104 -0
  135. package/src/js/tabs.js +63 -0
  136. package/src/js/toast.js +181 -0
  137. package/src/nunjucks/command.njk +206 -0
  138. package/src/nunjucks/dialog.njk +92 -0
  139. package/src/nunjucks/dropdown-menu.njk +124 -0
  140. package/src/nunjucks/popover.njk +48 -0
  141. package/src/nunjucks/select.njk +196 -0
  142. package/src/nunjucks/sidebar.njk +144 -0
  143. package/src/nunjucks/tabs.njk +78 -0
  144. package/src/nunjucks/toast.njk +117 -0
  145. package/wrangler.jsonc +7 -0
@@ -0,0 +1,104 @@
1
+ (() => {
2
+ // Monkey patching the history API to detect client-side navigation
3
+ if (!window.history.__basecoatPatched) {
4
+ const originalPushState = window.history.pushState;
5
+ window.history.pushState = function(...args) {
6
+ originalPushState.apply(this, args);
7
+ window.dispatchEvent(new Event('basecoat:locationchange'));
8
+ };
9
+
10
+ const originalReplaceState = window.history.replaceState;
11
+ window.history.replaceState = function(...args) {
12
+ originalReplaceState.apply(this, args);
13
+ window.dispatchEvent(new Event('basecoat:locationchange'));
14
+ };
15
+
16
+ window.history.__basecoatPatched = true;
17
+ }
18
+
19
+ const initSidebar = (sidebarComponent) => {
20
+ const initialOpen = sidebarComponent.dataset.initialOpen !== 'false';
21
+ const initialMobileOpen = sidebarComponent.dataset.initialMobileOpen === 'true';
22
+ const breakpoint = parseInt(sidebarComponent.dataset.breakpoint) || 768;
23
+
24
+ let open = breakpoint > 0
25
+ ? (window.innerWidth >= breakpoint ? initialOpen : initialMobileOpen)
26
+ : initialOpen;
27
+
28
+ const updateCurrentPageLinks = () => {
29
+ const currentPath = window.location.pathname.replace(/\/$/, '');
30
+ sidebarComponent.querySelectorAll('a').forEach(link => {
31
+ if (link.hasAttribute('data-ignore-current')) return;
32
+
33
+ const linkPath = new URL(link.href).pathname.replace(/\/$/, '');
34
+ if (linkPath === currentPath) {
35
+ link.setAttribute('aria-current', 'page');
36
+ } else {
37
+ link.removeAttribute('aria-current');
38
+ }
39
+ });
40
+ };
41
+
42
+ const updateState = () => {
43
+ sidebarComponent.setAttribute('aria-hidden', !open);
44
+ if (open) {
45
+ sidebarComponent.removeAttribute('inert');
46
+ } else {
47
+ sidebarComponent.setAttribute('inert', '');
48
+ }
49
+ };
50
+
51
+ const setState = (state) => {
52
+ open = state;
53
+ updateState();
54
+ };
55
+
56
+ const sidebarId = sidebarComponent.id;
57
+
58
+ document.addEventListener('basecoat:sidebar', (event) => {
59
+ if (event.detail?.id && event.detail.id !== sidebarId) return;
60
+
61
+ switch (event.detail?.action) {
62
+ case 'open':
63
+ setState(true);
64
+ break;
65
+ case 'close':
66
+ setState(false);
67
+ break;
68
+ default:
69
+ setState(!open);
70
+ break;
71
+ }
72
+ });
73
+
74
+ sidebarComponent.addEventListener('click', (event) => {
75
+ const target = event.target;
76
+ const nav = sidebarComponent.querySelector('nav');
77
+
78
+ const isMobile = window.innerWidth < breakpoint;
79
+
80
+ if (isMobile && (target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]'))) {
81
+ if (document.activeElement) document.activeElement.blur();
82
+ setState(false);
83
+ return;
84
+ }
85
+
86
+ if (target === sidebarComponent || (nav && !nav.contains(target))) {
87
+ if (document.activeElement) document.activeElement.blur();
88
+ setState(false);
89
+ }
90
+ });
91
+
92
+ window.addEventListener('popstate', updateCurrentPageLinks);
93
+ window.addEventListener('basecoat:locationchange', updateCurrentPageLinks);
94
+
95
+ updateState();
96
+ updateCurrentPageLinks();
97
+ sidebarComponent.dataset.sidebarInitialized = true;
98
+ sidebarComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
99
+ };
100
+
101
+ if (window.basecoat) {
102
+ window.basecoat.register('sidebar', '.sidebar:not([data-sidebar-initialized])', initSidebar);
103
+ }
104
+ })();
package/src/js/tabs.js ADDED
@@ -0,0 +1,63 @@
1
+ (() => {
2
+ const initTabs = (tabsComponent) => {
3
+ const tablist = tabsComponent.querySelector('[role="tablist"]');
4
+ if (!tablist) return;
5
+
6
+ const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
7
+ const panels = tabs.map(tab => document.getElementById(tab.getAttribute('aria-controls'))).filter(Boolean);
8
+
9
+ const selectTab = (tabToSelect) => {
10
+ tabs.forEach((tab, index) => {
11
+ tab.setAttribute('aria-selected', 'false');
12
+ tab.setAttribute('tabindex', '-1');
13
+ if (panels[index]) panels[index].hidden = true;
14
+ });
15
+
16
+ tabToSelect.setAttribute('aria-selected', 'true');
17
+ tabToSelect.setAttribute('tabindex', '0');
18
+ const activePanel = document.getElementById(tabToSelect.getAttribute('aria-controls'));
19
+ if (activePanel) activePanel.hidden = false;
20
+ };
21
+
22
+ tablist.addEventListener('click', (event) => {
23
+ const clickedTab = event.target.closest('[role="tab"]');
24
+ if (clickedTab) selectTab(clickedTab);
25
+ });
26
+
27
+ tablist.addEventListener('keydown', (event) => {
28
+ const currentTab = event.target;
29
+ if (!tabs.includes(currentTab)) return;
30
+
31
+ let nextTab;
32
+ const currentIndex = tabs.indexOf(currentTab);
33
+
34
+ switch (event.key) {
35
+ case 'ArrowRight':
36
+ nextTab = tabs[(currentIndex + 1) % tabs.length];
37
+ break;
38
+ case 'ArrowLeft':
39
+ nextTab = tabs[(currentIndex - 1 + tabs.length) % tabs.length];
40
+ break;
41
+ case 'Home':
42
+ nextTab = tabs[0];
43
+ break;
44
+ case 'End':
45
+ nextTab = tabs[tabs.length - 1];
46
+ break;
47
+ default:
48
+ return;
49
+ }
50
+
51
+ event.preventDefault();
52
+ selectTab(nextTab);
53
+ nextTab.focus();
54
+ });
55
+
56
+ tabsComponent.dataset.tabsInitialized = true;
57
+ tabsComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
58
+ };
59
+
60
+ if (window.basecoat) {
61
+ window.basecoat.register('tabs', '.tabs:not([data-tabs-initialized])', initTabs);
62
+ }
63
+ })();
@@ -0,0 +1,181 @@
1
+ (() => {
2
+ let toaster;
3
+ const toasts = new WeakMap();
4
+ let isPaused = false;
5
+ const ICONS = {
6
+ success: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
7
+ error: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
8
+ info: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
9
+ warning: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
10
+ };
11
+
12
+ function initToaster(toasterElement) {
13
+ if (toasterElement.dataset.toasterInitialized) return;
14
+ toaster = toasterElement;
15
+
16
+ toaster.addEventListener('mouseenter', pauseAllTimeouts);
17
+ toaster.addEventListener('mouseleave', resumeAllTimeouts);
18
+ toaster.addEventListener('click', (event) => {
19
+ const actionLink = event.target.closest('.toast footer a');
20
+ const actionButton = event.target.closest('.toast footer button');
21
+ if (actionLink || actionButton) {
22
+ closeToast(event.target.closest('.toast'));
23
+ }
24
+ });
25
+
26
+ toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast);
27
+ toaster.dataset.toasterInitialized = 'true';
28
+ toaster.dispatchEvent(new CustomEvent('basecoat:initialized'));
29
+ }
30
+
31
+ function initToast(element) {
32
+ if (element.dataset.toastInitialized) return;
33
+
34
+ const duration = parseInt(element.dataset.duration);
35
+ const timeoutDuration = duration !== -1
36
+ ? duration || (element.dataset.category === 'error' ? 5000 : 3000)
37
+ : -1;
38
+
39
+ const state = {
40
+ remainingTime: timeoutDuration,
41
+ timeoutId: null,
42
+ startTime: null,
43
+ };
44
+
45
+ if (timeoutDuration !== -1) {
46
+ if (isPaused) {
47
+ state.timeoutId = null;
48
+ } else {
49
+ state.startTime = Date.now();
50
+ state.timeoutId = setTimeout(() => closeToast(element), timeoutDuration);
51
+ }
52
+ }
53
+ toasts.set(element, state);
54
+
55
+ element.dataset.toastInitialized = 'true';
56
+ }
57
+
58
+ function pauseAllTimeouts() {
59
+ if (isPaused) return;
60
+
61
+ isPaused = true;
62
+
63
+ toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
64
+ if (!toasts.has(element)) return;
65
+
66
+ const state = toasts.get(element);
67
+ if (state.timeoutId) {
68
+ clearTimeout(state.timeoutId);
69
+ state.timeoutId = null;
70
+ state.remainingTime -= Date.now() - state.startTime;
71
+ }
72
+ });
73
+ }
74
+
75
+ function resumeAllTimeouts() {
76
+ if (!isPaused) return;
77
+
78
+ isPaused = false;
79
+
80
+ toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
81
+ if (!toasts.has(element)) return;
82
+
83
+ const state = toasts.get(element);
84
+ if (state.remainingTime !== -1 && !state.timeoutId) {
85
+ if (state.remainingTime > 0) {
86
+ state.startTime = Date.now();
87
+ state.timeoutId = setTimeout(() => closeToast(element), state.remainingTime);
88
+ } else {
89
+ closeToast(element);
90
+ }
91
+ }
92
+ });
93
+ }
94
+
95
+ function closeToast(element) {
96
+ if (!toasts.has(element)) return;
97
+
98
+ const state = toasts.get(element);
99
+ clearTimeout(state.timeoutId);
100
+ toasts.delete(element);
101
+
102
+ if (element.contains(document.activeElement)) document.activeElement.blur();
103
+ element.setAttribute('aria-hidden', 'true');
104
+ element.addEventListener('transitionend', () => element.remove(), { once: true });
105
+ }
106
+
107
+ function executeAction(button, toast) {
108
+ const actionString = button.dataset.toastAction;
109
+ if (!actionString) return;
110
+ try {
111
+ const func = new Function('close', actionString);
112
+ func(() => closeToast(toast));
113
+ } catch (event) {
114
+ console.error('Error executing toast action:', event);
115
+ }
116
+ }
117
+
118
+ function createToast(config) {
119
+ const {
120
+ category = 'info',
121
+ title,
122
+ description,
123
+ action,
124
+ cancel,
125
+ duration,
126
+ icon,
127
+ } = config;
128
+
129
+ const iconHtml = icon || (category && ICONS[category]) || '';
130
+ const titleHtml = title ? `<h2>${title}</h2>` : '';
131
+ const descriptionHtml = description ? `<p>${description}</p>` : '';
132
+ const actionHtml = action?.href
133
+ ? `<a href="${action.href}" class="btn" data-toast-action>${action.label}</a>`
134
+ : action?.onclick
135
+ ? `<button type="button" class="btn" data-toast-action onclick="${action.onclick}">${action.label}</button>`
136
+ : '';
137
+ const cancelHtml = cancel
138
+ ? `<button type="button" class="btn-outline h-6 text-xs px-2.5 rounded-sm" data-toast-cancel onclick="${cancel?.onclick}">${cancel.label}</button>`
139
+ : '';
140
+
141
+ const footerHtml = actionHtml || cancelHtml ? `<footer>${actionHtml}${cancelHtml}</footer>` : '';
142
+
143
+ const html = `
144
+ <div
145
+ class="toast"
146
+ role="${category === 'error' ? 'alert' : 'status'}"
147
+ aria-atomic="true"
148
+ ${category ? `data-category="${category}"` : ''}
149
+ ${duration !== undefined ? `data-duration="${duration}"` : ''}
150
+ >
151
+ <div class="toast-content">
152
+ ${iconHtml}
153
+ <section>
154
+ ${titleHtml}
155
+ ${descriptionHtml}
156
+ </section>
157
+ ${footerHtml}
158
+ </div>
159
+ </div>
160
+ </div>
161
+ `;
162
+ const template = document.createElement('template');
163
+ template.innerHTML = html.trim();
164
+ return template.content.firstChild;
165
+ }
166
+
167
+ document.addEventListener('basecoat:toast', (event) => {
168
+ if (!toaster) {
169
+ console.error('Cannot create toast: toaster container not found on page.');
170
+ return;
171
+ }
172
+ const config = event.detail?.config || {};
173
+ const toastElement = createToast(config);
174
+ toaster.appendChild(toastElement);
175
+ });
176
+
177
+ if (window.basecoat) {
178
+ window.basecoat.register('toaster', '#toaster:not([data-toaster-initialized])', initToaster);
179
+ window.basecoat.register('toast', '.toast:not([data-toast-initialized])', initToast);
180
+ }
181
+ })();
@@ -0,0 +1,206 @@
1
+ {#
2
+ Renders a standalone command menu component with search and keyboard navigation.
3
+
4
+ @param id {string} [optional] - Unique identifier for the command component.
5
+ @param items {array} [optional] - Array of items to render (alternative to using caller).
6
+ @param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
7
+ @param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
8
+ @param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
9
+ @param input_attrs {object} [optional] - Additional HTML attributes for the search input.
10
+ @param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
11
+ #}
12
+ {% macro command(
13
+ id=None,
14
+ items=None,
15
+ placeholder="Type a command or search...",
16
+ empty_text="No results found.",
17
+ main_attrs={},
18
+ input_attrs={},
19
+ menu_attrs={}
20
+ ) %}
21
+ {% set id = id or ("command-" + (range(100000, 999999) | random | string)) %}
22
+ <div
23
+ id="{{ id }}"
24
+ class="command {{ main_attrs.class }}"
25
+ aria-label="Command menu"
26
+ {% for key, value in main_attrs %}
27
+ {% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
28
+ {% endfor %}
29
+ >
30
+ <header>
31
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
32
+ <input
33
+ type="text"
34
+ id="{{ id }}-input"
35
+ placeholder="{{ placeholder }}"
36
+ autocomplete="off"
37
+ autocorrect="off"
38
+ spellcheck="false"
39
+ aria-autocomplete="list"
40
+ role="combobox"
41
+ aria-expanded="true"
42
+ aria-controls="{{ id }}-menu"
43
+ {% for key, value in input_attrs %}
44
+ {{ key }}="{{ value }}"
45
+ {% endfor %}
46
+ >
47
+ </header>
48
+ <div
49
+ role="menu"
50
+ id="{{ id }}-menu"
51
+ aria-orientation="vertical"
52
+ data-empty="{{ empty_text }}"
53
+ class="scrollbar {{ menu_attrs.class }}"
54
+ {% for key, value in menu_attrs %}
55
+ {% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
56
+ {% endfor %}
57
+ >
58
+ {% if items and items.length > 0 %}
59
+ {{ render_command_items(items, id ~ "-items" if id else "items") }}
60
+ {% else %}
61
+ {{ caller() if caller }}
62
+ {% endif %}
63
+ </div>
64
+ </div>
65
+ {% endmacro %}
66
+
67
+ {#
68
+ Renders a command dialog (modal command palette).
69
+
70
+ @param id {string} [optional] - Unique identifier for the command dialog.
71
+ @param items {array} [optional] - Array of items to render (alternative to using caller).
72
+ @param placeholder {string} [optional] [default="Type a command or search..."] - Placeholder text for the search input.
73
+ @param empty_text {string} [optional] [default="No results found."] - Text displayed when no results match the search.
74
+ @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog element.
75
+ @param input_attrs {object} [optional] - Additional HTML attributes for the search input.
76
+ @param menu_attrs {object} [optional] - Additional HTML attributes for the menu div.
77
+ @param open {boolean} [optional] [default=false] - Whether the command dialog should be open initially.
78
+ #}
79
+ {% macro command_dialog(
80
+ id=None,
81
+ items=None,
82
+ placeholder="Type a command or search...",
83
+ empty_text="No results found.",
84
+ dialog_attrs={},
85
+ input_attrs={},
86
+ menu_attrs={},
87
+ open=false
88
+ ) %}
89
+ {% set id = id or ("command-dialog-" + (range(100000, 999999) | random | string)) %}
90
+ <dialog
91
+ id="{{ id }}"
92
+ class="command-dialog {{ dialog_attrs.class }}"
93
+ aria-label="Command menu"
94
+ {% if open %}open{% endif %}
95
+ onclick="if (event.target === this) this.close()"
96
+ {% for key, value in dialog_attrs %}
97
+ {% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
98
+ {% endfor %}
99
+ >
100
+ <div class="command">
101
+ <header>
102
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
103
+ <input
104
+ type="text"
105
+ id="{{ id }}-input"
106
+ placeholder="{{ placeholder }}"
107
+ autocomplete="off"
108
+ autocorrect="off"
109
+ spellcheck="false"
110
+ aria-autocomplete="list"
111
+ role="combobox"
112
+ aria-expanded="true"
113
+ aria-controls="{{ id }}-menu"
114
+ {% for key, value in input_attrs %}
115
+ {{ key }}="{{ value }}"
116
+ {% endfor %}
117
+ >
118
+ </header>
119
+ <div
120
+ role="menu"
121
+ id="{{ id }}-menu"
122
+ aria-orientation="vertical"
123
+ data-empty="{{ empty_text }}"
124
+ class="scrollbar {{ menu_attrs.class }}"
125
+ {% for key, value in menu_attrs %}
126
+ {% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
127
+ {% endfor %}
128
+ >
129
+ {% if items and items.length > 0 %}
130
+ {{ render_command_items(items, id ~ "-items" if id else "items") }}
131
+ {% else %}
132
+ {{ caller() if caller }}
133
+ {% endif %}
134
+ </div>
135
+ <button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
136
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
137
+ </button>
138
+ </div>
139
+ </dialog>
140
+ {% endmacro %}
141
+
142
+ {#
143
+ Renders a list of items for the command component.
144
+
145
+ @param items {array} - Array of items to render. Each item can be:
146
+ - { type: "item", label: "Text or HTML", icon: "HTML", url: "href", keywords: "search terms", disabled: true, attrs: {} }
147
+ - { type: "group", label: "Group Title", items: [...] }
148
+ - { type: "separator" }
149
+ Note: Use attrs to add data-label for alternative search text if needed. If url is provided, renders as <a role="menuitem"> instead of <div role="menuitem">. Label supports HTML (marked as safe).
150
+ @param parent_id_prefix {string} [optional] - The prefix for the item id.
151
+ #}
152
+ {% macro render_command_items(items, parent_id_prefix="items") %}
153
+ {% for item in items %}
154
+ {% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
155
+ {% if item.type == "group" %}
156
+ {% set group_label_id = item.id if item.id else "group-label-" + item_id %}
157
+ <div
158
+ role="group"
159
+ aria-labelledby="{{ group_label_id }}"
160
+ {% if item.attrs %}
161
+ {% for key, value in item.attrs %}
162
+ {{ key }}="{{ value }}"
163
+ {% endfor %}
164
+ {% endif %}
165
+ >
166
+ <span role="heading" id="{{ group_label_id }}">{{ item.label | safe }}</span>
167
+ {{ render_command_items(item.items, item_id) if item.items }}
168
+ </div>
169
+ {% elif item.type == "separator" %}
170
+ <hr role="separator" />
171
+ {% elif item.type == "item" or not item.type %}
172
+ {% if item.url %}
173
+ <a
174
+ id="{{ item_id }}"
175
+ href="{{ item.url }}"
176
+ role="menuitem"
177
+ {% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
178
+ {% if item.disabled %}aria-disabled="true"{% endif %}
179
+ {% if item.attrs %}
180
+ {% for key, value in item.attrs %}
181
+ {% if key != "href" %}{{ key }}="{{ value }}"{% endif %}
182
+ {% endfor %}
183
+ {% endif %}
184
+ >
185
+ {% if item.icon %}{{ item.icon | safe }}{% endif %}
186
+ {{ item.label | safe }}
187
+ </a>
188
+ {% else %}
189
+ <div
190
+ id="{{ item_id }}"
191
+ role="menuitem"
192
+ {% if item.keywords %}data-keywords="{{ item.keywords }}"{% endif %}
193
+ {% if item.disabled %}aria-disabled="true"{% endif %}
194
+ {% if item.attrs %}
195
+ {% for key, value in item.attrs %}
196
+ {{ key }}="{{ value }}"
197
+ {% endfor %}
198
+ {% endif %}
199
+ >
200
+ {% if item.icon %}{{ item.icon | safe }}{% endif %}
201
+ {{ item.label | safe }}
202
+ </div>
203
+ {% endif %}
204
+ {% endif %}
205
+ {% endfor %}
206
+ {% endmacro %}
@@ -0,0 +1,92 @@
1
+ {#
2
+ Renders a dialog component with optional trigger, header, body, and footer.
3
+
4
+ @param id {string} - Unique identifier for the dialog component.
5
+ @param trigger {string} [optional] - Text or HTML for the button that triggers the dialog.
6
+ @param title {string} [optional] - Title text displayed in the dialog header.
7
+ @param description {string} [optional] - Description text displayed below the title.
8
+ @param footer {string} [optional] - HTML content for the dialog footer.
9
+ @param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
10
+ @param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
11
+ @param dialog_attrs {object} [optional] - Additional HTML attributes for the dialog content container.
12
+ @param header_attrs {object} [optional] - Additional HTML attributes for the dialog header.
13
+ @param body_attrs {object} [optional] - Additional HTML attributes for the dialog body section.
14
+ @param footer_attrs {object} [optional] - Additional HTML attributes for the dialog footer.
15
+ @param open {boolean} [optional] [default=false] - Whether the dialog should be open initially.
16
+ @param close_button {boolean} [optional] [default=true] - Whether to include a close button.
17
+ @param close_on_overlay_click {boolean} [optional] [default=true] - Whether clicking the overlay closes the dialog.
18
+ #}
19
+ {% macro dialog(
20
+ id=None,
21
+ trigger=None,
22
+ title=None,
23
+ description=None,
24
+ footer=None,
25
+ dialog_attrs={},
26
+ trigger_attrs={},
27
+ header_attrs={},
28
+ body_attrs={},
29
+ footer_attrs={},
30
+ open=false,
31
+ close_button=true,
32
+ close_on_overlay_click=true
33
+ ) %}
34
+ {% set id = id or ("dialog-" + (range(100000, 999999) | random | string)) %}
35
+ {% if trigger %}
36
+ <button
37
+ type="button"
38
+ onclick="document.getElementById('{{ id }}').showModal()"
39
+ {% for key, value in trigger_attrs %}
40
+ {{ key }}="{{ value }}"
41
+ {% endfor %}
42
+ >
43
+ {{ trigger | safe }}
44
+ </button>
45
+ {% endif %}
46
+ <dialog
47
+ id="{{ id }}"
48
+ class="dialog {{ dialog_attrs.class }}"
49
+ aria-labelledby="{{ id }}-title"
50
+ {% if description %}aria-describedby="{{ id }}-description"{% endif %}
51
+ {% if close_on_overlay_click %}onclick="if (event.target === this) this.close()"{% endif %}
52
+ {% for key, value in dialog_attrs %}
53
+ {% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
54
+ {% endfor %}
55
+ >
56
+ <div>
57
+ {% if title or description %}
58
+ <header
59
+ {% for key, value in header_attrs %}
60
+ {{ key }}="{{ value }}"
61
+ {% endfor %}
62
+ >
63
+ <h2 id="{{ id }}-title">{{ title | safe }}</h2>
64
+ {% if description %}<p id="{{ id }}-description">{{ description | safe }}</p>{% endif %}
65
+ </header>
66
+ {% endif %}
67
+ {% if caller %}
68
+ <section
69
+ {% for key, value in body_attrs %}
70
+ {{ key }}="{{ value }}"
71
+ {% endfor %}
72
+ >
73
+ {{ caller() }}
74
+ </section>
75
+ {% endif %}
76
+ {% if footer %}
77
+ <footer
78
+ {% for key, value in footer_attrs %}
79
+ {{ key }}="{{ value }}"
80
+ {% endfor %}
81
+ >
82
+ {{ footer | safe }}
83
+ </footer>
84
+ {% endif %}
85
+ {% if close_button %}
86
+ <button type="button" aria-label="Close dialog" onclick="this.closest('dialog').close()">
87
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
88
+ </button>
89
+ {% endif %}
90
+ </div>
91
+ </dialog>
92
+ {% endmacro %}