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
@@ -4,18 +4,31 @@ module UI
4
4
  class SwitchComponent < ApplicationComponent
5
5
  # sr-only input, track label, and thumb are all siblings inside a relative wrapper
6
6
  # so every element can use peer-checked: / peer-focus-visible: / peer-disabled: directly.
7
- WRAPPER = "relative inline-flex h-[1.15rem] w-8 shrink-0"
8
- TRACK = "absolute inset-0 cursor-pointer rounded-full border border-transparent shadow-xs " \
9
- "transition-all bg-input peer-checked:bg-primary dark:bg-input/80 " \
10
- "peer-focus-visible:border-ring peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 " \
11
- "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
12
- THUMB = "pointer-events-none absolute inset-y-0 left-[1px] my-auto z-10 block size-4 rounded-full " \
13
- "bg-background ring-0 transition-transform " \
14
- "translate-x-0 peer-checked:translate-x-[calc(100%-2px)]"
15
-
16
- def initialize(label: nil, checked: false, **html_attrs)
7
+ WRAPPERS = {
8
+ default: "relative inline-flex h-[1.15rem] w-8 shrink-0",
9
+ sm: "relative inline-flex h-3.5 w-6 shrink-0"
10
+ }.freeze
11
+
12
+ TRACK = "absolute inset-0 cursor-pointer rounded-full border border-transparent shadow-xs " \
13
+ "transition-all bg-input peer-checked:bg-primary dark:bg-input/80 " \
14
+ "#{UI::Styles::PEER_FOCUS_RING} " \
15
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
16
+
17
+ THUMBS = {
18
+ default: "pointer-events-none absolute inset-y-0 left-[1px] my-auto z-10 block size-4 rounded-full " \
19
+ "bg-background ring-0 transition-transform translate-x-0 " \
20
+ "peer-checked:translate-x-[calc(100%-2px)] " \
21
+ "dark:bg-foreground dark:peer-checked:bg-primary-foreground",
22
+ sm: "pointer-events-none absolute inset-y-0 left-[1px] my-auto z-10 block size-3 rounded-full " \
23
+ "bg-background ring-0 transition-transform translate-x-0 " \
24
+ "peer-checked:translate-x-[calc(100%-2px)] " \
25
+ "dark:bg-foreground dark:peer-checked:bg-primary-foreground"
26
+ }.freeze
27
+
28
+ def initialize(label: nil, checked: false, size: :default, **html_attrs)
17
29
  @label = label
18
30
  @checked = checked
31
+ @size = size.to_sym
19
32
  @id = html_attrs[:id] || html_attrs[:name]&.gsub(/[\[\]]+/, "_") || "switch_#{object_id}"
20
33
  @extra_class = html_attrs.delete(:class)
21
34
  @html_attrs = html_attrs
@@ -31,21 +44,21 @@ module UI
31
44
  private
32
45
 
33
46
  def switch_widget
34
- content_tag(:div, class: WRAPPER) do
47
+ content_tag(:div, class: WRAPPERS.fetch(@size, WRAPPERS[:default])) do
35
48
  input_attrs = { type: "checkbox", id: @id, class: "peer sr-only", role: "switch",
36
- "aria-checked": @checked.to_s }
49
+ "aria-checked": @checked.to_s, "data-size": @size.to_s }
37
50
  input_attrs[:checked] = true if @checked
38
51
  input_attrs.merge!(@html_attrs)
39
52
  concat content_tag(:input, nil, **input_attrs)
40
53
  concat content_tag(:label, nil, for: @id, class: TRACK)
41
- concat content_tag(:span, nil, class: THUMB, "aria-hidden": "true")
54
+ concat content_tag(:span, nil, class: THUMBS.fetch(@size, THUMBS[:default]), "aria-hidden": "true")
42
55
  end
43
56
  end
44
57
 
45
58
  def text_label
46
59
  content_tag(:label, @label,
47
60
  for: @id,
48
- class: "cursor-pointer text-sm font-medium leading-none")
61
+ class: "cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50")
49
62
  end
50
63
  end
51
64
  end
@@ -1,10 +1,12 @@
1
1
  <%= tag.div(
2
- class: cn("w-full", @extra_class),
3
- data: { controller: "tabs", tabs_index_value: @default_index },
2
+ class: cn("group/tabs flex w-full flex-col gap-2", @extra_class),
3
+ data: { slot: "tabs", controller: "tabs", tabs_index_value: @default_index },
4
4
  **@html_attrs
5
5
  ) do %>
