basecoat-cli 0.1.0

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.
@@ -0,0 +1,198 @@
1
+ {#
2
+ Renders a toaster container and individual toast messages.
3
+ Can render the initial container or be used with htmx OOB swaps to add toasts dynamically.
4
+
5
+ @param id {string} [optional] [default="toaster"] - Unique identifier for the toaster container.
6
+ @param toasts {array} [optional] - An array of toast objects to render initially. See the <code>toast()</code> macro for more details.
7
+ @param main_attrs {object} [optional] - Additional HTML attributes for the main toaster container div.
8
+ @param is_fragment {boolean} [optional] [default=False] - If True, renders only the toast elements with hx-swap-oob="beforeend", suitable for htmx responses. Skips script and template inclusion.
9
+ #}
10
+ {% macro toaster(
11
+ id="toaster",
12
+ toasts=[],
13
+ main_attrs=None,
14
+ is_fragment=False
15
+ ) %}
16
+ <div
17
+ id="{{ id }}"
18
+ class="toaster"
19
+ {% for key, value in main_attrs.items() %}
20
+ {{ key }}="{{ value }}"
21
+ {% endfor %}
22
+ {% if is_fragment %}hx-swap-oob="beforeend"{% endif %}
23
+ >
24
+ {% for item in toasts.items() %}
25
+ {{ toast(
26
+ category=item.category,
27
+ title=item.title,
28
+ description=item.description,
29
+ action=item.action,
30
+ cancel=item.cancel
31
+ ) }}
32
+ {% endfor %}
33
+ </div>
34
+
35
+ {% if not is_fragment %}
36
+ <template id="toast-template">
37
+ <div
38
+ class="toast"
39
+ role="status"
40
+ aria-atomic="true"
41
+ x-bind="$toastBindings"
42
+ >
43
+ <div class="toast-content">
44
+ <div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
45
+ <template x-if="config.icon">
46
+ <span aria-hidden="true" role="img" x-html="config.icon"></span>
47
+ </template>
48
+ <template x-if="!config.icon && config.category === 'success'">
49
+ {{ toast_icons.success | safe }}
50
+ </template>
51
+ <template x-if="!config.icon && config.category === 'error'">
52
+ {{ toast_icons.error | safe }}
53
+ </template>
54
+ <template x-if="!config.icon && config.category === 'info'">
55
+ {{ toast_icons.info | safe }}
56
+ </template>
57
+ <template x-if="!config.icon && config.category === 'warning'">
58
+ {{ toast_icons.warning | safe }}
59
+ </template>
60
+ <section class="flex-1 flex flex-col gap-0.5 items-start">
61
+ <template x-if="config.title">
62
+ <h2 class="font-medium" x-text="config.title"></h2>
63
+ </template>
64
+ <template x-if="config.description">
65
+ <p class="text-muted-foreground" x-text="config.description"></p>
66
+ </template>
67
+ </section>
68
+ <template x-if="config.action || config.cancel">
69
+ <footer class="flex flex-col gap-1 self-start">
70
+ <template x-if="config.action?.click">
71
+ <button
72
+ type="button"
73
+ class="btn h-6 text-xs px-2.5 rounded-sm"
74
+ @click="executeAction(config.action.click)"
75
+ x-text="config.action.label"
76
+ ></button>
77
+ </template>
78
+ <template x-if="config.action?.url">
79
+ <a
80
+ :href="config.action.url"
81
+ class="btn h-6 text-xs px-2.5 rounded-sm"
82
+ x-text="config.action.label"
83
+ ></a>
84
+ </template>
85
+ <template x-if="config.cancel?.click">
86
+ <button
87
+ type="button"
88
+ class="btn-outline h-6 text-xs px-2.5 rounded-sm"
89
+ @click="executeAction(config.cancel.click)"
90
+ x-text="config.cancel.label"
91
+ ></button>
92
+ </template>
93
+ <template x-if="config.cancel?.url">
94
+ <a
95
+ :href="config.cancel.url"
96
+ class="btn-outline h-6 text-xs px-2.5 rounded-sm"
97
+ x-text="config.cancel.label"
98
+ ></a>
99
+ </template>
100
+ </footer>
101
+ </template>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </template>
106
+ {% endif %}
107
+ {% endmacro %}
108
+
109
+ {#
110
+ Renders a single toast message.
111
+
112
+ @param category {string} [optional] [default="success"] - Type of toast ('success', 'error', 'info', 'warning'). Determines icon and ARIA role.
113
+ @param title {string} [optional] - The main title text of the toast.
114
+ @param description {string} [optional] - The secondary description text.
115
+ @param action {object} [optional] - Defines an action button.
116
+ - label {string}: Button text.
117
+ - click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
118
+ - url {string}: URL for an anchor link button.
119
+ @param cancel {object} [optional] - Defines a cancel/dismiss button (similar structure to action).
120
+ - label {string}: Button text.
121
+ - click {string}: JavaScript code to execute on click (e.g., '$dispatch(\'custom-event\')').
122
+ - url {string}: URL for an anchor link button.
123
+ #}
124
+ {% macro toast(
125
+ category="success",
126
+ title="",
127
+ description="",
128
+ action=None,
129
+ cancel=None
130
+ ) %}
131
+ <div
132
+ class="toast"
133
+ role="{{ 'alert' if category == 'error' else 'status' }}"
134
+ aria-atomic="true"
135
+ aria-hidden="false"
136
+ {% if category %}data-category="{{ category }}"{% endif %}
137
+ x-data="toast({
138
+ category: '{{ category }}',
139
+ duration: {{ duration or 'null' }}
140
+ })"
141
+ x-bind="$toastBindings"
142
+ >
143
+ <div class="toast-content">
144
+ <div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
145
+ {% if category in ["error", "success", "info", "warning"] %}
146
+ {{ toast_icons[category] | safe }}
147
+ {% endif %}
148
+ <section class="flex-1 flex flex-col gap-0.5 items-start">
149
+ {% if title %}
150
+ <h2 class="font-medium">{{ title }}</h2>
151
+ {% endif %}
152
+ {% if description %}
153
+ <p class="text-muted-foreground">{{ description }}</p>
154
+ {% endif %}
155
+ </section>
156
+ {% if action or cancel %}
157
+ <footer class="flex flex-col gap-1 self-start">
158
+ {% if action %}
159
+ {% if action.click %}
160
+ <button
161
+ type="button"
162
+ class="btn h-6 text-xs px-2.5 rounded-sm"
163
+ @click="{{ action.click }}"
164
+ >{{ action.label }}</button>
165
+ {% elif action.url %}
166
+ <a
167
+ href="{{ action.url }}"
168
+ class="btn h-6 text-xs px-2.5 rounded-sm"
169
+ >{{ action.label }}</a>
170
+ {% endif %}
171
+ {% endif %}
172
+ {% if cancel %}
173
+ {% if cancel.click %}
174
+ <button
175
+ type="button"
176
+ class="btn-outline h-6 text-xs px-2.5 rounded-sm"
177
+ @click="{{ cancel.click }}"
178
+ >{{ cancel.label }}</button>
179
+ {% elif cancel.url %}
180
+ <a
181
+ href="{{ cancel.url }}"
182
+ class="btn-outline h-6 text-xs px-2.5 rounded-sm"
183
+ >{{ toast.cancel.label }}</a>
184
+ {% endif %}
185
+ {% endif %}
186
+ </footer>
187
+ {% endif %}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ {% endmacro %}
192
+
193
+ {% set toast_icons = {
194
+ '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" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
195
+ '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" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
196
+ '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" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
197
+ '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" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><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>'
198
+ } %}
@@ -0,0 +1,54 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerDialog = function(Alpine) {
3
+ if (Alpine.components && Alpine.components.dialog) return;
4
+
5
+ Alpine.data('dialog', (initialOpen = false, initialCloseOnOverlayClick = true) => ({
6
+ id: null,
7
+ open: false,
8
+ closeOnOverlayClick: true,
9
+
10
+ init() {
11
+ this.id = this.$el.id;
12
+ if (!this.id) {
13
+ console.warn('Dialog component initialized without an `id`. This may cause issues with event targeting and accessibility.');
14
+ }
15
+ this.open = initialOpen;
16
+ this.closeOnOverlayClick = initialCloseOnOverlayClick;
17
+ },
18
+ show() {
19
+ if (!this.open) {
20
+ this.open = true;
21
+ this.$nextTick(() => {
22
+ this.$el.dispatchEvent(new CustomEvent('dialog:opened', { bubbles: true, detail: { id: this.id } }));
23
+ setTimeout(() => {
24
+ const focusTarget = this.$refs.focusOnOpen || this.$el.querySelector('[role="dialog"]');
25
+ if (focusTarget) focusTarget.focus();
26
+ }, 50);
27
+ });
28
+ }
29
+ },
30
+ hide() {
31
+ if (this.open) {
32
+ this.open = false;
33
+ this.$nextTick(() => {
34
+ this.$el.dispatchEvent(new CustomEvent('dialog:closed', { bubbles: true, detail: { id: this.id } }));
35
+ });
36
+ }
37
+ },
38
+
39
+ $main: {
40
+ '@dialog:open.window'(e) { if (e.detail && e.detail.id === this.id) this.show() },
41
+ '@dialog:close.window'(e) { if (e.detail && e.detail.id === this.id) this.hide() },
42
+ '@keydown.escape.window'() { this.open && this.hide() },
43
+ },
44
+ $trigger: {
45
+ '@click'() { this.show() },
46
+ ':aria-expanded'() { return this.open }
47
+ },
48
+ $content: {
49
+ ':inert'() { return !this.open },
50
+ '@click.self'() { if (this.closeOnOverlayClick) this.hide() },
51
+ 'x-cloak': ''
52
+ }
53
+ }));
54
+ };
@@ -0,0 +1,91 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerDropdownMenu = function(Alpine) {
3
+ if (Alpine.components && Alpine.components.dropdownMenu) return;
4
+
5
+ Alpine.data('dropdownMenu', () => ({
6
+ open: false,
7
+ focusedIndex: null,
8
+ menuItems: [],
9
+
10
+ init() {
11
+ this.$nextTick(() => {
12
+ this.menuItems = Array.from(this.$el.querySelectorAll('[role=menuitem]:not([disabled]),[role=menuitemcheckbox]:not([disabled]),[role=menuitemradio]:not([disabled])'));
13
+ });
14
+ },
15
+ focusMenuitem() {
16
+ if (this.menuItems.length === 0) return;
17
+
18
+ if (this.focusedIndex >= this.menuItems.length) {
19
+ this.focusedIndex = this.menuItems.length - 1;
20
+ } else if (this.focusedIndex < 0 || this.focusedIndex === null) {
21
+ this.focusedIndex = 0;
22
+ }
23
+ this.menuItems.forEach(item => item.blur());
24
+ this.menuItems[this.focusedIndex].focus();
25
+ },
26
+ moveMenuitemFocus(delta) {
27
+ if (this.menuItems.length === 0) return;
28
+
29
+ let wasOpen = this.open;
30
+ if (!this.open) {
31
+ this.open = true;
32
+ this.focusedIndex = 0;
33
+ } else {
34
+ this.focusedIndex = this.focusedIndex === null
35
+ ? 0
36
+ : this.focusedIndex + delta;
37
+ }
38
+
39
+ if (wasOpen) {
40
+ this.focusMenuitem();
41
+ } else {
42
+ setTimeout(() => this.focusMenuitem(), 50);
43
+ }
44
+ },
45
+ handleMenuitemClick(event) {
46
+ const menuitem = event.target.closest('[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]');
47
+ if (menuitem && menuitem.getAttribute('aria-disabled') !== 'true' && !menuitem.disabled) {
48
+ this.$nextTick(() => {
49
+ this.$refs.trigger.focus();
50
+ this.open = false;
51
+ });
52
+ }
53
+ },
54
+ handleMenuitemMousemove(event) {
55
+ const menuitem = event.target.closest('[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]');
56
+ if (menuitem && menuitem.getAttribute('aria-disabled') !== 'true' && !menuitem.disabled) {
57
+ this.focusedIndex = this.menuItems.indexOf(menuitem);
58
+ this.focusMenuitem();
59
+ }
60
+ },
61
+
62
+ $trigger: {
63
+ '@click'() { this.open = !this.open },
64
+ '@keydown.escape.prevent'() {
65
+ this.open = false;
66
+ this.$refs.trigger.focus();
67
+ },
68
+ '@keydown.down.prevent'() { this.moveMenuitemFocus(+1); },
69
+ '@keydown.up.prevent'() { this.moveMenuitemFocus(-1); },
70
+ '@keydown.home.prevent'() { this.focusMenuitem(0) },
71
+ '@keydown.end.prevent'() { this.focusMenuitem(this.menuItems.length - 1) },
72
+ '@keydown.enter.prevent'() { this.open = !this.open },
73
+ ':aria-expanded'() { return this.open },
74
+ 'x-ref': 'trigger'
75
+ },
76
+ $content: {
77
+ '@click'(e) { this.handleMenuitemClick(e) },
78
+ '@keydown.escape.prevent'() {
79
+ this.open = false;
80
+ this.$refs.trigger.focus();
81
+ },
82
+ '@keydown.down.prevent'() { this.moveMenuitemFocus(+1); },
83
+ '@keydown.up.prevent'() { this.moveMenuitemFocus(-1); },
84
+ '@keydown.home.prevent'() { this.focusMenuitem(0) },
85
+ '@keydown.end.prevent'() { this.focusMenuitem(this.menuItems.length - 1) },
86
+ '@mouseover'(e) { this.handleMenuitemMousemove(e) },
87
+ ':aria-hidden'() { return !this.open },
88
+ 'x-cloak': ''
89
+ },
90
+ }));
91
+ };
@@ -0,0 +1,26 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerPopover = function(Alpine) {
3
+ if (Alpine.components && Alpine.components.popover) return;
4
+
5
+ Alpine.data('popover', () => ({
6
+ open: false,
7
+
8
+ $trigger: {
9
+ '@click'() { this.open = !this.open },
10
+ '@keydown.escape.prevent'() {
11
+ this.open = false;
12
+ this.$refs.trigger.focus();
13
+ },
14
+ ':aria-expanded'() { return this.open },
15
+ 'x-ref': 'trigger'
16
+ },
17
+ $content: {
18
+ '@keydown.escape.prevent'() {
19
+ this.open = false;
20
+ this.$refs.trigger.focus();
21
+ },
22
+ ':aria-hidden'() { return !this.open },
23
+ 'x-cloak': ''
24
+ },
25
+ }));
26
+ };
@@ -0,0 +1,150 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerSelect = function(Alpine) {
3
+ if (Alpine.components && Alpine.components.select) return;
4
+
5
+ Alpine.data('select', (name = null, initialValue = null) => ({
6
+ open: false,
7
+ name: null,
8
+ options: [],
9
+ disabledOptions: [],
10
+ focusedIndex: null,
11
+ selectedLabel: null,
12
+ selectedValue: null,
13
+ query: '',
14
+
15
+ init() {
16
+ this.$nextTick(() => {
17
+ if (name) this.name = name;
18
+ this.options = Array.from(this.$el.querySelectorAll('[role=option]:not([aria-disabled])'));
19
+ this.disabledOptions = Array.from(this.$el.querySelectorAll('[role=option][aria-disabled=true]'));
20
+ if (this.options.length === 0) return;
21
+ if (initialValue) {
22
+ const option = this.options.find(opt => opt.getAttribute('data-value') === initialValue);
23
+ this.selectOption(option, false);
24
+ this.focusedIndex = this.options.indexOf(option);
25
+ } else {
26
+ this.selectOption(this.options[0], false);
27
+ }
28
+ });
29
+ },
30
+ focusOption() {
31
+ if (this.options.length === 0) return;
32
+
33
+ if (this.focusedIndex >= this.options.length) {
34
+ this.focusedIndex = this.options.length - 1;
35
+ } else if (this.focusedIndex < 0 || this.focusedIndex === null) {
36
+ this.focusedIndex = 0;
37
+ }
38
+ this.options.forEach(opt => opt.removeAttribute('data-focus'));
39
+ const option = this.options[this.focusedIndex];
40
+ option.setAttribute('data-focus', '');
41
+ option.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
42
+ },
43
+ focusOnSelectedOption() {
44
+ if (this.options.length === 0 || this.selectedValue === null) return;
45
+ const option = this.options.find(opt => opt.getAttribute('data-value') === this.selectedValue);
46
+ if (!option) return;
47
+ this.focusedIndex = this.options.indexOf(option);
48
+ this.focusOption();
49
+ },
50
+ moveOptionFocus(delta) {
51
+ if (this.options.length === 0) return;
52
+
53
+ if (!this.open) {
54
+ this.open = true;
55
+ this.focusOnSelectedOption();
56
+ } else {
57
+ this.focusedIndex = this.focusedIndex === null
58
+ ? 0
59
+ : this.focusedIndex + delta;
60
+ }
61
+ this.focusOption();
62
+ },
63
+ handleOptionClick(event) {
64
+ const option = event.target.closest('[role=option]');
65
+ if (option && option.getAttribute('aria-disabled') !== 'true') {
66
+ this.selectOption(option);
67
+ this.open = false;
68
+ this.$nextTick(() => this.$refs.trigger.focus());
69
+ }
70
+ },
71
+ handleOptionMousemove(event) {
72
+ const option = event.target.closest('[role=option]');
73
+ if (option && option.getAttribute('aria-disabled') !== 'true') {
74
+ this.focusedIndex = this.options.indexOf(option);
75
+ this.focusOption();
76
+ }
77
+ },
78
+ handleOptionEnter(event) {
79
+ this.selectOption(this.options[this.focusedIndex]);
80
+ this.open = !this.open;
81
+ },
82
+ selectOption(option, dispatch = true) {
83
+ if (this.options.length === 0 || !option || option.disabled || option.getAttribute('data-value') == null) {
84
+ return;
85
+ }
86
+
87
+ this.options.forEach(opt => {
88
+ opt.setAttribute('aria-selected', opt === option);
89
+ });
90
+ this.selectedLabel = option.innerHTML;
91
+ this.selectedValue = option.getAttribute('data-value');
92
+ if (dispatch) {
93
+ this.$dispatch('select:change', {
94
+ value: this.selectedValue,
95
+ label: this.selectedLabel
96
+ });
97
+ this.$dispatch('change');
98
+ }
99
+ },
100
+ filterOptions(query) {
101
+ if (query.length > 0) {
102
+ this.disabledOptions.forEach(opt => { opt.setAttribute('aria-hidden', 'true'); });
103
+ } else {
104
+ this.disabledOptions.forEach(opt => { opt.removeAttribute('aria-hidden'); });
105
+ }
106
+ this.options.forEach(opt => {
107
+ opt.removeAttribute('aria-hidden');
108
+ if (opt.getAttribute('data-value') != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
109
+ opt.setAttribute('aria-hidden', 'true');
110
+ }
111
+ });
112
+ },
113
+
114
+ $trigger: {
115
+ '@click'() { this.open = !this.open; this.focusOnSelectedOption(); },
116
+ '@keydown.escape.prevent'() {
117
+ this.open = false;
118
+ this.$refs.trigger.focus();
119
+ },
120
+ '@keydown.down.prevent'() { this.moveOptionFocus(+1); },
121
+ '@keydown.up.prevent'() { this.moveOptionFocus(-1); },
122
+ '@keydown.home.prevent'() { this.focusOption(0) },
123
+ '@keydown.end.prevent'() { this.focusOption(this.options.length - 1) },
124
+ '@keydown.enter.prevent'() {
125
+ if (this.open) this.handleOptionEnter();
126
+ else this.open = true;
127
+ },
128
+ ':aria-expanded'() { return this.open },
129
+ 'x-ref': 'trigger'
130
+ },
131
+ $content: {
132
+ '@click'(e) { this.handleOptionClick(e) },
133
+ '@mouseover'(e) { this.handleOptionMousemove(e) },
134
+ ':aria-hidden'() { return !this.open },
135
+ 'x-cloak': ''
136
+ },
137
+ $filter: {
138
+ '@input'(e) { this.filterOptions(e.target.value) },
139
+ '@keydown.escape.prevent'() {
140
+ this.open = false;
141
+ this.$refs.trigger.focus();
142
+ },
143
+ '@keydown.down.prevent'() { this.moveOptionFocus(+1); },
144
+ '@keydown.up.prevent'() { this.moveOptionFocus(-1); },
145
+ '@keydown.home.prevent'() { this.focusOption(0) },
146
+ '@keydown.end.prevent'() { this.focusOption(this.options.length - 1) },
147
+ '@keydown.enter.prevent'() { this.handleOptionEnter() },
148
+ }
149
+ }));
150
+ };
@@ -0,0 +1,25 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerSidebar= function(Alpine) {
3
+ if (Alpine.components && Alpine.components.sidebar) return;
4
+
5
+ Alpine.data('sidebar', (initialOpen = true, initialMobileOpen = false) => ({
6
+ open: window.innerWidth >= 768
7
+ ? initialOpen
8
+ : initialMobileOpen,
9
+
10
+ init() {
11
+ this.$nextTick(() => {
12
+ this.$el.removeAttribute('data-uninitialized');
13
+ });
14
+ },
15
+
16
+ $main: {
17
+ '@sidebar:open.window'(e) { this.open = true },
18
+ '@sidebar:close.window'(e) { this.open = false },
19
+ '@sidebar:toggle.window'(e) { this.open = !this.open },
20
+ '@click'(e) { if (e.target === this.$el) this.open = false },
21
+ ':aria-hidden'() { return !this.open },
22
+ ':inert'() { return !this.open }
23
+ },
24
+ }));
25
+ };
@@ -0,0 +1,64 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerTabs = function(Alpine) {
3
+ if (Alpine.components && Alpine.components.tabs) return;
4
+
5
+ Alpine.data('tabs', (initialTabIndex = 0) => ({
6
+ activeTabIndex: initialTabIndex,
7
+ tabs: [],
8
+ panels: [],
9
+
10
+ init() {
11
+ this.$nextTick(() => {
12
+ this.tabs = Array.from(this.$el.querySelectorAll(':scope > [role=tablist] [role=tab]:not([disabled])'));
13
+ this.panels = Array.from(this.$el.querySelectorAll(':scope > [role=tabpanel]'));
14
+ if (this.tabs.length > 0) {
15
+ this.selectTab(this.tabs[initialTabIndex], false);
16
+ }
17
+ });
18
+ },
19
+ nextTab() {
20
+ if (this.tabs.length === 0) return;
21
+ let newIndex = (this.activeTabIndex + 1) % this.tabs.length;
22
+ this.selectTab(this.tabs[newIndex]);
23
+ },
24
+ prevTab() {
25
+ if (this.tabs.length === 0) return;
26
+ let newIndex = (this.activeTabIndex - 1 + this.tabs.length) % this.tabs.length;
27
+ this.selectTab(this.tabs[newIndex]);
28
+ },
29
+ selectTab(tab, focus = true) {
30
+ if (!tab || this.tabs.length === 0) return;
31
+
32
+ this.tabs.forEach((t, index) => {
33
+ const isSelected = t === tab;
34
+ t.setAttribute('aria-selected', isSelected);
35
+ t.setAttribute('tabindex', isSelected ? '0' : '-1');
36
+ if (isSelected) {
37
+ this.activeTabIndex = index;
38
+ this.activeTab = t;
39
+ if (focus) {
40
+ t.focus();
41
+ }
42
+ }
43
+ });
44
+
45
+ const panelId = tab.getAttribute('aria-controls');
46
+ if (!panelId) return;
47
+
48
+ this.panels.forEach(panel => {
49
+ panel.hidden = (panel.getAttribute('id') !== panelId);
50
+ });
51
+ },
52
+
53
+ $tablist: {
54
+ ['@click'](event) {
55
+ const clickedTab = event.target.closest('[role=tab]');
56
+ if (clickedTab) {
57
+ this.selectTab(clickedTab);
58
+ }
59
+ },
60
+ ['@keydown.arrow-right.prevent']() { this.nextTab() },
61
+ ['@keydown.arrow-left.prevent']() { this.prevTab() },
62
+ },
63
+ }));
64
+ };
@@ -0,0 +1,75 @@
1
+ window.basecoat = window.basecoat || {};
2
+ window.basecoat.registerToast = function(Alpine) {
3
+ if (Alpine.components && Alpine.components.toast) return;
4
+
5
+ Alpine.store('toaster', { isPaused: false });
6
+ Alpine.data('toast', (config={}) => ({
7
+ config: config,
8
+ open: false,
9
+ timeoutDuration: null,
10
+ timeoutId: null,
11
+
12
+ init() {
13
+ if (config.duration !== -1) {
14
+ this.timeoutDuration = config.duration || (config.category === 'error' ? 5000 : 3000);
15
+ this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
16
+ }
17
+ this.open = true;
18
+ this.$watch('$store.toaster.isPaused', (isPaused) => {
19
+ if (!this.open) return;
20
+ if (isPaused) {
21
+ this.pauseTimeout();
22
+ } else {
23
+ this.resumeTimeout();
24
+ }
25
+ });
26
+ },
27
+ pauseTimeout() {
28
+ clearTimeout(this.timeoutId);
29
+ this.timeoutId = null;
30
+ },
31
+ resumeTimeout(index) {
32
+ if (this.open && this.timeoutId === null) {
33
+ this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
34
+ }
35
+ },
36
+ close() {
37
+ this.pauseTimeout();
38
+ this.open = false;
39
+ this.$el.blur();
40
+ },
41
+ executeAction(actionString) {
42
+ if (actionString) {
43
+ Alpine.evaluate(this.$el, actionString);
44
+ }
45
+ },
46
+
47
+ $toastBindings: {
48
+ ['@mouseenter']() { this.$store.toaster.isPaused = true },
49
+ ['@mouseleave']() { this.$store.toaster.isPaused = false },
50
+ ['@keydown.escape.prevent']() { this.close() },
51
+ [':aria-hidden']() { return !this.open }
52
+ },
53
+ }));
54
+
55
+ Alpine.magic('toast', (el) => (config, toasterId='toaster') => {
56
+ const toaster = document.getElementById(toasterId);
57
+ const template = document.getElementById('toast-template');
58
+
59
+ if (!toaster) {
60
+ console.error(`Toaster container with id #${toasterId} not found.`);
61
+ return;
62
+ }
63
+ if (!template) {
64
+ console.error('Toast template with id #toast-template not found.');
65
+ return;
66
+ }
67
+
68
+ const clone = template.content.firstElementChild.cloneNode(true);
69
+
70
+ clone.setAttribute('x-data', `toast(${JSON.stringify(config)})`);
71
+ clone.removeAttribute('id');
72
+
73
+ toaster.appendChild(clone);
74
+ });
75
+ };