quicksilver_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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/tailwind/alert.css +35 -0
  3. data/app/assets/tailwind/badge.css +27 -0
  4. data/app/assets/tailwind/button.css +35 -0
  5. data/app/assets/tailwind/form.css +35 -0
  6. data/app/assets/tailwind/link.css +23 -0
  7. data/app/assets/tailwind/modal.css +43 -0
  8. data/app/assets/tailwind/quicksilver_ui/engine.css +7 -0
  9. data/app/assets/tailwind/typography.css +112 -0
  10. data/app/helpers/app_form_builder.rb +94 -0
  11. data/app/helpers/app_form_helper.rb +7 -0
  12. data/app/javascript/controllers/autogrow_controller.js +19 -0
  13. data/app/javascript/controllers/dismissable_controller.js +35 -0
  14. data/app/javascript/controllers/dropdown_controller.js +59 -0
  15. data/app/javascript/controllers/modal_controller.js +45 -0
  16. data/app/javascript/controllers/tabs_controller.js +62 -0
  17. data/app/javascript/mixins/use_floating_ui.js +104 -0
  18. data/app/views/form/base_tag.rb +42 -0
  19. data/app/views/form/checkbox.rb +62 -0
  20. data/app/views/form/date_field.rb +11 -0
  21. data/app/views/form/email_field.rb +7 -0
  22. data/app/views/form/error.rb +15 -0
  23. data/app/views/form/file_field.rb +12 -0
  24. data/app/views/form/group.rb +97 -0
  25. data/app/views/form/hint.rb +15 -0
  26. data/app/views/form/input.rb +11 -0
  27. data/app/views/form/label.rb +19 -0
  28. data/app/views/form/password_field.rb +7 -0
  29. data/app/views/form/phone_field.rb +7 -0
  30. data/app/views/form/radio_button.rb +37 -0
  31. data/app/views/form/search_field.rb +7 -0
  32. data/app/views/form/select.rb +46 -0
  33. data/app/views/form/text_field.rb +7 -0
  34. data/app/views/form/textarea.rb +27 -0
  35. data/app/views/form/toggle.rb +35 -0
  36. data/app/views/ui/accordion.rb +67 -0
  37. data/app/views/ui/alert.rb +84 -0
  38. data/app/views/ui/avatar.rb +57 -0
  39. data/app/views/ui/badge.rb +35 -0
  40. data/app/views/ui/base.rb +29 -0
  41. data/app/views/ui/dropdown/item.rb +49 -0
  42. data/app/views/ui/dropdown.rb +111 -0
  43. data/app/views/ui/icon.rb +46 -0
  44. data/app/views/ui/modal.rb +96 -0
  45. data/app/views/ui/toast.rb +90 -0
  46. data/lib/generators/quicksilver_ui/affordance/affordance_generator.rb +102 -0
  47. data/lib/generators/quicksilver_ui/component/all_generator.rb +32 -0
  48. data/lib/generators/quicksilver_ui/component/component_generator.rb +194 -0
  49. data/lib/generators/quicksilver_ui/form/all_generator.rb +32 -0
  50. data/lib/generators/quicksilver_ui/form/form_generator.rb +164 -0
  51. data/lib/generators/quicksilver_ui/form/templates/app_form_builder.rb +39 -0
  52. data/lib/generators/quicksilver_ui/form/templates/app_form_helper.rb +7 -0
  53. data/lib/generators/quicksilver_ui/install/install_generator.rb +42 -0
  54. data/lib/generators/quicksilver_ui/install/templates/base.rb +29 -0
  55. data/lib/generators/quicksilver_ui/install/templates/initializer.rb +16 -0
  56. data/lib/quicksilver_ui/dependencies.rb +191 -0
  57. data/lib/quicksilver_ui/engine.rb +18 -0
  58. data/lib/quicksilver_ui/version.rb +5 -0
  59. data/lib/quicksilver_ui.rb +37 -0
  60. metadata +98 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 98070a613f6cd27f56a14c30d26f7a769f92e3434dcb38ec980d6af9f99ead6b