6
6
  <div role="tablist"
7
- class="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
7
+ data-slot="tabs-list"
8
+ data-variant="default"
9
+ class="group/tabs-list inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground">
8
10
  <% @items_data.each_with_index do |item, i| %>
9
11
  <button type="button"
10
12
  role="tab"
@@ -13,7 +15,8 @@
13
15
  data-tabs-target="trigger"
14
16
  data-state="<%= i == @default_index ? 'active' : 'inactive' %>"
15
17
  aria-selected="<%= i == @default_index %>"
16
- class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow">
18
+ data-slot="tabs-trigger"
19
+ class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground vp-focus-ring focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm data-[state=active]:bg-background data-[state=active]:text-foreground dark:text-muted-foreground dark:hover:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
17
20
  <%= item[:title] %>
18
21
  </button>
19
22
  <% end %>
@@ -25,7 +28,8 @@
25
28
  data-tabs-target="trigger"
26
29
  data-state="<%= (@items_data.size + i) == @default_index ? 'active' : 'inactive' %>"
27
30
  aria-selected="<%= (@items_data.size + i) == @default_index %>"
28
- class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow">
31
+ data-slot="tabs-trigger"
32
+ class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground vp-focus-ring focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm data-[state=active]:bg-background data-[state=active]:text-foreground dark:text-muted-foreground dark:hover:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
29
33
  <%= tab.title %>
30
34
  </button>
31
35
  <% end %>
@@ -33,16 +37,18 @@
33
37
 
34
38
  <% @items_data.each_with_index do |item, i| %>
35
39
  <div role="tabpanel"
40
+ data-slot="tabs-content"
36
41
  data-tabs-target="panel"
37
- class="mt-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
42
+ class="flex-1 outline-none"
38
43
  <%= i != @default_index ? "hidden" : "" %>>
39
44
  <%= item[:content] %>
40
45
  </div>
41
46
  <% end %>
42
47
  <% tabs.each_with_index do |tab, i| %>
43
48
  <div role="tabpanel"
49
+ data-slot="tabs-content"
44
50
  data-tabs-target="panel"
45
- class="mt-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
51
+ class="flex-1 outline-none"
46
52
  <%= (@items_data.size + i) != @default_index ? "hidden" : "" %>>
47
53
  <%= tab.call %>
