view_primitives 0.1.3 → 0.2.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +57 -2
  4. data/lib/generators/view_primitives/add/add_generator.rb +8 -62
  5. data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +30 -11
  6. data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +1 -1
  7. data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +9 -9
  8. data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +1 -1
  9. data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +1 -1
  10. data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +8 -4
  11. data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +1 -1
  12. data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +6 -6
  13. data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +11 -4
  14. data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +2 -2
  15. data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +8 -5
  16. data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +5 -5
  17. data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +18 -16
  18. data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +1 -1
  19. data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +1 -1
  20. data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +26 -13
  21. data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +10 -4
  22. data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +26 -3
  23. data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +4 -4
  24. data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +1 -1
  25. data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +12 -5
  26. data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +3 -6
  27. data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +22 -18
  28. data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
  29. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +9 -8
  30. data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +60 -29
  31. data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +2 -2
  32. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +8 -8
  33. data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +94 -21
  34. data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +13 -10
  35. data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +52 -0
  36. data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +8 -7
  37. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +5 -6
  38. data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +2 -2
  39. data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +1 -1
  40. data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +3 -12
  41. data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +1 -1
  42. data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +5 -4
  43. data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +18 -5
  44. data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +3 -3
  45. data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +1 -1
  46. data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +6 -5
  47. data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +6 -4
  48. data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +1 -1
  49. data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +5 -4
  50. data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +2 -13
  51. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +22 -10
  52. data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +3 -1
  53. data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +6 -2
  54. data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +6 -4
  55. data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +3 -2
  56. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +9 -9
  57. data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +5 -5
  58. data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +4 -5
  59. data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +51 -11
  60. data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +8 -3
  61. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +12 -16
  62. data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +4 -11
  63. data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +4 -3
  64. data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +2 -1
  65. data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +1 -2
  66. data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +3 -1
  67. data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +1 -1
  68. data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +8 -5
  69. data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +2 -3
  70. data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +1 -1
  71. data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +1 -1
  72. data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +4 -3
  73. data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +27 -15
  74. data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +10 -11
  75. data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +2 -11
  76. data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +25 -6
  77. data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +6 -3
  78. data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +25 -21
  79. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +27 -21
  80. data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +1 -1
  81. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +8 -9
  82. data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +15 -6
  83. data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +17 -16
  84. data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +27 -14
  85. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +13 -7
  86. data/lib/generators/view_primitives/add/templates/tags_input/tags_input_component.rb.tt +136 -0
  87. data/lib/generators/view_primitives/add/templates/tags_input/tags_input_controller.js +90 -0
  88. data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +2 -11
  89. data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +9 -7
  90. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +19 -15
  91. data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +10 -10
  92. data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +6 -6
  93. data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +10 -3
  94. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +6 -6
  95. data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +7 -6
  96. data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +1 -1
  97. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +9 -3
  98. data/lib/generators/view_primitives/component_copier.rb +96 -0
  99. data/lib/generators/view_primitives/components.rb +16 -2
  100. data/lib/generators/view_primitives/install/install_generator.rb +13 -3
  101. data/lib/generators/view_primitives/install/templates/application_component.rb.tt +7 -0
  102. data/lib/generators/view_primitives/install/templates/styles.rb.tt +26 -0
  103. data/lib/generators/view_primitives/install/templates/view_primitives/themes/default.css +79 -0
  104. data/lib/generators/view_primitives/install/templates/view_primitives/themes/rose.css +57 -0
  105. data/lib/generators/view_primitives/install/templates/view_primitives/tokens.css +46 -0
  106. data/lib/generators/view_primitives/install/templates/view_primitives/utilities.css +64 -0
  107. data/lib/generators/view_primitives/install/templates/view_primitives.css +6 -66
  108. data/lib/generators/view_primitives/list/list_generator.rb +3 -1
  109. data/lib/generators/view_primitives/theme/theme_generator.rb +79 -0
  110. data/lib/generators/view_primitives/update/update_generator.rb +112 -0
  111. data/lib/view_primitives/class_helper.rb +4 -1
  112. data/lib/view_primitives/railtie.rb +1 -1
  113. data/lib/view_primitives/version.rb +1 -1
  114. metadata +12 -4
  115. data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +0 -15
  116. data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +0 -15
@@ -2,24 +2,62 @@
2
2
 
3
3
  module UI
4
4
  class DeviceMockupComponent < ApplicationComponent