4
+ data.tar.gz: b54c5beab648c18a73c6a916bacf70c5391c88ec0c1cd483ca84f8ea431b82c8
5
+ SHA512:
6
+ metadata.gz: 634ea19a66852a9bd502eaeaea72edf39fab4c6c1d6b989b51e32d56e0562b8ebfc92dd8d7b226760556730c0c9788b5d290cf1764383f02fdb3c5528fc4cd89
7
+ data.tar.gz: a7ae72ff22c80a1ce740901c6accbe471e05ed59e15cc469ea60b6208d7ad30aed554a246fbc8cc2af3eaa5419f3ee9a80a6fe3782d9d4a188aac47604ae66a5
@@ -0,0 +1,35 @@
1
+ .ui-alert {
2
+ @apply min-w-96 max-w-2xl ring ring-gray-700 bg-white p-3 text-gray-900 inline-flex justify-start items-start gap-3;
3
+ }
4
+
5
+ .ui-alert-neutral {
6
+ @apply ring-gray-700 bg-gray-100;
7
+ }
8
+
9
+ .ui-alert-brand {
10
+ @apply text-primary-950 ring-primary-200 bg-primary-100;
11
+ }
12
+
13
+ .ui-alert-success {
14
+ @apply text-emerald-950 ring-emerald-200 bg-emerald-100;
15
+ }
16
+
17
+ .ui-alert-warning {
18
+ @apply text-amber-950 ring-amber-200 bg-amber-100;
19
+ }
20
+
21
+ .ui-alert-danger {
22
+ @apply text-red-950 ring-red-200 bg-red-100;
23
+ }
24
+
25
+ .ui-alert-info {
26
+ @apply text-blue-950 ring-blue-200 bg-blue-100;
27
+ }
28
+
29
+ .ui-alert-sm {
30
+ @apply p-1.5 gap-1.5;
31
+ }
32
+
33
+ .ui-alert-md {
34
+ @apply p-3 gap-3;
35
+ }
@@ -0,0 +1,27 @@
1
+ .ui-badge {
2
+ @apply inline-flex justify-start items-center min-w-fit max-w-fit gap-1 px-2 py-0.5 text-xs bg-gray-50 rounded-sm ring ring-gray-600;
3
+ }
4
+
5
+ .ui-badge-neutral {
6
+ @apply text-gray-900 bg-gray-50 ring-gray-600;
7
+ }
8
+
9
+ .ui-badge-brand {
10
+ @apply text-primary-900 bg-primary-100 ring-primary-200;
11
+ }
12
+
13
+ .ui-badge-danger {
14
+ @apply text-red-700 bg-red-50 ring-red-700;
15
+ }
16
+
17
+ .ui-badge-sm {
18
+ @apply px-2 py-0.5 text-xs;
19
+ }
20
+
21
+ .ui-badge-md {
22
+ @apply px-2.5 py-0.5 text-sm font-medium leading-5;
23
+ }
24
+
25
+ .ui-badge-lg {
26
+ @apply px-3 py-1 text-sm font-medium leading-5;
27
+ }
@@ -0,0 +1,35 @@
1
+ .ui-button {
2
+ @apply max-w-fit block px-2 py-1 no-underline text-sm font-medium text-gray-950 border border-gray-900 hover:bg-gray-900 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900;
3
+ }
4
+
5
+ .ui-button-primary {
6
+ @apply text-white border-primary-500 bg-primary-500 hover:bg-primary-700 hover:border-primary-700 focus-visible:outline-primary-500;
7
+ }
8
+
9
+ .ui-button-secondary {
10
+ @apply text-white border-secondary-700 bg-secondary-700 hover:bg-secondary-900 hover:border-secondary-900 focus-visible:outline-secondary-500;
11
+ }
12
+
13
+ .ui-button-warning {
14
+ @apply text-amber-950 bg-amber-200 hover:bg-amber-300 active:bg-amber-400 border border-amber-300 hover:border-amber-500 focus-visible:outline-amber-500;
15
+ }
16
+
17
+ .ui-button-danger {
18
+ @apply text-red-500 bg-white active:bg-red-400 border border-red-500 hover:bg-red-500 hover:text-red-50 focus-visible:outline-red-500;
19
+ }
20
+
21
+ .ui-button-lg {
22
+ @apply text-base py-2 px-4;
23
+ }
24
+
25
+ .ui-button-md {
26
+ @apply text-sm py-1 px-4;
27
+ }
28
+
29
+ .ui-button-sm {
30
+ @apply text-xs py-1 px-3;
31
+ }
32
+
33
+ .ui-button-xs {
34
+ @apply text-xs py-0.5 px-2;
35
+ }
@@ -0,0 +1,35 @@
1
+ .ui-form-control {
2
+ @apply ring ring-gray-800 px-2 py-1 rounded-sm max-w-fit focus:outline-2 focus:outline-gray-900;
3
+
4
+ [data-invalid] & {
5
+ @apply text-red-500 ring-red-500 focus:outline-red-500;
6
+ }
7
+ }
8
+
9
+ .ui-form-checkbox {
10
+ @apply size-4 appearance-none border border-gray-800 bg-white checked:bg-gray-900 indeterminate:border-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 focus:outline-gray-900 disabled:border-gray-500 hover:disabled:border-gray-500 disabled:bg-gray-400 disabled:checked:bg-gray-400;
11
+ }
12
+
13
+ .ui-form-radio {
14
+ @apply size-4 appearance-none border border-gray-800 bg-white checked:bg-gray-900 indeterminate:border-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 focus:outline-gray-900 disabled:border-gray-500 hover:disabled:border-gray-500 disabled:bg-gray-400 disabled:checked:bg-gray-400;
15
+ }
16
+
17
+ .ui-form-select {
18
+ @apply ring ring-gray-800 pl-2 pr-6 py-1 rounded-sm max-w-fit focus:outline-2 focus:outline-gray-900;
19
+
20
+ [data-invalid] & {
21
+ @apply text-red-500 ring-red-500 focus:outline-red-500;
22
+ }
23
+ }
24
+
25
+ .ui-form-label {
26
+ @apply font-medium text-sm text-gray-900;
27
+ }
28
+
29
+ .ui-form-hint {
30
+ @apply text-sm text-gray-800;
31
+ }
32
+
33
+ .ui-form-error {
34
+ @apply text-sm font-medium text-red-600;
35
+ }
@@ -0,0 +1,23 @@
1
+ .ui-link {
2
+ @apply underline text-sm text-gray-900 hover:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500;
3
+ }
4
+
5
+ .ui-link-danger {
6
+ @apply text-tertiary-600 hover:text-tertiary-700 active:text-tertiary-800 focus-visible:outline-tertiary-600;
7
+ }
8
+
9
+ .ui-link-primary {
10
+ @apply text-primary-600 hover:text-primary-700 active:text-primary-800 focus-visible:outline-primary-500;
11
+ }
12
+
13
+ .ui-link-lg {
14
+ @apply text-base;
15
+ }
16
+
17
+ .ui-link-md {
18
+ @apply text-sm;
19
+ }
20
+
21
+ .ui-link-sm {
22
+ @apply text-xs;
23
+ }
@@ -0,0 +1,43 @@
1
+ .ui-modal {
2
+ @apply absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 min-w-sm max-w-xl w-full shadow-xl ring ring-gray-300 bg-white backdrop:bg-gray-800/50;
3
+ }
4
+
5
+ dialog.ui-modal:not([open]) {
6
+ @apply hidden;
7
+ }
8
+
9
+ .ui-modal-body {
10
+ @apply p-4;
11
+ }
12
+
13
+ .ui-modal-footer {
14
+ @apply border-t border-gray-300 p-6;
15
+ }
16
+
17
+ @keyframes ui-fade-in {
18
+ 0% {
19
+ opacity: 0;
20
+ }
21
+
22
+ 100% {
23
+ opacity: 1;
24
+ }
25
+ }
26
+
27
+ @keyframes ui-fade-out {
28
+ 0% {
29
+ opacity: 1;
30
+ }
31
+
32
+ 100% {
33
+ opacity: 0;
34
+ }
35
+ }
36
+
37
+ dialog[data-modal-target="dialog"][open] {
38
+ animation: ui-fade-in 200ms forwards;
39
+ }
40
+
41
+ dialog[data-modal-target="dialog"][closing] {
42
+ animation: ui-fade-out 200ms forwards;
43
+ }
@@ -0,0 +1,7 @@
1
+ @import "../typography.css" layer(affordances);
2
+ @import "../button.css" layer(affordances);
3
+ @import "../link.css" layer(affordances);
4
+ @import "../alert.css" layer(affordances);
5
+ @import "../form.css" layer(affordances);
6
+ @import "../badge.css" layer(affordances);
7
+ @import "../modal.css" layer(affordances);
@@ -0,0 +1,112 @@
1
+ /* Typography */
2
+ .ui-display-2xl {
3
+ @apply text-gray-900 text-5xl font-light leading-relaxed tracking-tight;
4
+ }
5
+
6
+ .ui-display-xl {
7
+ @apply text-gray-900 text-4xl font-light leading-relaxed tracking-tight;
8
+ }
9
+
10
+ .ui-display-lg {
11
+ @apply text-gray-900 text-3xl font-light leading-relaxed tracking-tight;
12
+ }
13
+
14
+ .ui-display-md {
15
+ @apply text-gray-900 text-2xl font-light leading-relaxed tracking-tight;
16
+ }
17
+
18
+ .ui-display-sm {
19
+ @apply text-gray-900 text-xl font-light leading-relaxed;
20
+ }
21
+
22
+ .ui-display-xs {
23
+ @apply text-gray-900 text-lg font-light leading-relaxed;
24
+ }
25
+
26
+ /* Heading Typography */
27
+ .ui-heading-2xl {
28
+ @apply text-gray-900 text-4xl font-semibold leading-tight;
29
+ }
30
+
31
+ .ui-heading-xl {
32
+ @apply text-gray-900 text-3xl font-semibold leading-snug;
33
+ }
34
+
35
+ .ui-heading-lg {
36
+ @apply text-gray-900 text-2xl font-semibold leading-snug;
37
+ }
38
+
39
+ .ui-heading-md {
40
+ @apply text-gray-900 text-xl font-semibold leading-snug;
41
+ }
42
+
43
+ .ui-heading-sm {
44
+ @apply text-gray-900 text-lg font-semibold leading-snug;
45
+ }
46
+
47
+ .ui-heading-xs {
48
+ @apply text-gray-900 text-base font-semibold leading-normal;
49
+ }
50
+
51
+ /* Body Typography */
52
+ .ui-text-xl {
53
+ @apply text-gray-900 text-xl leading-7;
54
+ }
55
+
56
+ .ui-text-lg {
57
+ @apply text-gray-900 text-lg leading-7;
58
+ }
59
+
60
+ .ui-text-md {
61
+ @apply text-gray-900 text-base leading-6;
62
+ }
63
+
64
+ .ui-text-sm {
65
+ @apply text-gray-900 text-sm leading-5;
66
+ }
67
+
68
+ .ui-text-xs {
69
+ @apply text-gray-900 text-xs leading-4;
70
+ }
71
+
72
+ /* Body Typography - Medium Weight Variants */
73
+ .ui-text-xl-medium {
74
+ @apply text-gray-900 text-xl font-medium leading-7;
75
+ }
76
+
77
+ .ui-text-lg-medium {
78
+ @apply text-gray-900 text-lg font-medium leading-7;
79
+ }
80
+
81
+ .ui-text-md-medium {
82
+ @apply text-gray-900 text-base font-medium leading-6;
83
+ }
84
+
85
+ .ui-text-sm-medium {
86
+ @apply text-gray-900 text-sm font-medium leading-5;
87
+ }
88
+
89
+ .ui-text-xs-medium {
90
+ @apply text-gray-900 text-xs font-medium leading-4;
91
+ }
92
+
93
+ /* Body Typography - Semibold Weight Variants */
94
+ .ui-text-xl-semibold {
95
+ @apply text-gray-900 text-xl font-semibold leading-7;
96
+ }
97
+
98
+ .ui-text-lg-semibold {
99
+ @apply text-gray-900 text-lg font-semibold leading-7;
100
+ }
101
+
102
+ .ui-text-md-semibold {
103
+ @apply text-gray-900 text-base font-semibold leading-6;
104
+ }
105
+
106
+ .ui-text-sm-semibold {
107
+ @apply text-gray-900 text-sm font-semibold leading-5;
108
+ }
109
+
110
+ .ui-text-xs-semibold {
111
+ @apply text-gray-900 text-xs font-semibold leading-4;
112
+ }
@@ -0,0 +1,94 @@
1
+ class AppFormBuilder < ActionView::Helpers::FormBuilder
2
+ delegate :render, to: :@template
3
+
4
+ def self.with_blank_error_proc(&block)
5
+ old_error_proc = ActionView::Base.field_error_proc
6
+ begin
7
+ ActionView::Base.field_error_proc = proc do |tag, instance|
8
+ tag
9
+ end
10
+ block.call
11
+ ensure
12
+ ActionView::Base.field_error_proc = old_error_proc
13
+ end
14
+ end
15
+
16
+ def group(method, options = {})
17
+ render Form::Group.new(form: self, method:, type: options[:type] || :text, **options.except(:type))
18
+ end
19
+
20
+ def text_field(method, options = {})
21
+ render Form::TextField.new(form: self, method:, **options)
22
+ end
23
+
24
+ def date_field(method, options = {})
25
+ render Form::DateField.new(form: self, method:, **options)
26
+ end
27
+
28
+ def email_field(method, options = {})
29
+ render Form::EmailField.new(form: self, method:, **options)
30
+ end
31
+
32
+ def phone_field(method, options = {})
33
+ render Form::PhoneField.new(form: self, method:, **options)
34
+ end
35
+
36
+ def file_field(method, options = {})
37
+ render Form::FileField.new(form: self, method:, **options)
38
+ end
39
+
40
+ def password_field(method, options = {})
41
+ render Form::PasswordField.new(form: self, method:, **options)
42
+ end
43
+
44
+ def search_field(method, options = {})
45
+ render Form::SearchField.new(form: self, method:, **options)
46
+ end
47
+
48
+ def select(method, choices = nil, options = {}, html_options = {})
49
+ render Form::Select.new(
50
+ form: self,
51
+ method:,
52
+ choices:,
53
+ selected: options[:selected],
54
+ include_blank: options.fetch(:include_blank, true),
55
+ prompt: options[:prompt],
56
+ disabled: options[:disabled],
57
+ include_hidden: options.fetch(:include_hidden, true),
58
+ html_options:
59
+ )
60
+ end
61
+
62
+ def textarea(method, options = {})
63
+ render Form::Textarea.new(form: self, method:, **options)
64
+ end
65
+ alias_method :text_area, :textarea
66
+
67
+ def label(method, text = nil, options = {}, &block)
68
+ render Form::Label.new(form: self, method:, text:, **options)
69
+ end
70
+
71
+ def hint(method, text = nil, options = {}, &block)
72
+ render Form::Hint.new(form: self, method:, text:, **options)
73
+ end
74
+
75
+ def error(method, text = nil, options = {}, &block)
76
+ render Form::Error.new(form: self, method:, text:, **options)
77
+ end
78
+
79
+ def submit(value = nil, options = {})
80
+ super(value, options.with_defaults(class: "ui-button ui-button-primary"))
81
+ end
82
+
83
+ def checkbox(method, options = {})
84
+ render Form::Checkbox.new(form: self, method:, **options)
85
+ end
86
+
87
+ def toggle(method, options = {})
88
+ render Form::Toggle.new(form: self, method:, **options)
89
+ end
90
+
91
+ def radio_button(method, tag_value, options = {})
92
+ render Form::RadioButton.new(form: self, tag_value:, method:, **options)
93
+ end
94
+ end
@@ -0,0 +1,7 @@
1
+ module AppFormHelper
2
+ def app_form_with(*, **, &)
3
+ AppFormBuilder.with_blank_error_proc do
4
+ form_with(*, builder: AppFormBuilder, **, &)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { useResize } from "stimulus-use"
3
+
4
+ export default class extends Controller {
5
+ connect() {
6
+ useResize(this, { element: this.element })
7
+ this.resize()
8
+ }
9
+
10
+ input() {
11
+ this.resize()
12
+ }
13
+
14
+ resize() {
15
+ this.element.style.overflow = "hidden"
16
+ this.element.style.height = "auto"
17
+ this.element.style.height = `${this.element.scrollHeight}px`
18
+ }
19
+ }
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { useTransition } from "stimulus-use"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ autoDismissTime: Number,
7
+ hiddenClass: { type: String, default: "hidden" }
8
+ }
9
+
10
+ initialize() {
11
+ if (this.hasAutoDismissTimeValue) {
12
+ setTimeout(this.dismiss.bind(this), this.autoDismissTimeValue)
13
+ }
14
+ }
15
+
16
+ connect() {
17
+ useTransition(this, {
18
+ element: this.element,
19
+ enterActive: "transition ease-out duration-300",
20
+ enterFrom: "transform opacity-0 scale-95",
21
+ enterTo: "transform opacity-100 scale-100",
22
+ leaveActive: "transition ease-in duration-300",
23
+ leaveFrom: "transform opacity-100 scale-100",
24
+ leaveTo: "transform opacity-0 scale-95",
25
+ hiddenClass: this.hiddenClassValue,
26
+ transitioned: true
27
+ })
28
+ }
29
+
30
+ dismiss() {
31
+ this.leave().then(() => {
32
+ this.element.remove()
33
+ })
34
+ }
35
+ }
@@ -0,0 +1,59 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { useTransition } from "stimulus-use"
3
+ import { useFloatingUI } from "mixins/use_floating_ui"
4
+ import { offset, flip, shift } from "@floating-ui/dom"
5
+
6
+ export default class extends Controller {
7
+ static targets = ["trigger", "menu"]
8
+ static values = {
9
+ placement: { type: String, default: "bottom-start" },
10
+ shiftPadding: { type: Number, default: 8 },
11
+ offset: { type: Number, default: 4 }
12
+ }
13
+
14
+ connect() {
15
+ useTransition(this, {
16
+ element: this.menuTarget,
17
+ hiddenClass: "hidden",
18
+ })
19
+ this.actualTrigger = this.hasTriggerTarget ? this.triggerTarget : this.element
20
+
21
+ useFloatingUI(this, this.actualTrigger, this.menuTarget, this.positioningOptions)
22
+ }
23
+
24
+ toggle() {
25
+ if (this.menuTarget.classList.contains("hidden")) {
26
+ this.show()
27
+ } else {
28
+ this.hide()
29
+ }
30
+ }
31
+
32
+ show() {
33
+ this.enter()
34
+ this.showWithPositioning()
35
+ }
36
+
37
+ hide(event) {
38
+ // If event is provided, check if click is outside dropdown
39
+ if (event && this.element.contains(event.target)) {
40
+ return
41
+ }
42
+
43
+ this.leave()
44
+ this.hideWithPositioning()
45
+ }
46
+
47
+ // Private
48
+
49
+ get positioningOptions() {
50
+ return {
51
+ placement: this.placementValue,
52
+ middleware: [
53
+ offset(this.offsetValue),
54
+ flip(),
55
+ shift({ padding: this.shiftPaddingValue })
56
+ ]
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["dialog"]
5
+ static values = { open: Boolean }
6
+
7
+ initialize() {
8
+ this.forceClose = this.forceClose.bind(this)
9
+ }
10
+
11
+ connect() {
12
+ document.addEventListener("turbo:before-render", this.forceClose)
13
+
14
+ if (this.openValue) {
15
+ this.open()
16
+ }
17
+ }
18
+
19
+ disconnect() {
20
+ document.removeEventListener("turbo:before-render", this.forceClose)
21
+ }
22
+
23
+ open() {
24
+ this.dialogTarget.showModal()
25
+ }
26
+
27
+ close() {
28
+ this.dialogTarget.setAttribute("closing", "")
29
+
30
+ Promise.all(this.dialogTarget.getAnimations().map((animation) => animation.finished)).then(() => {
31
+ this.dialogTarget.removeAttribute("closing")
32
+ this.dialogTarget.close()
33
+ })
34
+ }
35
+
36
+ backdropClose(event) {
37
+ if (event.target === this.dialogTarget) {
38
+ this.close()
39
+ }
40
+ }
41
+
42
+ forceClose() {
43
+ this.dialogTarget.close()
44
+ }
45
+ }
@@ -0,0 +1,62 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tab", "panel"]
5
+ static values = { active: { type: String, default: "preview" } }
6
+
7
+ connect() {
8
+ this.show()
9
+ }
10
+
11
+ switch(event) {
12
+ this.activeValue = event.params.tab
13
+ }
14
+
15
+ keydown(event) {
16
+ const tabs = this.tabTargets
17
+ const index = tabs.indexOf(event.currentTarget)
18
+ let newIndex
19
+
20
+ switch (event.key) {
21
+ case "ArrowRight":
22
+ newIndex = (index + 1) % tabs.length
23
+ break
24
+ case "ArrowLeft":
25
+ newIndex = (index - 1 + tabs.length) % tabs.length
26
+ break
27
+ case "Home":
28
+ newIndex = 0
29
+ break
30
+ case "End":
31
+ newIndex = tabs.length - 1
32
+ break
33
+ default:
34
+ return
35
+ }
36
+
37
+ event.preventDefault()
38
+ this.activeValue = tabs[newIndex].dataset.tabsTabParam
39
+ tabs[newIndex].focus()
40
+ }
41
+
42
+ activeValueChanged() {
43
+ this.show()
44
+ }
45
+
46
+ show() {
47
+ this.tabTargets.forEach(tab => {
48
+ const isActive = tab.dataset.tabsTabParam === this.activeValue
49
+ tab.classList.toggle("bg-gray-900", isActive)
50
+ tab.classList.toggle("border-gray-900", isActive)
51
+ tab.classList.toggle("text-white", isActive)
52
+ tab.classList.toggle("border-transparent", !isActive)
53
+ tab.setAttribute("aria-selected", isActive)
54
+ tab.setAttribute("tabindex", isActive ? "0" : "-1")
55
+ })
56
+
57
+ this.panelTargets.forEach(panel => {
58
+ const isActive = panel.dataset.tab === this.activeValue
59
+ panel.classList.toggle("hidden", !isActive)
60
+ })
61
+ }
62
+ }