48
54
  </div>
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class TagsInputComponent < ApplicationComponent
5
+ TRIGGER = "flex flex-wrap items-center gap-1.5 min-h-9 w-full rounded-md border border-input " \
6
+ "bg-transparent px-3 py-1.5 text-sm shadow-xs cursor-text " \
7
+ "focus-within:outline-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50"
8
+ CHIP = "inline-flex items-center gap-1 rounded-md bg-secondary " \
9
+ "text-secondary-foreground px-2 py-0.5 text-xs font-medium"
10
+ CHIP_LABEL = "tags-input-chip-label"
11
+ REMOVE = "ml-0.5 rounded-xs opacity-60 transition-opacity hover:opacity-100 #{UI::Styles::FOCUS_RING}"
12
+ FILTER = "min-w-[80px] flex-1 bg-transparent text-sm outline-none " \
13
+ "placeholder:text-muted-foreground"
14
+ PANEL = "#{UI::Styles::POPOVER_PANEL} top-full left-0 mt-1 w-full overflow-hidden"
15
+ LIST = "max-h-[200px] overflow-y-auto p-1"
16
+ OPTION = "#{UI::Styles::MENU_ITEM} w-full cursor-pointer " \
17
+ "hover:bg-accent hover:text-accent-foreground"
18
+ EMPTY = "py-4 text-center text-sm text-muted-foreground"
19
+
20
+ def initialize(name:, options: [], values: [], placeholder: "Select...", **html_attrs)
21
+ @name = name
22
+ @options = options
23
+ @values = Array(values).map(&:to_s)
24
+ @placeholder = placeholder
25
+ @extra_class = html_attrs.delete(:class)
26
+ @html_attrs = html_attrs
27
+ end
28
+
29
+ def call
30
+ content_tag(:div,
31
+ class: cn("relative", @extra_class),
32
+ data: {
33
+ controller: "tags-input",
34
+ action: "click@document->tags-input#closeOnClickOutside"
35
+ },
36
+ **@html_attrs) do
37
+ concat trigger
38
+ concat chip_template
39
+ concat dropdown
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def trigger
46
+ content_tag(:div,
47
+ class: TRIGGER,
48
+ data: {"tags-input-target": "trigger", action: "click->tags-input#focusInput"}) do
49
+ concat safe_join(selected_options.map { |opt| chip(opt) })
50
+ concat filter_input
51
+ end
52
+ end
53
+
54
+ def chip(opt)
55
+ content_tag(:span,
56
+ class: CHIP,
57
+ data: {"tags-input-target": "chip", "tags-input-value": opt[:value]}) do
58
+ concat content_tag(:span, opt[:label], class: CHIP_LABEL)
59
+ concat tag.input(type: "hidden", name: "#{@name}[]", value: opt[:value])
60
+ concat remove_button(opt[:value])
61
+ end
62
+ end
63
+
64
+ def chip_template
65
+ content_tag(:template, data: {"tags-input-target": "chipTemplate"}) do
66
+ chip(value: "", label: "")
67
+ end
68
+ end
69
+
70
+ def remove_button(value)
71
+ content_tag(:button,
72
+ "×",
73
+ type: "button",
74
+ "aria-label": "Remove",
75
+ class: REMOVE,
76
+ data: {"tags-input-value": value, action: "click->tags-input#remove"})
77
+ end
78
+
79
+ def filter_input
80
+ tag.input(
81
+ type: "text",
82
+ placeholder: selected_options.empty? ? @placeholder : nil,
83
+ autocomplete: "off",
84
+ class: FILTER,
85
+ data: {
86
+ "tags-input-target": "input",
87
+ action: "focus->tags-input#open input->tags-input#filter keydown->tags-input#keydown"
88
+ }
89
+ )
90
+ end
91
+
92
+ def dropdown
93
+ content_tag(:div,
94
+ data: {"tags-input-target": "panel"},
95
+ hidden: true,
96
+ class: PANEL) do
97
+ concat content_tag(:div, class: LIST) {
98
+ concat options_list
99
+ concat content_tag(:div, "No results.",
100
+ class: EMPTY,
101
+ data: {"tags-input-target": "empty"},
102
+ hidden: true)
103
+ }
104
+ end
105
+ end
106
+
107
+ def options_list
108
+ safe_join(normalized_options.map { |opt|
109
+ content_tag(:button, opt[:label],
110
+ type: "button",
111
+ class: OPTION,
112
+ hidden: @values.include?(opt[:value]),
113
+ data: {
114
+ "tags-input-target": "option",
115
+ "tags-input-value": opt[:value],
116
+ "tags-input-label": opt[:label],
117
+ action: "click->tags-input#select"
118
+ })
119
+ })
120
+ end
121
+
122
+ def normalized_options
123
+ @options.map { |o|
124
+ case o
125
+ when Hash then {value: o[:value].to_s, label: o[:label].to_s}
126
+ when Array then {value: o[0].to_s, label: o[1].to_s}
127
+ else {value: o.to_s, label: o.to_s}
128
+ end
129
+ }
130
+ end
131
+
132
+ def selected_options
133
+ normalized_options.select { |o| @values.include?(o[:value]) }
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,90 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "panel", "option", "empty", "chip", "chipTemplate"]
5
+
6
+ open() {
7
+ this.panelTarget.hidden = false
8
+ this.filter()
9
+ }
10
+
11
+ close() {
12
+ this.panelTarget.hidden = true
13
+ this.inputTarget.value = ""
14
+ this.#resetOptionVisibility()
15
+ }
16
+
17
+ filter() {
18
+ const query = this.inputTarget.value.toLowerCase()
19
+ let visible = 0
20
+ this.optionTargets.forEach(option => {
21
+ if (option.hidden) return
22
+ const match = option.dataset.tagsInputLabel.toLowerCase().includes(query)
23
+ option.style.display = match ? "" : "none"
24
+ if (match) visible++
25
+ })
26
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = visible > 0
27
+ }
28
+
29
+ select(event) {
30
+ const { tagsInputValue, tagsInputLabel } = event.currentTarget.dataset
31
+ event.currentTarget.hidden = true
32
+ event.currentTarget.style.display = ""
33
+ this.#addChip(tagsInputValue, tagsInputLabel)
34
+ this.inputTarget.value = ""
35
+ this.filter()
36
+ this.inputTarget.focus()
37
+ }
38
+
39
+ remove(event) {
40
+ event.stopPropagation()
41
+ const value = event.currentTarget.dataset.tagsInputValue
42
+ const option = this.optionTargets.find(o => o.dataset.tagsInputValue === value)
43
+ if (option) { option.hidden = false; option.style.display = "" }
44
+ event.currentTarget.closest("[data-tags-input-target~='chip']").remove()
45
+ this.filter()
46
+ }
47
+
48
+ focusInput() {
49
+ this.inputTarget.focus()
50
+ if (this.panelTarget.hidden) this.open()
51
+ }
52
+
53
+ keydown(event) {
54
+ if (event.key === "Backspace" && this.inputTarget.value === "") {
55
+ const chips = this.chipTargets
56
+ if (chips.length > 0) {
57
+ chips[chips.length - 1].querySelector("[data-action*='tags-input#remove']")?.click()
58
+ }
59
+ }
60
+ if (event.key === "Escape") this.close()
61
+ if (event.key === "Enter") {
62
+ event.preventDefault()
63
+ const first = this.optionTargets.find(o => !o.hidden && o.style.display !== "none")
64
+ if (first) first.click()
65
+ }
66
+ }
67
+
68
+ closeOnClickOutside({ target }) {
69
+ if (!this.element.contains(target)) this.close()
70
+ }
71
+
72
+ #addChip(value, label) {
73
+ const chip = this.chipTemplateTarget.content.cloneNode(true).firstElementChild
74
+ chip.dataset.tagsInputValue = value
75
+ chip.querySelector(".tags-input-chip-label").textContent = label
76
+ const hidden = chip.querySelector("input[type='hidden']")
77
+ if (hidden) hidden.value = value
78
+ const btn = chip.querySelector("[data-action*='tags-input#remove']")
79
+ if (btn) btn.dataset.tagsInputValue = value
80
+ this.inputTarget.insertAdjacentElement("beforebegin", chip)
81
+ }
82
+
83
+ #resetOptionVisibility() {
84
+ const selected = new Set(this.chipTargets.map(c => c.dataset.tagsInputValue))
85
+ this.optionTargets.forEach(o => {
86
+ o.hidden = selected.has(o.dataset.tagsInputValue)
87
+ o.style.display = ""
88
+ })
89
+ }
90
+ }
@@ -2,22 +2,13 @@
2
2
 