5
+ # Shell holds aspect ratio and hardware controls; bezel clips the screen inset.
6
+ PHONE_SHELL = "relative mx-auto w-full max-w-[280px] aspect-[393/852]"
7
+
8
+ PHONE_BEZEL = "absolute inset-0 overflow-hidden rounded-[3rem] bg-gradient-to-b from-zinc-600 via-zinc-800 to-zinc-900 " \
9
+ "shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] ring-1 ring-inset ring-white/15"
10
+
11
+ PHONE_SCREEN = "absolute inset-[10px] flex flex-col overflow-hidden rounded-[2.45rem] bg-black"
12
+
13
+ DYNAMIC_ISLAND = "pointer-events-none absolute left-1/2 top-[10px] z-20 h-[26px] w-[92px] " \
14
+ "-translate-x-1/2 rounded-full bg-zinc-950 " \
15
+ "shadow-[0_0_0_1px_rgba(255,255,255,0.14),inset_0_1px_0_rgba(255,255,255,0.08)]"
16
+
17
+ HOME_INDICATOR = "pointer-events-none absolute bottom-[7px] left-1/2 z-20 h-1 w-[112px] " \
18
+ "-translate-x-1/2 rounded-full bg-white/40"
19
+
20
+ PHONE_BUTTONS = [
21
+ "pointer-events-none absolute -left-px top-[24%] z-30 h-7 w-[3px] rounded-l-sm bg-zinc-500/90",
22
+ "pointer-events-none absolute -left-px top-[31%] z-30 h-11 w-[3px] rounded-l-sm bg-zinc-500/90",
23
+ "pointer-events-none absolute -left-px top-[40%] z-30 h-11 w-[3px] rounded-l-sm bg-zinc-500/90",
24
+ "pointer-events-none absolute -right-px top-[33%] z-30 h-14 w-[3px] rounded-r-sm bg-zinc-500/90"
25
+ ].freeze
26
+
27
+ TABLET_SHELL = "relative mx-auto w-full max-w-[640px] aspect-[3/2]"
28
+
29
+ TABLET_BEZEL = "absolute inset-0 overflow-hidden rounded-[1.5rem] bg-gradient-to-b from-zinc-600 via-zinc-800 to-zinc-900 " \
30
+ "shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] ring-1 ring-inset ring-white/15"
31
+
32
+ TABLET_SCREEN = "absolute inset-3 flex flex-col overflow-hidden rounded-[0.85rem] bg-black"
33
+
34
+ TABLET_CAMERA = "pointer-events-none absolute left-1/2 top-[6px] z-30 size-[7px] " \
35
+ "-translate-x-1/2 rounded-full bg-zinc-950 " \
36
+ "shadow-[0_0_0_1px_rgba(255,255,255,0.1),inset_0_0_2px_rgba(0,0,0,0.9)]"
37
+
38
+ SCREEN_CONTENT = "relative flex min-h-0 flex-1 flex-col"
39
+
5
40
  VARIANTS = {
6
41
  phone: {
7
- outer: "relative mx-auto h-[600px] w-[300px] rounded-[2.5rem] border-[14px] " \
8
- "border-foreground bg-foreground shadow-xl",
9
- screen: "relative h-full w-full overflow-hidden rounded-[2rem] bg-white dark:bg-zinc-900",
10
- notch: "absolute left-1/2 top-0 z-10 h-6 w-28 -translate-x-1/2 rounded-b-2xl bg-foreground"
42
+ shell: PHONE_SHELL,
43
+ bezel: PHONE_BEZEL,
44
+ screen: PHONE_SCREEN,
45
+ side_controls: PHONE_BUTTONS,
46
+ overlays: [DYNAMIC_ISLAND, HOME_INDICATOR]
11
47
  },
12
48
  browser: {
13
- outer: "relative mx-auto overflow-hidden rounded-xl border border-border bg-background shadow-xl",
14
- bar: "flex h-10 items-center gap-2 border-b border-border bg-muted px-4",
49
+ outer: "relative mx-auto flex w-full max-w-3xl flex-col overflow-hidden rounded-lg " \
50
+ "#{UI::Styles::BORDER} bg-background shadow-md",
51
+ bar: "flex h-10 shrink-0 items-center gap-2 border-b border-border bg-muted/50 px-4",
15
52
  dots: "flex gap-1.5",
16
- screen: "overflow-hidden bg-white dark:bg-zinc-900"
53
+ screen: "relative w-full min-h-48 overflow-hidden bg-background"
17
54
  },
18
55
  tablet: {
19
- outer: "relative mx-auto h-[500px] w-[700px] rounded-[1.75rem] border-[12px] " \
20
- "border-foreground bg-foreground shadow-xl",
21
- screen: "relative h-full w-full overflow-hidden rounded-[1.25rem] bg-white dark:bg-zinc-900",
22
- notch: nil
56
+ shell: TABLET_SHELL,
57
+ bezel: TABLET_BEZEL,
58
+ screen: TABLET_SCREEN,
59
+ bezel_overlays: [TABLET_CAMERA],
60
+ overlays: [HOME_INDICATOR]
23
61
  }
24
62
  }.freeze
