shadcn-phlex 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 (113) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +195 -0
  3. data/app.css +20 -0
  4. data/css/shadcn-source.css +3 -0
  5. data/css/shadcn-tailwind.css +160 -0
  6. data/css/themes/mauve.css +62 -0
  7. data/css/themes/mist.css +62 -0
  8. data/css/themes/neutral.css +74 -0
  9. data/css/themes/olive.css +62 -0
  10. data/css/themes/stone.css +62 -0
  11. data/css/themes/taupe.css +62 -0
  12. data/css/themes/zinc.css +62 -0
  13. data/js/controllers/accordion_controller.js +135 -0
  14. data/js/controllers/checkbox_controller.js +52 -0
  15. data/js/controllers/collapsible_controller.js +85 -0
  16. data/js/controllers/combobox_controller.js +168 -0
  17. data/js/controllers/command_controller.js +171 -0
  18. data/js/controllers/context_menu_controller.js +132 -0
  19. data/js/controllers/dark_mode_controller.js +106 -0
  20. data/js/controllers/dialog_controller.js +205 -0
  21. data/js/controllers/drawer_controller.js +161 -0
  22. data/js/controllers/dropdown_menu_controller.js +189 -0
  23. data/js/controllers/hover_card_controller.js +85 -0
  24. data/js/controllers/index.js +89 -0
  25. data/js/controllers/menubar_controller.js +171 -0
  26. data/js/controllers/navigation_menu_controller.js +160 -0
  27. data/js/controllers/popover_controller.js +151 -0
  28. data/js/controllers/radio_group_controller.js +78 -0
  29. data/js/controllers/scroll_area_controller.js +117 -0
  30. data/js/controllers/select_controller.js +198 -0
  31. data/js/controllers/sheet_controller.js +130 -0
  32. data/js/controllers/slider_controller.js +142 -0
  33. data/js/controllers/switch_controller.js +40 -0
  34. data/js/controllers/tabs_controller.js +96 -0
  35. data/js/controllers/toast_controller.js +206 -0
  36. data/js/controllers/toggle_controller.js +30 -0
  37. data/js/controllers/toggle_group_controller.js +73 -0
  38. data/js/controllers/tooltip_controller.js +146 -0
  39. data/lib/generators/shadcn_phlex/component_generator.rb +79 -0
  40. data/lib/generators/shadcn_phlex/install_generator.rb +217 -0
  41. data/lib/shadcn/base.rb +27 -0
  42. data/lib/shadcn/engine.rb +24 -0
  43. data/lib/shadcn/kit.rb +1158 -0
  44. data/lib/shadcn/themes/accent_colors.rb +106 -0
  45. data/lib/shadcn/themes/base_colors.rb +313 -0
  46. data/lib/shadcn/ui/accordion.rb +135 -0
  47. data/lib/shadcn/ui/alert.rb +79 -0
  48. data/lib/shadcn/ui/alert_dialog.rb +220 -0
  49. data/lib/shadcn/ui/aspect_ratio.rb +35 -0
  50. data/lib/shadcn/ui/avatar.rb +134 -0
  51. data/lib/shadcn/ui/badge.rb +48 -0
  52. data/lib/shadcn/ui/breadcrumb.rb +180 -0
  53. data/lib/shadcn/ui/button.rb +63 -0
  54. data/lib/shadcn/ui/button_group.rb +58 -0
  55. data/lib/shadcn/ui/card.rb +133 -0
  56. data/lib/shadcn/ui/checkbox.rb +72 -0
  57. data/lib/shadcn/ui/collapsible.rb +76 -0
  58. data/lib/shadcn/ui/combobox.rb +229 -0
  59. data/lib/shadcn/ui/command.rb +256 -0
  60. data/lib/shadcn/ui/context_menu.rb +319 -0
  61. data/lib/shadcn/ui/dialog.rb +226 -0
  62. data/lib/shadcn/ui/direction.rb +23 -0
  63. data/lib/shadcn/ui/drawer.rb +217 -0
  64. data/lib/shadcn/ui/dropdown_menu.rb +384 -0
  65. data/lib/shadcn/ui/empty.rb +97 -0
  66. data/lib/shadcn/ui/field.rb +126 -0
  67. data/lib/shadcn/ui/hover_card.rb +75 -0
  68. data/lib/shadcn/ui/input.rb +36 -0
  69. data/lib/shadcn/ui/input_group.rb +32 -0
  70. data/lib/shadcn/ui/input_otp.rb +112 -0
  71. data/lib/shadcn/ui/item.rb +115 -0
  72. data/lib/shadcn/ui/kbd.rb +45 -0
  73. data/lib/shadcn/ui/label.rb +28 -0
  74. data/lib/shadcn/ui/menubar.rb +345 -0
  75. data/lib/shadcn/ui/native_select.rb +31 -0
  76. data/lib/shadcn/ui/navigation_menu.rb +238 -0
  77. data/lib/shadcn/ui/pagination.rb +224 -0
  78. data/lib/shadcn/ui/popover.rb +147 -0
  79. data/lib/shadcn/ui/progress.rb +40 -0
  80. data/lib/shadcn/ui/radio_group.rb +92 -0
  81. data/lib/shadcn/ui/resizable.rb +108 -0
  82. data/lib/shadcn/ui/scroll_area.rb +75 -0
  83. data/lib/shadcn/ui/select.rb +235 -0
  84. data/lib/shadcn/ui/separator.rb +36 -0
  85. data/lib/shadcn/ui/sheet.rb +231 -0
  86. data/lib/shadcn/ui/sidebar.rb +420 -0
  87. data/lib/shadcn/ui/skeleton.rb +23 -0
  88. data/lib/shadcn/ui/slider.rb +72 -0
  89. data/lib/shadcn/ui/sonner.rb +177 -0
  90. data/lib/shadcn/ui/spinner.rb +58 -0
  91. data/lib/shadcn/ui/switch.rb +75 -0
  92. data/lib/shadcn/ui/table.rb +154 -0
  93. data/lib/shadcn/ui/tabs.rb +154 -0
  94. data/lib/shadcn/ui/text_field.rb +146 -0
  95. data/lib/shadcn/ui/textarea.rb +32 -0
  96. data/lib/shadcn/ui/theme_toggle.rb +74 -0
  97. data/lib/shadcn/ui/toggle.rb +66 -0
  98. data/lib/shadcn/ui/toggle_group.rb +75 -0
  99. data/lib/shadcn/ui/tooltip.rb +78 -0
  100. data/lib/shadcn/ui/typography.rb +217 -0
  101. data/lib/shadcn/version.rb +5 -0
  102. data/lib/shadcn-phlex.rb +6 -0
  103. data/lib/shadcn.rb +80 -0
  104. data/package.json +14 -0
  105. data/skills/shadcn-phlex/SKILL.md +190 -0
  106. data/skills/shadcn-phlex/evals/evals.json +90 -0
  107. data/skills/shadcn-phlex/references/component-catalog.md +355 -0
  108. data/skills/shadcn-phlex/rules/composition.md +235 -0
  109. data/skills/shadcn-phlex/rules/forms.md +151 -0
  110. data/skills/shadcn-phlex/rules/helpers.md +54 -0
  111. data/skills/shadcn-phlex/rules/stimulus.md +61 -0
  112. data/skills/shadcn-phlex/rules/styling.md +177 -0
  113. metadata +209 -0