3
3
  module UI
4
4
  class TextareaComponent < ApplicationComponent
5
- BASE = "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 " \
6
- "text-base shadow-xs transition-[color,box-shadow] outline-none " \
7
- "placeholder:text-muted-foreground " \
8
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
9
- "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
10
- "disabled:cursor-not-allowed disabled:opacity-50 " \
11
- "md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40"
12
-
13
5
  def initialize(**html_attrs)
14
- @extra_class = html_attrs.delete(:class)
15
- @html_attrs = html_attrs
6
+ extract_html_attrs(**html_attrs)
16
7
  end
17
8
 
18
9
  def call
19
10
  content_tag(:textarea, content,
20
- class: cn(BASE, @extra_class),
11
+ class: cn(UI::Styles::TEXTAREA, @extra_class),
21
12
  **@html_attrs)
22
13
  end
23
14
  end
@@ -21,7 +21,8 @@ module UI
21
21
 
22
22
  def call
23
23
  content_tag(:ol,
24
- class: cn("relative border-l border-border ml-3", @extra_class),
24
+ class: cn("relative ml-3 border-l border-border/60", @extra_class),
25
+ data: { slot: "timeline" },
25
26
  **@html_attrs) do
26
27
  safe_join(items)
27
28
  end
@@ -31,15 +32,15 @@ module UI
31
32
  # variant: :default | :success | :warning | :destructive | :muted
32
33
  VARIANTS = {
33
34
  default: "bg-primary",
34
- success: "bg-green-500",
35
- warning: "bg-amber-500",
35
+ success: "bg-chart-2",
36
+ warning: "bg-chart-4",
36
37
  destructive: "bg-destructive",
37
38
  muted: "bg-muted-foreground"
38
39
  }.freeze