25
63
 
@@ -35,29 +73,64 @@ module UI
35
73
  def call
36
74
  cfg = VARIANTS.fetch(@variant, VARIANTS[:phone])
37
75
 
38
- content_tag(:div, class: cn(cfg[:outer], @extra_class), **@html_attrs) do
39
- if @variant == :browser
40
- concat browser_bar(cfg)
41
- concat content_tag(:div, content, class: cfg[:screen])
42
- else
43
- concat content_tag(:div, nil, class: cfg[:notch]) if cfg[:notch]
44
- concat content_tag(:div, content, class: cfg[:screen])
45
- end
76
+ if @variant == :browser
77
+ browser_mockup(cfg)
78
+ else
79
+ device_mockup(cfg)
46
80
  end
47
81
  end
48
82
 
49
83
  private
50
84
 
85
+ def device_mockup(cfg)
86
+ content_tag(:div, class: cn(cfg[:shell], @extra_class), **@html_attrs) do
87
+ render_side_controls(cfg)
88
+ concat(content_tag(:div, class: cfg[:bezel]) do
89
+ render_bezel_overlays(cfg)
90
+ concat device_screen(cfg)
91
+ end)
92
+ end
93
+ end
94
+
95
+ def browser_mockup(cfg)
96
+ content_tag(:div, class: cn(cfg[:outer], @extra_class), **@html_attrs) do
97
+ concat browser_bar(cfg)
98
+ concat content_tag(:div, content, class: cfg[:screen])
99
+ end
100
+ end
101
+
102
+ def render_side_controls(cfg)
103
+ Array(cfg[:side_controls]).each do |cls|
104
+ concat content_tag(:div, nil, class: cls, role: "presentation")
105
+ end
106
+ end
107
+
108
+ def render_bezel_overlays(cfg)
109
+ Array(cfg[:bezel_overlays]).each do |cls|
110
+ concat content_tag(:div, nil, class: cls, role: "presentation")
111
+ end
112
+ end
113
+
114
+ def device_screen(cfg)
115
+ content_tag(:div, class: cfg[:screen]) do
116
+ Array(cfg[:overlays]).each do |cls|
117
+ concat content_tag(:div, nil, class: cls, role: "presentation")
118
+ end
119
+ concat content_tag(:div, class: SCREEN_CONTENT) { concat content }
120
+ end
121
+ end
122
+
51
123
  def browser_bar(cfg)
52
124
  content_tag(:div, class: cfg[:bar]) do
53
125
  concat(content_tag(:div, class: cfg[:dots]) {
54
- %w[bg-red-400 bg-yellow-400 bg-green-400].each do |color|
126
+ %w[bg-[#FF5F57] bg-[#FFBD2E] bg-[#28CA42]].each do |color|
55
127
  concat content_tag(:div, nil, class: "size-3 rounded-full #{color}")
56
128
  end
57
129
  })
58
130
  if @url
59
131
  concat content_tag(:div, @url,
60
- class: "ml-4 flex-1 truncate rounded-md bg-background px-3 py-1 text-xs text-muted-foreground")
132
+ class: "ml-4 flex-1 truncate rounded-md border border-input bg-transparent px-3 py-1 " \
133
+ "text-xs text-muted-foreground shadow-xs dark:bg-input/30")
61
134
  end
62
135
  end
63
136
  end
@@ -5,10 +5,13 @@ module UI
5
5
  renders_one :trigger
6
6
  renders_one :footer
7
7
 
8
- OVERLAY = "fixed inset-0 z-50 bg-black/50"
9
- PANEL = "fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
8
+ OVERLAY = UI::Styles::OVERLAY
9
+ PANEL = "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
10
10
  "translate-x-[-50%] translate-y-[-50%] gap-4 " \
11
- "rounded-lg border bg-background p-6 shadow-lg outline-none sm:max-w-lg"
11
+ "rounded-lg #{UI::Styles::BORDER} bg-background p-6 shadow-lg duration-200 outline-none sm:max-w-lg"
12
+ CLOSE_BTN = "absolute top-4 right-4 z-10 rounded-xs opacity-70 ring-offset-background transition-opacity " \
13
+ "hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden " \
14
+ "disabled:pointer-events-none"
12
15
 
13
16
  def initialize(title: nil, description: nil, **html_attrs)
14
17
  @title = title
@@ -40,8 +43,8 @@ module UI
40
43
  data: { action: "keydown.escape@window->dialog#close" }) {
41
44
  concat close_button
42
45
  concat header_area
43
- concat content_tag(:div, content, class: "py-1 text-sm text-foreground")
44
- concat content_tag(:div, footer, class: "mt-6 flex justify-end gap-2") if footer
46
+ concat content_tag(:div, content) unless content.blank?
47
+ concat content_tag(:div, footer, class: "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end") if footer
45
48
  }
