lycan_ui 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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +33 -0
  4. data/Rakefile +5 -0
  5. data/lib/generators/lycan_ui/add_generator.rb +109 -0
  6. data/lib/generators/lycan_ui/setup_generator.rb +76 -0
  7. data/lib/generators/lycan_ui/templates/components/accordion.rb +63 -0
  8. data/lib/generators/lycan_ui/templates/components/alert.rb +35 -0
  9. data/lib/generators/lycan_ui/templates/components/avatar.rb +38 -0
  10. data/lib/generators/lycan_ui/templates/components/badge.rb +29 -0
  11. data/lib/generators/lycan_ui/templates/components/button.rb +49 -0
  12. data/lib/generators/lycan_ui/templates/components/checkbox.rb +31 -0
  13. data/lib/generators/lycan_ui/templates/components/collapsible.rb +40 -0
  14. data/lib/generators/lycan_ui/templates/components/component.rb +72 -0
  15. data/lib/generators/lycan_ui/templates/components/dialog.rb +129 -0
  16. data/lib/generators/lycan_ui/templates/components/dropdown.rb +242 -0
  17. data/lib/generators/lycan_ui/templates/components/input.rb +26 -0
  18. data/lib/generators/lycan_ui/templates/components/label.rb +30 -0
  19. data/lib/generators/lycan_ui/templates/components/popover.rb +53 -0
  20. data/lib/generators/lycan_ui/templates/components/radio.rb +27 -0
  21. data/lib/generators/lycan_ui/templates/components/select.rb +38 -0
  22. data/lib/generators/lycan_ui/templates/components/switch.rb +26 -0
  23. data/lib/generators/lycan_ui/templates/components/textarea.rb +25 -0
  24. data/lib/generators/lycan_ui/templates/extras/form_builder.rb +90 -0
  25. data/lib/generators/lycan_ui/templates/javascript/accordion_controller.js +46 -0
  26. data/lib/generators/lycan_ui/templates/javascript/avatar_controller.js +34 -0
  27. data/lib/generators/lycan_ui/templates/javascript/collapsible_controller.js +23 -0
  28. data/lib/generators/lycan_ui/templates/javascript/dialog_controller.js +90 -0
  29. data/lib/generators/lycan_ui/templates/javascript/dropdown_controller.js +395 -0
  30. data/lib/generators/lycan_ui/templates/javascript/popover_controller.js +114 -0
  31. data/lib/generators/lycan_ui/templates/setup/application.tailwind.css +94 -0
  32. data/lib/generators/lycan_ui/templates/setup/lycan_ui_helper.rb +39 -0
  33. data/lib/lycan_ui/configuration.rb +32 -0
  34. data/lib/lycan_ui/railtie.rb +6 -0
  35. data/lib/lycan_ui/version.rb +3 -0
  36. data/lib/lycan_ui.rb +8 -0
  37. data/lib/tasks/lycan_ui_tasks.rake +6 -0
  38. metadata +107 -0