39
40
 
40
- DOT_CLS = "absolute -left-1.5 mt-1.5 size-3 rounded-full ring-4 ring-background shrink-0"
41
- DATE_CLS = "mb-0.5 text-xs font-normal text-muted-foreground"
42
- TITLE_CLS = "text-sm font-medium text-foreground leading-snug"
41
+ DOT_CLS = "absolute -left-[7px] mt-1.5 size-3 shrink-0 rounded-full border-2 border-background"
42
+ DATE_CLS = "mb-1 text-xs text-muted-foreground"
43
+ TITLE_CLS = "text-sm font-medium leading-snug text-foreground"
43
44
  DESC_CLS = "mt-1 text-sm text-muted-foreground"
44
45
 
45
46
  # date: optional date/time string shown above the title
@@ -57,7 +58,8 @@ module UI
57
58
 
58
59
  def call
59
60
  content_tag(:li,
60
- class: cn("mb-8 ml-4 last:mb-0", @extra_class),
61
+ class: cn("mb-10 ml-6 last:mb-0", @extra_class),
62
+ data: { slot: "timeline-item" },
61
63
  **@html_attrs) do
62
64
  concat dot
63
65
  concat content_tag(:time, @date, class: DATE_CLS) if @date
@@ -10,21 +10,23 @@ module UI
10
10
  # step: minute step increment (default 1, common: 5, 15, 30)
11
11
 
12
12
  WRAPPER = "relative inline-block"
13
- TRIGGER = "flex h-9 w-36 cursor-pointer items-center gap-2 rounded-md border border-input " \
14
- "bg-background px-3 text-sm text-foreground shadow-xs " \
15
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition " \
16
- "aria-expanded:border-ring"
17
- ICON_CLS = "size-4 shrink-0 text-muted-foreground"
18
- POPOVER = "absolute left-0 top-full z-50 mt-1 hidden w-max rounded-lg border border-border " \
19
- "bg-popover p-3 shadow-md data-[open=true]:block"
13
+ TRIGGER = "#{UI::Styles::PICKER_TRIGGER} w-36"
14
+ ICON_CLS = "size-4 shrink-0 text-muted-foreground pointer-events-none"
15
+ LABEL_PLACEHOLDER = "text-muted-foreground"
16
+ POPOVER = "absolute left-0 top-full z-50 mt-2 hidden w-max p-3 #{UI::Styles::FIELD_PANEL} data-[open=true]:block"
20
17
  SPINNER_WRAP = "flex items-center justify-center gap-1"
21
18
  COL_CLS = "flex flex-col items-center gap-1"
22
- SPIN_BTN = "inline-flex size-7 items-center justify-center rounded-md " \
23
- "text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
24
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
25
- NUM_CLS = "w-10 rounded-md border border-input bg-background px-1 py-0.5 text-center text-sm " \
26
- "focus:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
27
- SEP_CLS = "text-lg font-medium text-foreground pb-1"
19
+ SPIN_BTN = "inline-flex size-7 items-center justify-center rounded-md text-muted-foreground " \
20
+ "transition-colors outline-none " \
21
+ "hover:bg-accent hover:text-accent-foreground " \
22
+ "#{UI::Styles::FOCUS_RING} " \
23
+ "dark:hover:bg-accent/50"
24
+ NUM_CLS = "h-9 w-10 rounded-md border border-input bg-transparent px-1 py-1 text-center text-sm shadow-xs " \
25
+ "transition-[color,box-shadow] outline-none " \
26
+ "#{UI::Styles::FOCUS_RING} " \
27
+ "dark:bg-input/30"
28
+ AMPM_CLS = NUM_CLS + " cursor-pointer select-none"
29
+ SEP_CLS = "pb-1 text-lg font-medium text-foreground"
28
30
 
29
31
  def initialize(value: nil, name: nil, format: :h24, step: 1, **html_attrs)
30
32
  @value = value
@@ -68,7 +70,9 @@ module UI
68
70
  action: "click->timepicker#toggle"
69
71
  }) do
70
72
  concat clock_icon
71
- concat content_tag(:span, @value || "Pick time", data: { timepicker_target: "label" })
73
+ concat content_tag(:span, @value || "Pick time",
74
+ class: (@value ? nil : LABEL_PLACEHOLDER),
75
+ data: { timepicker_target: "label" })
72
76
  end