46
49
  end
47
50
  end
@@ -49,9 +52,9 @@ module UI
49
52
  def header_area
50
53
  return "" if @title.nil? && @description.nil?
51
54
 
52
- content_tag(:div, class: "mb-4 pr-6") do
53
- concat content_tag(:h2, @title, class: "text-lg font-semibold leading-none tracking-tight") if @title
54
- concat content_tag(:p, @description, class: "mt-2 text-sm text-muted-foreground") if @description
55
+ content_tag(:div, class: "flex flex-col gap-2 text-center sm:text-left") do
56
+ concat content_tag(:h2, @title, class: "text-lg leading-none font-semibold") if @title
57
+ concat content_tag(:p, @description, class: "text-sm text-muted-foreground") if @description
55
58
  end
56
59
  end
57
60
 
@@ -59,13 +62,13 @@ module UI
59
62
  content_tag(:button,
60
63
  close_svg,
61
64
  type: "button",
62
- class: "absolute right-4 top-4 rounded-sm p-1 opacity-70 hover:opacity-100 transition-opacity",
65
+ class: CLOSE_BTN,
63
66
  data: { action: "click->dialog#close" },
64
67
  "aria-label": "Close")
65
68
  end
66
69
 
67
70
  def close_svg
68
- raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>')
71
+ raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>')
69
72
  end
70
73
  end
71
74
  end
@@ -6,10 +6,62 @@ export default class extends Controller {
6
6
  open() {
7
7
  this.panelTarget.hidden = false
8
8
  document.body.style.overflow = "hidden"
9
+
10
+ this._previouslyFocused = document.activeElement
11
+ this._dialog = this.panelTarget.querySelector("[role=dialog], [role=alertdialog]")
12
+ this._trapFocus = this.#trapFocus.bind(this)
13
+ this._dialog?.addEventListener("keydown", this._trapFocus)
14
+
15
+ const focusable = this.#focusableElements(this._dialog)
16
+ if (focusable.length > 0) {
17
+ focusable[0].focus()
18
+ } else {
19
+ this._dialog?.focus()
20
+ }
9
21
  }
10
22
 
11
23
  close() {
12
24
  this.panelTarget.hidden = true
13
25
  document.body.style.overflow = ""
26
+
27
+ this._dialog?.removeEventListener("keydown", this._trapFocus)
28
+ this._previouslyFocused?.focus?.()
29
+ this._previouslyFocused = null
30
+ this._dialog = null
31
+ }
32
+
33
+ #trapFocus(event) {
34
+ if (event.key !== "Tab" || !this._dialog) return
35
+
36
+ const focusable = this.#focusableElements(this._dialog)
37
+ if (focusable.length === 0) return
38
+
39
+ const first = focusable[0]
40
+ const last = focusable[focusable.length - 1]
41
+
42
+ if (event.shiftKey && document.activeElement === first) {
43
+ event.preventDefault()
44
+ last.focus()
45
+ } else if (!event.shiftKey && document.activeElement === last) {
46
+ event.preventDefault()
47
+ first.focus()
48
+ }
49
+ }
50
+
51
+ #focusableElements(container) {
52
+ if (!container) return []
53
+
54
+ const selector = [
55
+ "a[href]",
56
+ "button:not([disabled])",
57
+ "input:not([disabled])",
58
+ "select:not([disabled])",
59
+ "textarea:not([disabled])",
60
+ "[tabindex]:not([tabindex='-1'])",
61
+ ].join(", ")
62
+
63
+ return Array.from(container.querySelectorAll(selector)).filter(
64
+ (el) => !el.hasAttribute("hidden") && el.offsetParent !== null
65
+ )
14
66
  }