@@ -0,0 +1,46 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["item"];
5
+
6
+ focusNext() {
7
+ this.#focusItem(this.#nextIndex)
8
+ }
9
+
10
+ focusPrevious() {
11
+ this.#focusItem(this.#previousIndex)
12
+ }
13
+
14
+ focusFirst() {
15
+ this.#focusItem(0)
16
+ }
17
+
18
+ focusLast() {
19
+ this.#focusItem(this.itemTargets.length - 1);
20
+ }
21
+
22
+ #focusItem(index) {
23
+ this.itemTargets[index].focus();
24
+ }
25
+
26
+ get #nextIndex() {
27
+ const index = this.itemTargets.findIndex((item) => document.activeElement === item);
28
+
29
+ if (index == -1) {
30
+ return 0;
31
+ }
32
+
33
+ return (index + 1) % this.itemTargets.length;
34
+ }
35
+
36
+ get #previousIndex() {
37
+ const index = this.itemTargets.findIndex((item) => document.activeElement === item);
38
+ const length = this.itemTargets.length;
39
+
40
+ if (index == -1) {
41
+ return length - 1;
42
+ }
43
+
44
+ return (index - 1 + length) % length;
45
+ }
46
+ }
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["fallback", "image"];
5
+
6
+ initialize() {
7
+ if (!this.hasImageTarget) {
8
+ this.showFallback()
9
+ return
10
+ }
11
+
12
+ if (!this.imageTarget.complete) {
13
+ this.showFallback()
14
+ return
15
+ }
16
+
17
+ if (this.imageTarget.naturalWidth === 0) {
18
+ this.showFallback()
19
+ return
20
+ }
21
+
22
+ this.hideFallback()
23
+ }
24
+
25
+ showFallback() {
26
+ this.fallbackTarget.hidden = false;
27
+ this.imageTarget.hidden = true;
28
+ }
29
+
30
+ hideFallback() {
31
+ this.imageTarget.hidden = false;
32
+ this.fallbackTarget.hidden = true;
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "content"];
5
+ static values = { open: Boolean };
6
+
7
+ toggle() {
8
+ this.openValue = !this.openValue;
9
+ }
10
+
11
+ open() {
12
+ this.openValue = true;
13
+ }
14
+
15
+ close() {
16
+ this.openValue = false;
17
+ }
18
+
19
+ openValueChanged(open) {
20
+ this.triggerTarget.ariaExpanded = open;
21
+ this.contentTarget.hidden = !open;
22
+ }
23
+ }
@@ -0,0 +1,90 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ open: Boolean,
6
+ frame: String,
7
+ };
8
+ static targets = ["trigger", "content"];
9
+
10
+ connect() {
11
+ if (this.openValue) {
12
+ this.open();
13
+ }
14
+ }
15
+
16
+ open() {
17
+ this.contentTarget.showModal();
18
+
19
+ this.#bindEventListeners();
20
+ }
21
+
22
+ closeOnFormSubmit(event) {
23
+ if (!event.detail.success) return;
24
+
25
+ this.close();
26
+ }
27
+
28
+ close() {
29
+ this.contentTarget.close();
30
+
31
+ this.#removeEventListeners();
32
+ }
33
+
34
+ get isRemote() {
35
+ return Boolean(this.frameValue);
36
+ }
37
+
38
+ #frameRender = ({ detail }) => {
39
+ if (detail.newFrame.id !== this.frameValue) return;
40
+
41
+ detail.render = (_, newFrame) => {
42
+ const selector = `#${this.frameValue} dialog`;
43
+
44
+ window.Turbo.renderStreamMessage(
45
+ `
46
+ <turbo-stream action="update" method="morph" targets="${selector}">
47
+ <template>
48
+ ${newFrame.querySelector("dialog").innerHTML}
49
+ </template>
50
+ </turbo-stream>
51
+ `
52
+ );
53
+ };
54
+ };
55
+
56
+ #clickOutside = ({ clientX, clientY }) => {
57
+ const { top, left, height, width } =
58
+ this.contentTarget.getBoundingClientRect();
59
+
60
+ if (
61
+ top <= clientY &&
62
+ clientY <= top + height &&
63
+ left <= clientX &&
64
+ clientX <= left + width
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ this.close();
70
+ };
71
+
72
+ #bindEventListeners() {
73
+ document.addEventListener("pointerdown", this.#clickOutside);
74
+
75
+ if (!this.isRemote) return;
76
+
77
+ document.addEventListener("turbo:before-frame-render", this.#frameRender);
78
+ }
79
+
80
+ #removeEventListeners() {
81
+ document.removeEventListener("pointerdown", this.#clickOutside);
82
+
83
+ if (!this.isRemote) return;
84
+
85
+ document.removeEventListener(
86
+ "turbo:before-frame-render",
87
+ this.#frameRender
88
+ );
89
+ }
90
+ }
@@ -0,0 +1,395 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import {
3
+ autoUpdate,
4
+ computePosition,
5
+ offset,
6
+ flip,
7
+ shift,
8
+ } from "@floating-ui/dom";
9
+
10
+ export default class extends Controller {
11
+ static values = {
12
+ open: Boolean,
13
+ placement: { type: String, default: "bottom" },
14
+ submenuPlacement: { type: String, default: "right-start" },
15
+ query: String,
16
+ queryReset: { type: Number, default: 500 },
17
+ typeahead: { type: Boolean, default: true },
18
+ };
19
+ static targets = ["trigger", "content", "item", "submenu", "submenuItem"];
20
+
21
+ focusAfterOpen = false;
22
+ searchTimeout = null;
23
+ submenuCleanupFns = {};
24
+
25
+ disconnect() {
26
+ this.close();
27
+ }
28
+
29
+ toggle(event) {
30
+ this.focusAfterOpen = this.openValue && event.detail === 0;
31
+
32
+ if (this.openValue) {
33
+ this.close();
34
+ } else {
35
+ this.open();
36
+ }
37
+ }
38
+
39
+ open() {
40
+ this.openValue = true;
41
+ this.contentTarget.dataset.open = true;
42
+ this.triggerTarget.ariaExpanded = true;
43
+
44
+ this.#bindClickOutsideListeners();
45
+
46
+ const updatePosition = this.#updatePosition(
47
+ this.triggerTarget,
48
+ this.contentTarget,
49
+ this.placementValue
50
+ );
51
+ updatePosition();
52
+
53
+ this.cleanup = autoUpdate(
54
+ this.triggerTarget,
55
+ this.contentTarget,
56
+ updatePosition
57
+ );
58
+
59
+ this.itemTargets.forEach((i) => (i.tabIndex = -1));
60
+
61
+ if (this.focusAfterOpen) {
62
+ requestAnimationFrame(() => {
63
+ requestAnimationFrame(() => {
64
+ this.focusItem({ target: this.itemTargets[0] });
65
+ });
66
+ });
67
+
68
+ this.focusAfterOpen = false;
69
+ }
70
+ }
71
+
72
+ close() {
73
+ this.openValue = false;
74
+ this.contentTarget.dataset.open = false;
75
+ this.triggerTarget.ariaExpanded = false;
76
+
77
+ this.closeAllSubmenus();
78
+
79
+ this.#removeClickOutsideListeners();
80
+
81
+ if (this.cleanup) this.cleanup();
82
+ }
83
+
84
+ focusItem({ target: item, params }) {
85
+ if (!item) return;
86
+ if (this.#itemDisabled(item)) return;
87
+
88
+ if (
89
+ this.hasSubmenuTarget &&
90
+ !this.#isSubmenuCurrentlyOpen(item.dataset.submenu)
91
+ ) {
92
+ this.closeAllSubmenus();
93
+ }
94
+
95
+ if (params?.submenu) {
96
+ this.#submenuItems(params.submenu).forEach((i) => (i.tabIndex = -1));
97
+ } else {
98
+ this.itemTargets.forEach((i) => (i.tabIndex = -1));
99
+ }
100
+
101
+ item.tabIndex = 0;
102
+ item.focus();
103
+ }
104
+
105
+ focusTrigger() {
106
+ this.itemTargets.forEach((i) => (i.tabIndex = -1));
107
+
108
+ this.triggerTarget.focus();
109
+ }
110
+
111
+ focusSubmenuTrigger({ params: { submenu } }) {
112
+ this.#submenuItems(submenu).forEach((i) => (i.tabIndex = -1));
113
+
114
+ const trigger = this.#submenuTrigger(submenu);
115
+
116
+ trigger?.focus();
117
+ }
118
+
119
+ handleKeydown(event) {
120
+ switch (event.key) {
121
+ case "Tab":
122
+ break;
123
+ case "Escape":
124
+ event.preventDefault();
125
+ this.close();
126
+ break;
127
+ case "ArrowUp":
128
+ event.preventDefault();
129
+ this.#focusPrev(this.itemTargets);
130
+ break;
131
+ case "ArrowDown":
132
+ event.preventDefault();
133
+
134
+ if (this.openValue) {
135
+ this.#focusNext(this.itemTargets);
136
+ } else {
137
+ this.focusAfterOpen = true;
138
+ this.open();
139
+ }
140
+ break;
141
+ case "Home":
142
+ event.preventDefault();
143
+ this.focusItem({ target: this.itemTargets[0] });
144
+ break;
145
+ case "End":
146
+ event.preventDefault();
147
+ this.focusItem({
148
+ target: this.itemTargets[this.itemTargets.length - 1],
149
+ });
150
+ break;
151
+ default:
152
+ this.#typeahead(event);
153
+ }
154
+ }
155
+
156
+ submenuHandleKeydown(event) {
157
+ event.stopPropagation();
158
+
159
+ const submenuId = event.params.submenu;
160
+ const itemTargets = this.#submenuItems(submenuId);
161
+
162
+ switch (event.key) {
163
+ case "Tab":
164
+ break;
165
+ case "Escape":
166
+ event.preventDefault();
167
+ this.close();
168
+ break;
169
+ case "ArrowUp":
170
+ event.preventDefault();
171
+ this.#focusPrev(itemTargets);
172
+ break;
173
+ case "ArrowDown":
174
+ event.preventDefault();
175
+ this.#focusNext(itemTargets);
176
+ break;
177
+ case "ArrowLeft":
178
+ event.preventDefault();
179
+ this.closeSubmenu(event);
180
+ this.focusSubmenuTrigger(event);
181
+ break;
182
+ case "Home":
183
+ event.preventDefault();
184
+ this.focusItem({ target: itemTargets[0] });
185
+ break;
186
+ case "End":
187
+ event.preventDefault();
188
+ this.focusItem({
189
+ target: itemTargets[itemTargets.length - 1],
190
+ });
191
+ break;
192
+ }
193
+ }
194
+
195
+ openSubmenu(event) {
196
+ const {
197
+ currentTarget,
198
+ params: { submenu },
199
+ } = event;
200
+
201
+ const menu = this.submenuTargets.find((element) => element.id === submenu);
202
+
203
+ if (!menu) return;
204
+
205
+ if (menu.dataset.open === "true") return;
206
+
207
+ menu.dataset.open = true;
208
+ const updatePosition = this.#updatePosition(
209
+ currentTarget,
210
+ menu,
211
+ this.submenuPlacementValue
212
+ );
213
+
214
+ updatePosition();
215
+ this.submenuCleanupFns[submenu] = autoUpdate(
216
+ this.triggerTarget,
217
+ this.contentTarget,
218
+ updatePosition
219
+ );
220
+
221
+ currentTarget.ariaExpanded = true;
222
+
223
+ const items = this.#submenuItems(submenu);
224
+ items.forEach((item) => (item.tabIndex = -1));
225
+
226
+ if (
227
+ event.detail === 0 &&
228
+ (event.pointerType === undefined || event.pointerType === "")
229
+ ) {
230
+ requestAnimationFrame(() => {
231
+ requestAnimationFrame(() => {
232
+ this.focusItem({ target: items[0] });
233
+ });
234
+ });
235
+ }
236
+ }
237
+
238
+ closeSubmenu({ params: { submenu } }) {
239
+ const element = this.submenuTargets.find((menu) => menu.id === submenu);
240
+
241
+ if (!element) return;
242
+
243
+ this.#closeSubmenuByElement(element);
244
+ }
245
+
246
+ closeAllSubmenus() {
247
+ this.submenuTargets.forEach((element) =>
248
+ this.#closeSubmenuByElement(element)
249
+ );
250
+ }
251
+
252
+ #closeSubmenuByElement(element) {
253
+ element.dataset.open = false;
254
+
255
+ const trigger = this.#submenuTrigger(element.id);
256
+
257
+ trigger?.setAttribute("aria-expanded", false);
258
+
259
+ if (this.submenuCleanupFns[element.id]) {
260
+ this.submenuCleanupFns[element.id]();
261
+ }
262
+ }
263
+
264
+ #itemDisabled(item) {
265
+ return item.disabled || item.getAttribute("aria-disabled") === "true";
266
+ }
267
+
268
+ #typeahead(event) {
269
+ if (!this.typeaheadValue) return;
270
+ if (!this.openValue) return;
271
+ if (event.ctrlKey || event.altKey || event.metaKey) return;
272
+ if (event.key.length !== 1) return;
273
+ if (event.key === " ") return;
274
+
275
+ event.preventDefault();
276
+
277
+ if (this.searchTimeout) clearTimeout(this.searchTimeout);
278
+
279
+ this.queryValue += event.key.toLowerCase();
280
+
281
+ const isRepeated =
282
+ this.queryValue.length > 1 &&
283
+ this.queryValue.split("").every((char) => char === this.queryValue[0]);
284
+ const normalizedQuery = isRepeated ? this.queryValue[0] : this.queryValue;
285
+
286
+ const focusedIndex = this.#focusedItemIndex(this.itemTargets);
287
+ const candidates = this.#candidateItems(this.itemTargets);
288
+ const currentIndex = Math.max(focusedIndex, 0);
289
+ const currentItem = focusedIndex === -1 ? null : candidates[currentIndex];
290
+ let items = candidates.map(
291
+ (_, index, array) => array[(currentIndex + index) % array.length]
292
+ );
293
+
294
+ if (normalizedQuery.length === 1) {
295
+ items = items.filter((item) => item !== currentItem);
296
+ }
297
+
298
+ let nextItem = items.find(
299
+ (item) =>
300
+ item?.innerText &&
301
+ item.innerText.trim().toLowerCase().startsWith(normalizedQuery)
302
+ );
303
+
304
+ if (nextItem !== currentItem) this.focusItem({ target: nextItem });
305
+
306
+ this.searchTimeout = setTimeout(() => {
307
+ this.queryValue = "";
308
+ this.searchTimeout = null;
309
+ }, this.queryResetValue);
310
+ }
311
+
312
+ #focusNext(items) {
313
+ const candidates = this.#candidateItems(items);
314
+ const index = (this.#focusedItemIndex(items) + 1) % candidates.length;
315
+
316
+ this.focusItem({ target: candidates[index] });
317
+ }
318
+
319
+ #focusPrev(items) {
320
+ const candidates = this.#candidateItems(items);
321
+ const length = candidates.length;
322
+ const focused = this.#focusedItemIndex(items);
323
+
324
+ if (focused < 0) {
325
+ this.focusItem({ target: candidates.at(-1) });
326
+
327
+ return;
328
+ }
329
+
330
+ const index = (focused - 1 + length) % length;
331
+ this.focusItem({ target: candidates[index] });
332
+ }
333
+
334
+ #isSubmenuCurrentlyOpen(submenu) {
335
+ if (!submenu) return false;
336
+
337
+ return this.submenuTargets.some(
338
+ (target) => target.id === submenu && target.dataset.open === "true"
339
+ );
340
+ }
341
+
342
+ #submenuTrigger(id) {
343
+ return this.itemTargets.find(
344
+ (trigger) => trigger.getAttribute("aria-controls") === id
345
+ );
346
+ }
347
+
348
+ #submenuItems(id) {
349
+ return this.submenuItemTargets.filter(
350
+ (item) => item.dataset.submenu === id
351
+ );
352
+ }
353
+
354
+ #focusedItemIndex(items) {
355
+ return this.#candidateItems(items).findIndex(
356
+ (item) => document.activeElement === item
357
+ );
358
+ }
359
+
360
+ #candidateItems(items) {
361
+ return items.filter((item) => !this.#itemDisabled(item));
362
+ }
363
+
364
+ #updatePosition = (trigger, content, placement) => () => {
365
+ computePosition(trigger, content, {
366
+ placement,
367
+ middleware: [offset(5), flip(), shift({ padding: 5 })],
368
+ }).then(({ x, y, placement }) => {
369
+ content.dataset.side = placement.split("-")[0];
370
+
371
+ Object.assign(content.style, {
372
+ left: `${x}px`,
373
+ top: `${y}px`,
374
+ });
375
+ });
376
+ };
377
+
378
+ #clickOutside = ({ target }) => {
379
+ if (this.element.contains(target)) return;
380
+
381
+ this.close();
382
+ };
383
+
384
+ #bindClickOutsideListeners() {
385
+ document.addEventListener("click", this.#clickOutside);
386
+ document.addEventListener("touchend", this.#clickOutside);
387
+ document.addEventListener("focusin", this.#clickOutside);
388
+ }
389
+
390
+ #removeClickOutsideListeners() {
391
+ document.removeEventListener("click", this.#clickOutside);
392
+ document.removeEventListener("touchend", this.#clickOutside);
393
+ document.removeEventListener("focusin", this.#clickOutside);
394
+ }
395
+ }
@@ -0,0 +1,114 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import {
3
+ autoUpdate,
4
+ computePosition,
5
+ offset,
6
+ arrow,
7
+ flip,
8
+ shift,
9
+ } from "@floating-ui/dom";
10
+ import * as focusTrap from "focus-trap";
11
+
12
+ export default class extends Controller {
13
+ static targets = ["trigger", "content"];
14
+ static values = {
15
+ open: Boolean,
16
+ placement: { type: String, default: "bottom" },
17
+ offset: { type: Number, default: 8 },
18
+ };
19
+
20
+ connect() {
21
+ this.#updatePosition();
22
+
23
+ this.focusTrap = focusTrap.createFocusTrap(this.contentTarget, {
24
+ fallbackFocus: this.contentTarget,
25
+ setReturnFocus: this.triggerTarget,
26
+ allowOutsideClick: true,
27
+ escapeDeactivates: false,
28
+ clickOutsideDeactivates: false,
29
+ });
30
+ }
31
+
32
+ disconnect() {
33
+ this.close();
34
+ }
35
+
36
+ toggle() {
37
+ if (this.openValue) {
38
+ this.close();
39
+ } else {
40
+ this.open();
41
+ }
42
+ }
43
+
44
+ open() {
45
+ this.openValue = true;
46
+ this.contentTarget.show();
47
+
48
+ this.#updatePosition();
49
+
50
+ this.cleanup = autoUpdate(
51
+ this.triggerTarget,
52
+ this.contentTarget,
53
+ this.#updatePosition
54
+ );
55
+ this.#bindClickOutsideListeners();
56
+
57
+ requestAnimationFrame(() => {
58
+ requestAnimationFrame(() => {
59
+ this.focusTrap.activate();
60
+ });
61
+ });
62
+ }
63
+
64
+ close() {
65
+ this.openValue = false;
66
+ this.contentTarget.close();
67
+ this.focusTrap.deactivate();
68
+
69
+ this.#removeClickOutsideListeners();
70
+ if (this.cleanup) this.cleanup();
71
+ }
72
+
73
+ #updatePosition = () => {
74
+ computePosition(this.triggerTarget, this.contentTarget, {
75
+ placement: this.placementValue,
76
+ middleware: [offset(5), flip(), shift({ padding: 5 })],
77
+ }).then(({ x, y, placement }) => {
78
+ this.contentTarget.dataset.side = placement.split("-")[0];
79
+
80
+ Object.assign(this.contentTarget.style, {
81
+ left: `${x}px`,
82
+ top: `${y}px`,
83
+ });
84
+ });
85
+ };
86
+
87
+ #clickOutside = ({ target }) => {
88
+ if (this.element.contains(target)) return;
89
+
90
+ this.close();
91
+ };
92
+
93
+ #escPressed = (event) => {
94
+ if (event.key !== "Escape") return;
95
+
96
+ event.preventDefault();
97
+
98
+ this.close();
99
+ };
100
+
101
+ #bindClickOutsideListeners() {
102
+ document.addEventListener("click", this.#clickOutside);
103
+ document.addEventListener("touchend", this.#clickOutside);
104
+ document.addEventListener("focusin", this.#clickOutside);
105
+ document.addEventListener("keydown", this.#escPressed);
106
+ }
107
+
108
+ #removeClickOutsideListeners() {
109
+ document.removeEventListener("click", this.#clickOutside);
110
+ document.removeEventListener("touchend", this.#clickOutside);
111
+ document.removeEventListener("focusin", this.#clickOutside);
112
+ document.removeEventListener("keydown", this.#escPressed);
113
+ }
114
+ }