73
77
  end
74
78
 
@@ -113,7 +117,7 @@ module UI
113
117
  content_tag(:div, class: COL_CLS) do
114
118
  concat spin_btn("▲", "click->timepicker#toggleAmPm")
115
119
  concat content_tag(:span, "AM",
116
- class: "w-10 rounded-md border border-input bg-background px-1 py-0.5 text-center text-sm cursor-pointer select-none",
120
+ class: AMPM_CLS,
117
121
  data: { timepicker_target: "ampm", action: "click->timepicker#toggleAmPm" })
118
122
  concat spin_btn("▼", "click->timepicker#toggleAmPm")
119
123
  end
@@ -25,7 +25,7 @@ module UI
25
25
  top_center: "fixed top-4 left-1/2 -translate-x-1/2"
26
26
  }.freeze
27
27
 
28
- CONTAINER_CLS = "z-50 flex flex-col gap-2 w-80 pointer-events-none"
28
+ CONTAINER_CLS = "toaster group z-50 flex w-80 flex-col gap-3 pointer-events-none"
29
29
 
30
30
  renders_many :toasts, "UI::ToasterComponent::ToastComponent"
31
31
 
@@ -53,14 +53,14 @@ module UI
53
53
  VARIANTS = {
54
54
  default: {border: "border-border", icon: nil, icon_color: "text-foreground"},
55
55
  success: {
56
- border: "border-green-500/40",
56
+ border: "border-chart-2/40",
57
57
  icon: "M9 12l2 2 4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
58
- icon_color: "text-green-500"
58
+ icon_color: "text-chart-2"
59
59
  },
60
60
  warning: {
61
- border: "border-amber-500/40",
61
+ border: "border-chart-4/40",
62
62
  icon: "M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z",
63
- icon_color: "text-amber-500"
63
+ icon_color: "text-chart-4"
64
64
  },
65
65
  destructive: {
66
66
  border: "border-destructive/40",
@@ -68,20 +68,20 @@ module UI
68
68
  icon_color: "text-destructive"
69
69
  },
70
70
  info: {
71
- border: "border-blue-500/40",
71
+ border: "border-border",
72
72
  icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z",
73
- icon_color: "text-blue-500"
73
+ icon_color: "text-muted-foreground"
74
74
  }
75
75
  }.freeze
76
76
 
77
- TOAST_CLS = "pointer-events-auto flex items-start gap-3 rounded-lg border " \
78
- "bg-background px-4 py-3 shadow-lg text-foreground " \
77
+ TOAST_CLS = "pointer-events-auto flex items-start gap-3 rounded-md #{UI::Styles::BORDER} " \
78
+ "bg-popover px-4 py-3 text-popover-foreground shadow-md outline-hidden " \
79
79
  "transition-all duration-300 translate-y-2 opacity-0 " \
80
80
  "data-[open=true]:translate-y-0 data-[open=true]:opacity-100"
81
81
 
82
82
  CLOSE_CLS = "ml-auto -mr-1 -mt-0.5 shrink-0 inline-flex size-6 items-center justify-center " \
83
83
  "rounded-md text-muted-foreground hover:text-foreground hover:bg-accent " \
84
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
84
+ "#{UI::Styles::FOCUS_RING} outline-hidden transition"
85
85
 
86
86
  # message: toast body (required)
87
87
  # title: optional bold heading