15
67
  }
@@ -5,8 +5,9 @@ module UI
5
5
  renders_one :trigger
6
6
  renders_one :footer
7
7
 
8
- OVERLAY = "fixed inset-0 z-50 bg-black/50"
9
- PANEL = "fixed inset-x-0 bottom-0 z-50 rounded-t-xl border-t bg-background shadow-xl overflow-y-auto"
8
+ OVERLAY = UI::Styles::OVERLAY
9
+ PANEL = "fixed inset-x-0 bottom-0 z-50 flex h-auto max-h-[80vh] flex-col " \
10
+ "rounded-t-lg border-t border-border bg-background shadow-lg overflow-y-auto"
10
11
 
11
12
  def initialize(title: nil, description: nil, **html_attrs)
12
13
  @title = title
@@ -16,8 +17,8 @@ module UI
16
17
  end
17
18
 
18
19
  def call
19
- content_tag(:div, data: { controller: "drawer" }, **@html_attrs) do
20
- concat content_tag(:span, trigger, data: { action: "click->drawer#open" }, class: "contents") if trigger
20
+ content_tag(:div, data: { controller: "dialog" }, **@html_attrs) do
21
+ concat content_tag(:span, trigger, data: { action: "click->dialog#open" }, class: "contents") if trigger
21
22
  concat panel
22
23
  end
23
24
  end
@@ -25,17 +26,17 @@ module UI
25
26
  private
26
27
 
27
28
  def panel
28
- content_tag(:div, data: { drawer_target: "panel" }, hidden: true) do
29
+ content_tag(:div, data: { dialog_target: "panel" }, hidden: true) do
29
30
  concat content_tag(:div, nil,
30
31
  class: OVERLAY,
31
- data: { action: "click->drawer#close" },
32
+ data: { action: "click->dialog#close" },
32
33
  "aria-hidden": "true")
33
34
  concat content_tag(:div,
34
35
  class: cn(PANEL, @extra_class),
35
36
  role: "dialog",
36
37
  "aria-modal": "true",
37
38
  "aria-label": @title,
38
- data: { action: "keydown.escape@window->drawer#close" }) {
39
+ data: { action: "keydown.escape@window->dialog#close" }) {
39
40
  concat drag_handle
40
41
  concat header_area
41
42
  concat content_tag(:div, content, class: "px-4 pb-6 text-sm")
@@ -4,20 +4,19 @@ module UI
4
4
  class DropdownMenuComponent < ApplicationComponent
5
5
  renders_one :trigger
6
6
 
7
- PANEL = "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 " \
8
- "text-popover-foreground shadow-md"
7
+ PANEL = "#{UI::Styles::POPOVER_PANEL} min-w-[8rem] overflow-x-hidden overflow-y-auto p-1"
9
8
 
10
9
  ALIGN = {
11
10
  start: "top-full left-0 mt-1",
12
11
  end: "top-full right-0 mt-1",
13
12
  }.freeze
14
13
 
15
- ITEM = "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm " \
16
- "px-2 py-1.5 text-sm outline-none " \
17
- "hover:bg-accent hover:text-accent-foreground " \
14
+ ITEM = "#{UI::Styles::MENU_ITEM} w-full hover:bg-accent hover:text-accent-foreground " \
15
+ "data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 " \
16
+ "dark:data-[variant=destructive]:focus:bg-destructive/20 " \
18
17
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
19
18
  "[&_svg:not([class*='text-'])]:text-muted-foreground"
20
- SEPARATOR = "-mx-1 my-1 h-px bg-border"
19
+ SEPARATOR = UI::Styles::MENU_SEPARATOR
21
20
  LABEL_CLS = "px-2 py-1.5 text-sm font-medium"
22
21
 
23
22
  def initialize(align: :start, **html_attrs)
@@ -48,8 +48,8 @@ module UI
48
48
  /yandex\.(ru|com)\/maps/i => :yandex_maps
49
49
  }.freeze
50
50
 
51
- WRAPPER_CLS = "overflow-hidden rounded-md"
52
- DARK_WRAPPER_CLS = "overflow-hidden rounded-md bg-black"
51
+ WRAPPER_CLS = "overflow-hidden rounded-md #{UI::Styles::BORDER} bg-card shadow-xs"
52
+ DARK_WRAPPER_CLS = "overflow-hidden rounded-md #{UI::Styles::BORDER} bg-black shadow-xs"
53
53
 