@@ -0,0 +1,62 @@
1
+ /*
2
+ * shadcn theme: stone
3
+ * Compatible with ui.shadcn.com/themes — paste any theme here to swap.
4
+ */
5
+ :root {
6
+ --radius: 0.625rem;
7
+ --background: oklch(1 0 75);
8
+ --foreground: oklch(0.147 0.004 49.25);
9
+ --card: oklch(1 0 75);
10
+ --card-foreground: oklch(0.147 0.004 49.25);
11
+ --popover: oklch(1 0 75);
12
+ --popover-foreground: oklch(0.147 0.004 49.25);
13
+ --primary: oklch(0.216 0.006 56.043);
14
+ --primary-foreground: oklch(0.985 0.001 106.423);
15
+ --secondary: oklch(0.97 0.001 106.424);
16
+ --secondary-foreground: oklch(0.216 0.006 56.043);
17
+ --muted: oklch(0.97 0.001 106.424);
18
+ --muted-foreground: oklch(0.553 0.013 58.071);
19
+ --accent: oklch(0.97 0.001 106.424);
20
+ --accent-foreground: oklch(0.216 0.006 56.043);
21
+ --destructive: oklch(0.577 0.245 27.325);
22
+ --border: oklch(0.923 0.003 48.717);
23
+ --input: oklch(0.923 0.003 48.717);
24
+ --ring: oklch(0.709 0.01 56.259);
25
+ --sidebar: oklch(0.985 0.001 106.423);
26
+ --sidebar-foreground: oklch(0.147 0.004 49.25);
27
+ --sidebar-primary: oklch(0.216 0.006 56.043);
28
+ --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
29
+ --sidebar-accent: oklch(0.97 0.001 106.424);
30
+ --sidebar-accent-foreground: oklch(0.216 0.006 56.043);
31
+ --sidebar-border: oklch(0.923 0.003 48.717);
32
+ --sidebar-ring: oklch(0.709 0.01 56.259);
33
+ }
34
+
35
+ .dark {
36
+ --background: oklch(0.147 0.004 49.25);
37
+ --foreground: oklch(0.985 0.001 106.423);
38
+ --card: oklch(0.216 0.006 56.043);
39
+ --card-foreground: oklch(0.985 0.001 106.423);
40
+ --popover: oklch(0.216 0.006 56.043);
41
+ --popover-foreground: oklch(0.985 0.001 106.423);
42
+ --primary: oklch(0.923 0.003 48.717);
43
+ --primary-foreground: oklch(0.216 0.006 56.043);
44
+ --secondary: oklch(0.268 0.007 34.298);
45
+ --secondary-foreground: oklch(0.985 0.001 106.423);
46
+ --muted: oklch(0.268 0.007 34.298);
47
+ --muted-foreground: oklch(0.709 0.01 56.259);
48
+ --accent: oklch(0.268 0.007 34.298);
49
+ --accent-foreground: oklch(0.985 0.001 106.423);
50
+ --destructive: oklch(0.704 0.191 22.216);
51
+ --border: oklch(1 0 0 / 10%);
52
+ --input: oklch(1 0 0 / 15%);
53
+ --ring: oklch(0.553 0.013 58.071);
54
+ --sidebar: oklch(0.216 0.006 56.043);
55
+ --sidebar-foreground: oklch(0.985 0.001 106.423);
56
+ --sidebar-primary: oklch(0.488 0.243 264.376);
57
+ --sidebar-primary-foreground: oklch(0.985 0 0);
58
+ --sidebar-accent: oklch(0.268 0.007 34.298);
59
+ --sidebar-accent-foreground: oklch(0.985 0.001 106.423);
60
+ --sidebar-border: oklch(1 0 0 / 10%);
61
+ --sidebar-ring: oklch(0.553 0.013 58.071);
62
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * shadcn theme: taupe
3
+ * Compatible with ui.shadcn.com/themes — paste any theme here to swap.
4
+ */
5
+ :root {
6
+ --radius: 0.625rem;
7
+ --background: oklch(1 0 0);
8
+ --foreground: oklch(0.15 0.008 75);
9
+ --card: oklch(1 0 0);
10
+ --card-foreground: oklch(0.15 0.008 75);
11
+ --popover: oklch(1 0 0);
12
+ --popover-foreground: oklch(0.15 0.008 75);
13
+ --primary: oklch(0.218 0.012 75);
14
+ --primary-foreground: oklch(0.985 0.002 75);
15
+ --secondary: oklch(0.968 0.003 75);
16
+ --secondary-foreground: oklch(0.218 0.012 75);
17
+ --muted: oklch(0.968 0.003 75);
18
+ --muted-foreground: oklch(0.556 0.016 75);
19
+ --accent: oklch(0.968 0.003 75);
20
+ --accent-foreground: oklch(0.218 0.012 75);
21
+ --destructive: oklch(0.577 0.245 27.325);
22
+ --border: oklch(0.923 0.005 75);
23
+ --input: oklch(0.923 0.005 75);
24
+ --ring: oklch(0.708 0.014 75);
25
+ --sidebar: oklch(0.985 0.002 75);
26
+ --sidebar-foreground: oklch(0.15 0.008 75);
27
+ --sidebar-primary: oklch(0.218 0.012 75);
28
+ --sidebar-primary-foreground: oklch(0.985 0.002 75);
29
+ --sidebar-accent: oklch(0.968 0.003 75);
30
+ --sidebar-accent-foreground: oklch(0.218 0.012 75);
31
+ --sidebar-border: oklch(0.923 0.005 75);
32
+ --sidebar-ring: oklch(0.708 0.014 75);
33
+ }
34
+
35
+ .dark {
36
+ --background: oklch(0.15 0.008 75);
37
+ --foreground: oklch(0.985 0.002 75);
38
+ --card: oklch(0.218 0.012 75);
39
+ --card-foreground: oklch(0.985 0.002 75);
40
+ --popover: oklch(0.218 0.012 75);
41
+ --popover-foreground: oklch(0.985 0.002 75);
42
+ --primary: oklch(0.923 0.005 75);
43
+ --primary-foreground: oklch(0.218 0.012 75);
44
+ --secondary: oklch(0.274 0.012 75);
45
+ --secondary-foreground: oklch(0.985 0.002 75);
46
+ --muted: oklch(0.274 0.012 75);
47
+ --muted-foreground: oklch(0.708 0.014 75);
48
+ --accent: oklch(0.274 0.012 75);
49
+ --accent-foreground: oklch(0.985 0.002 75);
50
+ --destructive: oklch(0.704 0.191 22.216);
51
+ --border: oklch(1 0 0 / 10%);
52
+ --input: oklch(1 0 0 / 15%);
53
+ --ring: oklch(0.556 0.016 75);
54
+ --sidebar: oklch(0.218 0.012 75);
55
+ --sidebar-foreground: oklch(0.985 0.002 75);
56
+ --sidebar-primary: oklch(0.488 0.243 264.376);
57
+ --sidebar-primary-foreground: oklch(0.985 0 0);
58
+ --sidebar-accent: oklch(0.274 0.012 75);
59
+ --sidebar-accent-foreground: oklch(0.985 0.002 75);
60
+ --sidebar-border: oklch(1 0 0 / 10%);
61
+ --sidebar-ring: oklch(0.556 0.016 75);
62
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * shadcn theme: zinc
3
+ * Compatible with ui.shadcn.com/themes — paste any theme here to swap.
4
+ */
5
+ :root {
6
+ --radius: 0.625rem;
7
+ --background: oklch(1 0 0);
8
+ --foreground: oklch(0.141 0.005 285.823);
9
+ --card: oklch(1 0 0);
10
+ --card-foreground: oklch(0.141 0.005 285.823);
11
+ --popover: oklch(1 0 0);
12
+ --popover-foreground: oklch(0.141 0.005 285.823);
13
+ --primary: oklch(0.21 0.006 285.885);
14
+ --primary-foreground: oklch(0.985 0.002 247.839);
15
+ --secondary: oklch(0.967 0.001 264.542);
16
+ --secondary-foreground: oklch(0.21 0.006 285.885);
17
+ --muted: oklch(0.967 0.001 264.542);
18
+ --muted-foreground: oklch(0.552 0.016 285.938);
19
+ --accent: oklch(0.967 0.001 264.542);
20
+ --accent-foreground: oklch(0.21 0.006 285.885);
21
+ --destructive: oklch(0.577 0.245 27.325);
22
+ --border: oklch(0.92 0.004 264.532);
23
+ --input: oklch(0.92 0.004 264.532);
24
+ --ring: oklch(0.705 0.015 286.067);
25
+ --sidebar: oklch(0.985 0 0);
26
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
27
+ --sidebar-primary: oklch(0.21 0.006 285.885);
28
+ --sidebar-primary-foreground: oklch(0.985 0.002 247.839);
29
+ --sidebar-accent: oklch(0.967 0.001 264.542);
30
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
31
+ --sidebar-border: oklch(0.92 0.004 264.532);
32
+ --sidebar-ring: oklch(0.705 0.015 286.067);
33
+ }
34
+
35
+ .dark {
36
+ --background: oklch(0.141 0.005 285.823);
37
+ --foreground: oklch(0.985 0.002 247.839);
38
+ --card: oklch(0.21 0.006 285.885);
39
+ --card-foreground: oklch(0.985 0.002 247.839);
40
+ --popover: oklch(0.21 0.006 285.885);
41
+ --popover-foreground: oklch(0.985 0.002 247.839);
42
+ --primary: oklch(0.92 0.004 264.532);
43
+ --primary-foreground: oklch(0.21 0.006 285.885);
44
+ --secondary: oklch(0.274 0.006 286.033);
45
+ --secondary-foreground: oklch(0.985 0.002 247.839);
46
+ --muted: oklch(0.274 0.006 286.033);
47
+ --muted-foreground: oklch(0.705 0.015 286.067);
48
+ --accent: oklch(0.274 0.006 286.033);
49
+ --accent-foreground: oklch(0.985 0.002 247.839);
50
+ --destructive: oklch(0.704 0.191 22.216);
51
+ --border: oklch(1 0 0 / 10%);
52
+ --input: oklch(1 0 0 / 15%);
53
+ --ring: oklch(0.552 0.016 285.938);
54
+ --sidebar: oklch(0.21 0.006 285.885);
55
+ --sidebar-foreground: oklch(0.985 0.002 247.839);
56
+ --sidebar-primary: oklch(0.488 0.243 264.376);
57
+ --sidebar-primary-foreground: oklch(0.985 0 0);
58
+ --sidebar-accent: oklch(0.274 0.006 286.033);
59
+ --sidebar-accent-foreground: oklch(0.985 0.002 247.839);
60
+ --sidebar-border: oklch(1 0 0 / 10%);
61
+ --sidebar-ring: oklch(0.552 0.016 285.938);
62
+ }
@@ -0,0 +1,135 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Accordion behavior
4
+ // Supports single (default) or multiple open items
5
+ // data-type="single" | "multiple"
6
+ // data-collapsible="true" allows closing all items in single mode
7
+ export default class extends Controller {
8
+ static targets = ["item", "trigger", "content"]
9
+ static values = {
10
+ type: { type: String, default: "single" },
11
+ collapsible: { type: Boolean, default: false },
12
+ }
13
+
14
+ connect() {
15
+ this.itemTargets.forEach((item) => {
16
+ const content = item.querySelector('[data-slot="accordion-content"]')
17
+ if (content && item.dataset.state !== "open") {
18
+ content.hidden = true
19
+ content.dataset.state = "closed"
20
+ } else if (content && item.dataset.state === "open") {
21
+ content.hidden = false
22
+ content.dataset.state = "open"
23
+ }
24
+ })
25
+ }
26
+
27
+ toggle(event) {
28
+ const trigger = event.currentTarget
29
+ const item = trigger.closest('[data-slot="accordion-item"]')
30
+ if (!item) return
31
+
32
+ const isOpen = item.dataset.state === "open"
33
+
34
+ if (this.typeValue === "single") {
35
+ // Close all other items
36
+ this.itemTargets.forEach((otherItem) => {
37
+ if (otherItem !== item && otherItem.dataset.state === "open") {
38
+ this._closeItem(otherItem)
39
+ }
40
+ })
41
+ }
42
+
43
+ if (isOpen && (this.collapsibleValue || this.typeValue === "multiple")) {
44
+ this._closeItem(item)
45
+ } else if (!isOpen) {
46
+ this._openItem(item)
47
+ }
48
+ }
49
+
50
+ keydown(event) {
51
+ const triggers = this.triggerTargets
52
+ const index = triggers.indexOf(event.currentTarget)
53
+ let nextIndex
54
+
55
+ switch (event.key) {
56
+ case "ArrowDown":
57
+ event.preventDefault()
58
+ nextIndex = (index + 1) % triggers.length
59
+ triggers[nextIndex].focus()
60
+ break
61
+ case "ArrowUp":
62
+ event.preventDefault()
63
+ nextIndex = (index - 1 + triggers.length) % triggers.length
64
+ triggers[nextIndex].focus()
65
+ break
66
+ case "Home":
67
+ event.preventDefault()
68
+ triggers[0].focus()
69
+ break
70
+ case "End":
71
+ event.preventDefault()
72
+ triggers[triggers.length - 1].focus()
73
+ break
74
+ }
75
+ }
76
+
77
+ _openItem(item) {
78
+ const content = item.querySelector('[data-slot="accordion-content"]')
79
+ const trigger = item.querySelector('[data-slot="accordion-trigger"]')
80
+ if (!content) return
81
+
82
+ item.dataset.state = "open"
83
+ if (trigger) {
84
+ trigger.dataset.state = "open"
85
+ trigger.setAttribute("aria-expanded", "true")
86
+ }
87
+ content.dataset.state = "open"
88
+ content.hidden = false
89
+
90
+ // Animate open
91
+ const height = content.scrollHeight
92
+ content.style.height = "0px"
93
+ content.style.overflow = "hidden"
94
+ requestAnimationFrame(() => {
95
+ content.style.transition = "height 200ms ease-out"
96
+ content.style.height = `${height}px`
97
+ const onEnd = () => {
98
+ content.style.height = ""
99
+ content.style.overflow = ""
100
+ content.style.transition = ""
101
+ content.removeEventListener("transitionend", onEnd)
102
+ }
103
+ content.addEventListener("transitionend", onEnd)
104
+ })
105
+ }
106
+
107
+ _closeItem(item) {
108
+ const content = item.querySelector('[data-slot="accordion-content"]')
109
+ const trigger = item.querySelector('[data-slot="accordion-trigger"]')
110
+ if (!content) return
111
+
112
+ // Animate close
113
+ const height = content.scrollHeight
114
+ content.style.height = `${height}px`
115
+ content.style.overflow = "hidden"
116
+ requestAnimationFrame(() => {
117
+ content.style.transition = "height 200ms ease-out"
118
+ content.style.height = "0px"
119
+ const onEnd = () => {
120
+ content.hidden = true
121
+ content.style.height = ""
122
+ content.style.overflow = ""
123
+ content.style.transition = ""
124
+ item.dataset.state = "closed"
125
+ if (trigger) {
126
+ trigger.dataset.state = "closed"
127
+ trigger.setAttribute("aria-expanded", "false")
128
+ }
129
+ content.dataset.state = "closed"
130
+ content.removeEventListener("transitionend", onEnd)
131
+ }
132
+ content.addEventListener("transitionend", onEnd)
133
+ })
134
+ }
135
+ }
@@ -0,0 +1,52 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Checkbox behavior
4
+ // Toggle checked/unchecked/indeterminate, updates hidden input for forms
5
+ export default class extends Controller {
6
+ static targets = ["button", "indicator", "input"]
7
+ static values = {
8
+ checked: { type: String, default: "false" }, // "true", "false", "indeterminate"
9
+ disabled: { type: Boolean, default: false },
10
+ name: String,
11
+ }
12
+
13
+ connect() {
14
+ this._syncState()
15
+ }
16
+
17
+ toggle() {
18
+ if (this.disabledValue) return
19
+
20
+ if (this.checkedValue === "true") {
21
+ this.checkedValue = "false"
22
+ } else {
23
+ this.checkedValue = "true"
24
+ }
25
+
26
+ this.dispatch("change", { detail: { checked: this.checkedValue === "true" } })
27
+ }
28
+
29
+ checkedValueChanged() { this._syncState() }
30
+
31
+ _syncState() {
32
+ const isChecked = this.checkedValue === "true"
33
+ const isIndeterminate = this.checkedValue === "indeterminate"
34
+ const state = isIndeterminate ? "indeterminate" : isChecked ? "checked" : "unchecked"
35
+
36
+ this.buttonTargets.forEach((btn) => {
37
+ btn.dataset.state = state
38
+ btn.setAttribute("aria-checked", isIndeterminate ? "mixed" : String(isChecked))
39
+ btn.setAttribute("data-disabled", String(this.disabledValue))
40
+ })
41
+
42
+ this.indicatorTargets.forEach((ind) => {
43
+ ind.hidden = !isChecked && !isIndeterminate
44
+ })
45
+
46
+ // Update hidden input for form submission
47
+ this.inputTargets.forEach((input) => {
48
+ input.value = isChecked ? "1" : "0"
49
+ input.checked = isChecked
50
+ })
51
+ }
52
+ }
@@ -0,0 +1,85 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Collapsible behavior
4
+ export default class extends Controller {
5
+ static targets = ["trigger", "content"]
6
+ static values = {
7
+ open: { type: Boolean, default: false },
8
+ }
9
+
10
+ connect() {
11
+ this._syncState()
12
+ }
13
+
14
+ toggle() {
15
+ this.openValue = !this.openValue
16
+ }
17
+
18
+ open() {
19
+ this.openValue = true
20
+ }
21
+
22
+ close() {
23
+ this.openValue = false
24
+ }
25
+
26
+ openValueChanged() {
27
+ this._syncState()
28
+ }
29
+
30
+ _syncState() {
31
+ const state = this.openValue ? "open" : "closed"
32
+ this.element.dataset.state = state
33
+
34
+ this.triggerTargets.forEach((trigger) => {
35
+ trigger.dataset.state = state
36
+ trigger.setAttribute("aria-expanded", String(this.openValue))
37
+ })
38
+
39
+ this.contentTargets.forEach((content) => {
40
+ if (this.openValue) {
41
+ this._animateOpen(content)
42
+ } else {
43
+ this._animateClose(content)
44
+ }
45
+ })
46
+ }
47
+
48
+ _animateOpen(content) {
49
+ content.hidden = false
50
+ content.dataset.state = "open"
51
+ const height = content.scrollHeight
52
+ content.style.height = "0px"
53
+ content.style.overflow = "hidden"
54
+ requestAnimationFrame(() => {
55
+ content.style.transition = "height 200ms ease-out"
56
+ content.style.height = `${height}px`
57
+ const onEnd = () => {
58
+ content.style.height = ""
59
+ content.style.overflow = ""
60
+ content.style.transition = ""
61
+ content.removeEventListener("transitionend", onEnd)
62
+ }
63
+ content.addEventListener("transitionend", onEnd)
64
+ })
65
+ }
66
+
67
+ _animateClose(content) {
68
+ const height = content.scrollHeight
69
+ content.dataset.state = "closed"
70
+ content.style.height = `${height}px`
71
+ content.style.overflow = "hidden"
72
+ requestAnimationFrame(() => {
73
+ content.style.transition = "height 200ms ease-out"
74
+ content.style.height = "0px"
75
+ const onEnd = () => {
76
+ content.hidden = true
77
+ content.style.height = ""
78
+ content.style.overflow = ""
79
+ content.style.transition = ""
80
+ content.removeEventListener("transitionend", onEnd)
81
+ }
82
+ content.addEventListener("transitionend", onEnd)
83
+ })
84
+ }
85
+ }
@@ -0,0 +1,168 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Combobox: filterable select with search input
4
+ // Uses Stimulus declarative click@window for outside click (no flicker)
5
+ export default class extends Controller {
6
+ static targets = ["input", "content", "item", "empty", "hiddenInput", "value"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ value: { type: String, default: "" },
10
+ placeholder: { type: String, default: "Search..." },
11
+ emptyText: { type: String, default: "No results found." },
12
+ }
13
+
14
+ connect() {
15
+ this._hideTimeouts = []
16
+ this._allItems = this.itemTargets.map((el) => ({
17
+ element: el,
18
+ value: el.dataset.value || "",
19
+ label: el.textContent.trim().toLowerCase(),
20
+ }))
21
+ this.contentTargets.forEach((el) => { el.dataset.state = "closed"; el.hidden = true })
22
+ }
23
+
24
+ disconnect() {
25
+ this._hideTimeouts.forEach(id => clearTimeout(id))
26
+ this._hideTimeouts = []
27
+ }
28
+
29
+ toggle() { this.openValue = !this.openValue }
30
+
31
+ // Wired as click@window->shadcn--combobox#hide
32
+ hide(event) {
33
+ if (!this.openValue) return
34
+ if (event && event.target && this.element.contains(event.target)) return
35
+ this.openValue = false
36
+ }
37
+
38
+ // Wired as keydown.esc@window->shadcn--combobox#hideOnEscape
39
+ hideOnEscape() {
40
+ if (!this.openValue) return
41
+ this.openValue = false
42
+ }
43
+
44
+ close() { this.openValue = false }
45
+
46
+ filter(event) {
47
+ const query = event.currentTarget.value.toLowerCase()
48
+ let visibleCount = 0
49
+
50
+ this._allItems.forEach(({ element, label }) => {
51
+ const match = label.includes(query)
52
+ element.hidden = !match
53
+ if (match) visibleCount++
54
+ })
55
+
56
+ this.emptyTargets.forEach((el) => { el.hidden = visibleCount > 0 })
57
+ }
58
+
59
+ selectItem(event) {
60
+ const item = event.currentTarget
61
+ if (item.dataset.disabled) return
62
+
63
+ const value = item.dataset.value
64
+ const label = item.textContent.trim()
65
+
66
+ this.valueValue = value
67
+ this.valueTargets.forEach((el) => { el.textContent = label })
68
+ this.inputTargets.forEach((el) => { el.value = "" })
69
+
70
+ this.dispatch("change", { detail: { value, label } })
71
+ this.openValue = false
72
+ }
73
+
74
+ keydown(event) {
75
+ if (!this.openValue && (event.key === "ArrowDown" || event.key === "Enter")) {
76
+ event.preventDefault()
77
+ this.openValue = true
78
+ return
79
+ }
80
+ if (event.key === "Escape") { this.openValue = false; return }
81
+
82
+ const items = this._getVisibleItems()
83
+ const current = items.indexOf(document.activeElement)
84
+
85
+ switch (event.key) {
86
+ case "ArrowDown":
87
+ event.preventDefault()
88
+ if (current < items.length - 1) items[current + 1]?.focus()
89
+ else if (current === -1 && items.length > 0) items[0].focus()
90
+ break
91
+ case "ArrowUp":
92
+ event.preventDefault()
93
+ if (current > 0) items[current - 1]?.focus()
94
+ else if (this.hasInputTarget) this.inputTarget.focus()
95
+ break
96
+ case "Enter":
97
+ if (document.activeElement?.matches('[data-slot*="item"]')) {
98
+ event.preventDefault()
99
+ document.activeElement.click()
100
+ }
101
+ break
102
+ }
103
+ }
104
+
105
+ openValueChanged() {
106
+ if (!this._hideTimeouts) return
107
+ this._render()
108
+ }
109
+
110
+ valueValueChanged() { this._syncValueState() }
111
+
112
+ _render() {
113
+ const open = this.openValue
114
+
115
+ this._hideTimeouts.forEach(id => clearTimeout(id))
116
+ this._hideTimeouts = []
117
+
118
+ this.contentTargets.forEach((el) => {
119
+ if (open) {
120
+ el.getAnimations().forEach(a => a.cancel())
121
+ el.hidden = false
122
+ el.dataset.state = "open"
123
+ requestAnimationFrame(() => {
124
+ this._position(el)
125
+ if (this.hasInputTarget) this.inputTarget.focus()
126
+ })
127
+ } else {
128
+ el.dataset.state = "closed"
129
+ // Reset filter
130
+ this._allItems.forEach(({ element }) => { element.hidden = false })
131
+ this.emptyTargets.forEach((em) => { em.hidden = true })
132
+
133
+ const animations = el.getAnimations()
134
+ if (animations.length > 0) {
135
+ Promise.all(animations.map(a => a.finished)).then(() => {
136
+ if (el.dataset.state === "closed") el.hidden = true
137
+ }).catch(() => {})
138
+ } else {
139
+ el.hidden = true
140
+ }
141
+ }
142
+ })
143
+ }
144
+
145
+ _syncValueState() {
146
+ this.itemTargets.forEach((item) => {
147
+ const isSelected = item.dataset.value === this.valueValue
148
+ item.dataset.state = isSelected ? "checked" : "unchecked"
149
+ item.setAttribute("aria-selected", String(isSelected))
150
+ })
151
+ this.hiddenInputTargets.forEach((input) => { input.value = this.valueValue })
152
+ }
153
+
154
+ _position(content) {
155
+ // Use the trigger for positioning, not the root (which expands when content is visible)
156
+ const trigger = this.element.querySelector('[data-slot="combobox-trigger"]') || this.element
157
+ const rect = trigger.getBoundingClientRect()
158
+ content.style.position = "fixed"
159
+ content.style.zIndex = "50"
160
+ content.style.width = `${Math.max(rect.width, 200)}px`
161
+ content.style.top = `${rect.bottom + 4}px`
162
+ content.style.left = `${rect.left}px`
163
+ }
164
+
165
+ _getVisibleItems() {
166
+ return this.itemTargets.filter((el) => !el.hidden && !el.dataset.disabled)
167
+ }
168
+ }