@@ -40,10 +40,10 @@ export default class extends Controller {
40
40
  #buildToast({ message = "", title = "", variant = "default", duration = 4000 }) {
41
41
  const borderCls = {
42
42
  default: "border-border",
43
- success: "border-green-500/40",
44
- warning: "border-amber-500/40",
43
+ success: "border-chart-2/40",
44
+ warning: "border-chart-4/40",
45
45
  destructive: "border-destructive/40",
46
- info: "border-blue-500/40"
46
+ info: "border-border"
47
47
  }[variant] ?? "border-border"
48
48
 
49
49
  const div = document.createElement("div")
@@ -53,8 +53,8 @@ export default class extends Controller {
53
53
  div.dataset.toasterTarget = "toast"
54
54
  div.dataset.toasterDurationParam = duration
55
55
  div.className = [
56
- "pointer-events-auto flex items-start gap-3 rounded-lg border",
57
- "bg-background px-4 py-3 shadow-lg text-foreground",
56
+ "pointer-events-auto flex items-start gap-3 rounded-md vp-border",
57
+ "bg-popover px-4 py-3 text-popover-foreground shadow-md outline-hidden",
58
58
  "transition-all duration-300 translate-y-2 opacity-0",
59
59
  "data-[open=true]:translate-y-0 data-[open=true]:opacity-100",
60
60
  borderCls
@@ -71,7 +71,7 @@ export default class extends Controller {
71
71
  data-action="click->toaster#dismiss"
72
72
  class="ml-auto -mr-1 -mt-0.5 shrink-0 inline-flex size-6 items-center justify-center
73
73
  rounded-md text-muted-foreground hover:text-foreground hover:bg-accent
74
- focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition">
74
+ vp-focus-ring outline-hidden transition">
75
75
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
76
76
  stroke="currentColor" stroke-width="2" class="size-3.5" aria-hidden="true">
77
77
  <path d="M18 6 6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
@@ -4,21 +4,27 @@ module UI
4
4
  class ToggleComponent < ApplicationComponent
5
5
  BASE = "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap " \
6
6
  "transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground " \
7
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
7
+ "#{UI::Styles::FOCUS_RING} " \
8
8
  "disabled:pointer-events-none disabled:opacity-50 " \
9
9
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
10
10
  "data-[state=on]:bg-accent data-[state=on]:text-accent-foreground " \
11
11
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
12
12
 
13
+ VARIANTS = {
14
+ default: "bg-transparent",
15
+ outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground"
16
+ }.freeze
17
+
13
18
  SIZES = {
14
19
  default: "h-9 min-w-9 px-2",
15
20
  sm: "h-8 min-w-8 px-1.5",
16
21
  lg: "h-10 min-w-10 px-2.5"
17
22
  }.freeze
18
23
 
19
- def initialize(label = nil, pressed: false, size: :default, value: nil, **html_attrs)
24
+ def initialize(label = nil, pressed: false, variant: :default, size: :default, value: nil, **html_attrs)
20
25
  @label = label || html_attrs.delete(:label)
21
26
  @pressed = pressed
27
+ @variant = variant.to_sym
22
28
  @size = size.to_sym
23
29
  @value = value
24
30
  @extra_class = html_attrs.delete(:class)
@@ -34,7 +40,8 @@ module UI
34
40
  "data-controller": "toggle",
35
41
  "data-action": "click->toggle#toggle",
36
42
  value: @value,
37
- class: cn(BASE, SIZES.fetch(@size, SIZES[:default]), @extra_class),
43
+ class: cn(BASE, VARIANTS.fetch(@variant, VARIANTS[:default]),
44
+ SIZES.fetch(@size, SIZES[:default]), @extra_class),
38
45
  **@html_attrs)
39
46
  end
40
47
  end
@@ -2,15 +2,13 @@
2
2
 
3
3
  module UI
4
4
  class ToggleGroupComponent < ApplicationComponent
5
- BASE = "inline-flex gap-1"
5
+ BASE = "group/toggle-group flex w-fit items-center gap-1 rounded-md " \
6
+ "data-[variant=outline]:shadow-xs"
6
7
 
7
- # type: :single only one item active at a time
8
- # :multiple — multiple items can be active simultaneously
9
- # value: currently active value (String) for :single,
10
- # or array of active values for :multiple
11
- def initialize(type: :single, value: nil, **html_attrs)
8
+ def initialize(type: :single, value: nil, variant: :default, **html_attrs)
12
9
  @type = type.to_sym
13
10
  @value = Array(value).map(&:to_s)
11
+ @variant = variant.to_sym
14
12
  @extra_class = html_attrs.delete(:class)
15
13
  @html_attrs = html_attrs
16
14
  end
@@ -20,8 +18,10 @@ module UI
20
18
  content,
21
19
  class: cn(BASE, @extra_class),
22
20
  role: "group",
21
+ "data-variant": @variant.to_s,
23
22
  "data-controller": "toggle-group",
24
23
  "data-toggle-group-type-value": @type,
24
+ "data-slot": "toggle-group",
25
25
  **@html_attrs)
26
26
  end
27
27