54
54
  def self.detect_provider(url)
55
55
  DOMAIN_MAP.each { |pattern, provider| return provider if url.to_s.match?(pattern) }
@@ -2,7 +2,7 @@
2
2
 
3
3
  module UI
4
4
  class FigureComponent < ApplicationComponent
5
- CAPTION = "mt-2 text-sm text-muted-foreground"
5
+ CAPTION = "mt-4 text-sm text-muted-foreground"
6
6
 
7
7
  # caption: text shown in <figcaption> (optional; omit to render none)
8
8
  # caption_class: override the figcaption classes
@@ -2,27 +2,18 @@
2
2
 
3
3
  module UI
4
4
  class FileInputComponent < ApplicationComponent
5
- BASE = "h-9 w-full min-w-0 cursor-pointer rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs " \
6
- "transition-[color,box-shadow] outline-none " \
7
- "file:mr-3 file:inline-flex file:h-7 file:cursor-pointer file:border-0 " \
8
- "file:bg-transparent file:text-sm file:font-medium file:text-foreground " \
9
- "placeholder:text-muted-foreground " \
10
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
11
- "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
12
- "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
13
- "md:text-sm dark:bg-input/30"
5
+ FILE_EXTRA = "cursor-pointer file:mr-3 file:cursor-pointer"
14
6
 
15
7
  # accept: MIME types or extensions, e.g. "image/*" or ".pdf,.docx"
16
8
  # multiple: allow selecting multiple files
17
9
  def initialize(accept: nil, multiple: false, **html_attrs)
18
10
  @accept = accept
19
11
  @multiple = multiple
20
- @extra_class = html_attrs.delete(:class)
21
- @html_attrs = html_attrs
12
+ extract_html_attrs(**html_attrs)
22
13
  end
23
14
 
24
15
  def call
25
- attrs = { type: "file", class: cn(BASE, @extra_class) }
16
+ attrs = { type: "file", class: cn(UI::Styles::INPUT, FILE_EXTRA, @extra_class) }
26
17
  attrs[:accept] = @accept if @accept
27
18
  attrs[:multiple] = true if @multiple
28
19
  content_tag(:input, nil, **attrs, **@html_attrs)
@@ -8,7 +8,7 @@ module UI
8
8
  # then rises above when the input is focused or has a value (:not(:placeholder-shown)).
9
9
  INPUT_BASE = "peer h-12 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pb-1.5 pt-4 " \
10
10
  "text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-transparent " \
11
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
11
+ "#{UI::Styles::FOCUS_RING} " \
12
12
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
13
13
  "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
14
14
  "md:text-sm dark:bg-input/30"
@@ -2,8 +2,9 @@
2
2
 
3
3
  module UI
4
4
  class FooterComponent < ApplicationComponent
5
- BASE = "border-t bg-background"
6
- LINK = "text-sm text-muted-foreground hover:text-foreground transition-colors"
5
+ BASE = "border-t border-border bg-background"
6
+ LINK = "inline-flex rounded-md px-1 py-0.5 text-sm text-muted-foreground transition-colors " \
7
+ "outline-none hover:text-foreground #{UI::Styles::FOCUS_RING}"
7
8
 
8
9
  # columns: [{ title:, links: [{ label:, href: }] }]
9
10
  def initialize(copyright: nil, columns: [], **html_attrs)
@@ -33,7 +34,7 @@ module UI
33
34
 
34
35
  def column(col)
35
36
  content_tag(:div) do
36
- concat content_tag(:h3, col[:title], class: "mb-3 text-sm font-semibold text-foreground")
37
+ concat content_tag(:h3, col[:title], class: "mb-3 text-sm font-medium text-foreground")
37
38
  concat content_tag(:ul, class: "space-y-2") {
38
39
  safe_join((col[:links] || []).map { |link|
39
40
  content_tag(:li) { content_tag(:a, link[:label], href: link[:href], class: LINK) }
@@ -43,7 +44,7 @@ module UI
43
44
  end
44
45
 
45
46
  def copyright_row
46
- content_tag(:div, class: "border-t pt-8 mt-8 text-center") do
47
+ content_tag(:div, class: "mt-8 border-t border-border pt-8 text-center") do
47
48
  content_tag(:p, @copyright, class: "text-sm text-muted-foreground")
48
49
  end
49
50
  end
@@ -8,6 +8,8 @@ module UI
8
8
  # <%%= ui :input, type: "email", name: "user[email]", id: "user_email" %>
9
9
  # <%% end %>
10
10
  class FormFieldComponent < ApplicationComponent
11
+ WRAPPER = "group/field flex w-full flex-col gap-3"
12
+
11
13
  def initialize(label: nil, hint: nil, error: nil, required: false, **html_attrs)
12
14
  @label = label
13
15
  @hint = hint
@@ -18,9 +20,13 @@ module UI
18
20
  end
19
21
 
20
22
  def call
21
- content_tag(:div, class: cn("space-y-1.5", @extra_class), **@html_attrs) do
23
+ content_tag(:div,
24
+ class: cn(WRAPPER, (@error.present? ? "data-[invalid=true]" : nil), @extra_class),
25
+ "data-slot": "field",
26
+ "data-invalid": @error.present?.to_s,
27
+ **@html_attrs) do
22
28
  concat field_label if @label
23
- concat content
29
+ concat content_tag(:div, content, class: "flex flex-col gap-1.5 leading-snug", "data-slot": "field-content")
24
30
  concat hint_tag if @hint && @error.blank?
25
31
  concat error_tag if @error.present?
26
32
  end
@@ -31,7 +37,9 @@ module UI
31
37
  def field_label
32
38
  content_tag(:label,
33
39
  label_text,
34
- class: "text-sm font-medium leading-none")
40
+ class: "flex items-center gap-2 text-sm leading-none font-medium select-none " \
41
+ "group-data-[disabled=true]/field:opacity-50",
42
+ "data-slot": "field-label")
35
43
  end
36
44
 
37
45
  def label_text
@@ -41,11 +49,16 @@ module UI
41
49
  end
42
50
 
43
51
  def hint_tag
44
- content_tag(:p, @hint, class: "text-xs text-muted-foreground")
52
+ content_tag(:p, @hint,
53
+ class: "text-sm leading-normal font-normal text-muted-foreground",
54
+ "data-slot": "field-description")
45
55
  end
46
56
 
47
57
  def error_tag
48
- content_tag(:p, @error, class: "text-xs text-destructive", role: "alert")
58
+ content_tag(:p, @error,
59
+ class: "text-sm font-normal text-destructive",
60
+ role: "alert",
61
+ "data-slot": "field-error")
49
62
  end
50
63
  end
51
64
  end
@@ -10,15 +10,15 @@ module UI
10
10
  # g.with_image(src: "/img/b.jpg", alt: "Photo B", caption: "The coast")
11
11
  # end
12
12
 
13
- GRID_BASE = "grid gap-2"
13
+ GRID_BASE = "grid gap-4"
14
14
  GRID_COLS = {
15
15
  1 => "grid-cols-1", 2 => "grid-cols-2", 3 => "grid-cols-3",
16
16
  4 => "grid-cols-4", 5 => "grid-cols-5", 6 => "grid-cols-6"
17
17
  }.freeze
18
18
 
19
- ITEM_CLS = "group relative cursor-zoom-in overflow-hidden rounded-md"
19
+ ITEM_CLS = "group relative cursor-zoom-in overflow-hidden rounded-md #{UI::Styles::BORDER} bg-muted shadow-xs"
20
20
  IMG_CLS = "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
21
- CAP_CLS = "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 px-3 py-2 " \
21
+ CAP_CLS = "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 " \
22
22
  "text-sm text-white opacity-0 transition-opacity group-hover:opacity-100"
23
23
 
24
24
  renders_many :images, "UI::GalleryComponent::ImageComponent"
@@ -4,7 +4,7 @@ export default class extends Controller {
4
4
  open({ params: { src, alt } }) {
5
5
  if (this._overlay) return
6
6
  const overlay = document.createElement("div")
7
- overlay.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
7
+ overlay.className = "vp-overlay flex items-center justify-center p-4"
8
8
  overlay.dataset.galleryOverlay = ""
9
9
 
10
10
  const img = document.createElement("img")
@@ -4,8 +4,7 @@ module UI
4
4
  class HoverCardComponent < ApplicationComponent
5
5
  renders_one :trigger
6
6
 
7
- CARD_BASE = "absolute z-50 w-64 rounded-lg border bg-popover p-4 text-sm " \
8
- "text-popover-foreground shadow-md " \
7
+ CARD_BASE = "#{UI::Styles::POPOVER_PANEL} w-64 p-4 " \
9
8
  "opacity-0 group-hover:opacity-100 pointer-events-none " \
10
9
  "transition-opacity duration-200"
11
10
 
@@ -24,9 +23,10 @@ module UI
24
23
 
25
24
  def call
26
25
  content_tag(:span,
27
- class: cn("relative inline-block group", @extra_class),
26
+ class: cn("group relative inline-block", @extra_class),
27
+ data: { slot: "hover-card" },
28
28
  **@html_attrs) do
29
- concat trigger if trigger
29
+ concat content_tag(:span, trigger, class: "contents", data: { slot: "hover-card-trigger" }) if trigger
30
30
  concat card_panel
31
31
  end
32
32
  end
@@ -35,7 +35,8 @@ module UI
35
35
 
36
36
  def card_panel
37
37
  content_tag(:div,
38
- class: cn(CARD_BASE, POSITIONS.fetch(@side, POSITIONS[:bottom]))) do
38
+ class: cn(CARD_BASE, POSITIONS.fetch(@side, POSITIONS[:bottom])),
39
+ data: { slot: "hover-card-content" }) do
39
40
  content
40
41
  end
41
42
  end
@@ -27,8 +27,10 @@ module UI
27
27
 
28
28
  def call
29
29
  if @aspect
30
- content_tag(:div, style: "aspect-ratio: #{@aspect}", class: "w-full overflow-hidden") do
31
- iframe_tag
30
+ content_tag(:div,
31
+ style: "aspect-ratio: #{@aspect}",
32
+ class: cn("w-full overflow-hidden rounded-md #{UI::Styles::BORDER} shadow-xs", @extra_class)) do
33
+ iframe_tag(wrapped: true)
32
34
  end
33
35
  else
34
36
  iframe_tag
@@ -37,12 +39,12 @@ module UI
37
39
 
38
40
  private
39
41
 
40
- def iframe_tag
42
+ def iframe_tag(wrapped: false)
41
43
  attrs = {
42
44
  src: @src,
43
45
  title: @title,
44
46
  loading: @loading,
45
- class: cn(BASE, (@aspect ? "h-full" : nil), @extra_class)
47
+ class: cn(BASE, (wrapped ? "h-full" : @extra_class))
46
48
  }
47
49
  attrs[:sandbox] = sandbox_value if @sandbox != false
48
50
  attrs[:width] = @width if @width
@@ -2,7 +2,7 @@
2
2
 
3
3
  module UI
4
4
  class ImageComponent < ApplicationComponent
5
- BASE = "max-w-full"
5
+ BASE = "h-auto w-full max-w-full rounded-md"
6
6
 
7
7
  LOADING_MODES = %i[lazy eager auto].freeze
8
8
 
@@ -2,13 +2,14 @@
2
2
 
3
3
  module UI
4
4
  class IndicatorComponent < ApplicationComponent
5
- DOT_BASE = "absolute flex items-center justify-center rounded-full text-[10px] font-medium leading-none"
5
+ DOT_BASE = "absolute flex items-center justify-center rounded-full text-[10px] font-medium leading-none " \
6
+ "ring-2 ring-background"
6
7
 
7
8
  VARIANTS = {
8
9
  default: "bg-primary text-primary-foreground",
9
- destructive: "bg-destructive text-white",
10
- success: "bg-green-500 text-white",
11
- warning: "bg-yellow-500 text-foreground"
10
+ destructive: "bg-destructive text-white dark:bg-destructive/60",
11
+ success: "bg-chart-2 text-white",
12
+ warning: "bg-chart-4 text-foreground"
12
13
  }.freeze
13
14
 
14
15
  POSITIONS = {
@@ -2,26 +2,15 @@
2
2
 
3
3
  module UI
4
4
  class InputComponent < ApplicationComponent
5
- BASE = "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs " \
6
- "transition-[color,box-shadow] outline-none " \
7
- "selection:bg-primary selection:text-primary-foreground " \
8
- "file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground " \
9
- "placeholder:text-muted-foreground " \
10
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
11
- "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
12
- "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
13
- "md:text-sm dark:bg-input/30"
14
-
15
5
  def initialize(type: "text", **html_attrs)
16
6
  @type = type
17
- @extra_class = html_attrs.delete(:class)
18
- @html_attrs = html_attrs
7
+ extract_html_attrs(**html_attrs)
19
8
  end
20
9
 
21
10
  def call
22
11
  content_tag(:input, nil,
23
12
  type: @type,
24
- class: cn(BASE, @extra_class),
13
+ class: cn(UI::Styles::INPUT, @extra_class),
25
14
  **@html_attrs)
26
15
  end
27
16
  end