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
@@ -8,19 +8,17 @@ module UI
8
8
  # Usage:
9
9
  # <%%= ui :input_otp, length: 6, name: "otp" %>
10
10
 
11
- CELL_CLS = "h-12 w-10 rounded-md border border-input bg-transparent text-center text-lg font-medium " \
12
- "shadow-xs transition-[color,box-shadow] outline-none " \
11
+ CELL_CLS = "relative flex h-9 w-9 items-center justify-center rounded-md border border-input " \
12
+ "bg-transparent text-center text-sm shadow-xs transition-all outline-none " \
13
13
  "caret-transparent selection:bg-primary selection:text-primary-foreground " \
14
- "focus:border-ring focus:ring-[3px] focus:ring-ring/50 " \
14
+ "#{UI::Styles::FOCUS_RING} " \
15
15
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 " \
16
- "disabled:pointer-events-none disabled:opacity-50"
16
+ "dark:bg-input/30 dark:aria-invalid:ring-destructive/40 " \
17
+ "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
17
18
 
18
- WRAPPER_CLS = "flex items-center gap-2"
19
- SEPARATOR_CLS = "text-muted-foreground text-lg font-medium"
19
+ WRAPPER_CLS = "flex items-center gap-2 has-[:disabled]:opacity-50"
20
+ SEPARATOR_CLS = "text-muted-foreground"
20
21
 
21
- # length: number of OTP digits (default: 6)
22
- # name: form field name (individual cells get name[0], name[1], …)
23
- # separator: position (Integer) or Hash { position => char }, e.g. 3 or { 3 => "-" }
24
22
  def initialize(length: 6, name: "otp", separator: nil, **html_attrs)
25
23
  @length = length.to_i
26
24
  @name = name
@@ -35,11 +33,12 @@ module UI
35
33
  def call
36
34
  content_tag(:div,
37
35
  class: cn(WRAPPER_CLS, @extra_class),
36
+ "data-slot": "input-otp",
38
37
  data: { controller: "input-otp" },
39
38
  **@html_attrs) do
40
39
  @length.times do |i|
41
40
  sep = @separator&.fetch(i, nil)
42
- concat content_tag(:span, sep, class: SEPARATOR_CLS) if sep
41
+ concat content_tag(:div, sep == "-" ? separator_icon : sep, class: SEPARATOR_CLS, role: "separator") if sep
43
42
  concat digit_input(i)
44
43
  end
45
44
  end
@@ -47,6 +46,18 @@ module UI
47
46
 
48
47
  private
49
48
 
49
+ def separator_icon
50
+ content_tag(:svg,
51
+ content_tag(:path, nil, d: "M5 12h14", "stroke-linecap": "round", "stroke-linejoin": "round"),
52
+ xmlns: "http://www.w3.org/2000/svg",
53
+ viewBox: "0 0 24 24",
54
+ fill: "none",
55
+ stroke: "currentColor",
56
+ "stroke-width": "2",
57
+ class: "size-4",
58
+ "aria-hidden": "true")
59
+ end
60
+
50
61
  def digit_input(index)
51
62
  content_tag(:input, nil,
52
63
  type: "text",
@@ -55,6 +66,7 @@ module UI
55
66
  autocomplete: index.zero? ? "one-time-code" : "off",
56
67
  name: "#{@name}[#{index}]",
57
68
  class: CELL_CLS,
69
+ "data-slot": "input-otp-slot",
58
70
  "aria-label": "Digit #{index + 1}",
59
71
  data: {
60
72
  input_otp_target: "cell",
@@ -4,7 +4,9 @@ module UI
4
4
  class KbdComponent < ApplicationComponent
5
5
  BASE = "pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 " \
6
6
  "rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none " \
7
- "[&_svg:not([class*='size-'])]:size-3"
7
+ "[&_svg:not([class*='size-'])]:size-3 " \
8
+ "[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background " \
9
+ "dark:[[data-slot=tooltip-content]_&]:bg-background/10"
8
10
 
9
11
  def initialize(key = nil, **html_attrs)
10
12
  @key = key || html_attrs.delete(:label)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module UI
4
4
  class ListGroupComponent < ApplicationComponent
5
- BASE = "divide-y divide-border overflow-hidden rounded-lg border"
5
+ BASE = "flex flex-col divide-y divide-border overflow-hidden rounded-md #{UI::Styles::BORDER}"
6
6
 
7
7
  def initialize(**html_attrs)
8
8
  @extra_class = html_attrs.delete(:class)
@@ -10,7 +10,11 @@ module UI
10
10
  end
11
11
 
12
12
  def call
13
- content_tag(:ul, content, class: cn(BASE, @extra_class), **@html_attrs)
13
+ content_tag(:ul, content,
14
+ class: cn(BASE, @extra_class),
15
+ role: "list",
16
+ data: { slot: "item-group" },
17
+ **@html_attrs)
14
18
  end
15
19
  end
16
20
  end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module UI
4
4
  class ListGroupItemComponent < ApplicationComponent
5
- BASE = "flex items-center justify-between px-4 py-3 text-sm"
5
+ BASE = "flex items-center justify-between gap-4 px-4 py-3 text-sm transition-colors duration-100 " \
6
+ "outline-none #{UI::Styles::FOCUS_RING}"
6
7
 
7
8
  VARIANTS = {
8
- default: "text-foreground hover:bg-muted",
9
- active: "bg-primary text-primary-foreground",
10
- muted: "text-muted-foreground hover:bg-muted"
9
+ default: "text-foreground hover:bg-accent/50",
10
+ active: "bg-accent font-medium text-accent-foreground",
11
+ muted: "text-muted-foreground hover:bg-accent/50"
11
12
  }.freeze
12
13
 
13
14
  def initialize(label = nil, href: nil, active: false, variant: :default, **html_attrs)
@@ -24,6 +25,7 @@ module UI
24
25
  content_tag(tag_name,
25
26
  content.presence || @label,
26
27
  class: cn(BASE, VARIANTS.fetch(@variant, VARIANTS[:default]), @extra_class),
28
+ data: { slot: "item" },
27
29
  **extra,
28
30
  **@html_attrs)
29
31
  end
@@ -23,7 +23,8 @@ module UI
23
23
  # target: link target, e.g. "_blank"
24
24
  # rel: link rel attribute
25
25
 
26
- WRAPPER_CLS = "relative inline-block"
26
+ WRAPPER_CLS = "relative inline-block overflow-hidden rounded-md #{UI::Styles::BORDER} shadow-xs"
27
+ IMG_CLS = "block h-auto w-full max-w-full rounded-md"
27
28
 
28
29
  def initialize(src:, alt:, areas: [], width: nil, height: nil,
29
30
  loading: :lazy, map_name: nil, **html_attrs)
@@ -47,7 +48,7 @@ module UI
47
48
  private
48
49
 
49
50
  def img_tag
50
- attrs = { src: @src, alt: @alt, usemap: "##{@map_name}", loading: @loading }
51
+ attrs = { src: @src, alt: @alt, usemap: "##{@map_name}", loading: @loading, class: IMG_CLS }
51
52
  attrs[:width] = @width if @width
52
53
  attrs[:height] = @height if @height
53
54
  tag.img(**attrs)
@@ -5,25 +5,25 @@ module UI
5
5
  # Full-width dropdown panel anchored to a trigger button.
6
6
  # Columns are rendered via with_column blocks.
7
7
 
8
- TRIGGER_CLS = "inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-background " \
8
+ TRIGGER_CLS = "inline-flex h-9 w-max items-center justify-center gap-1.5 rounded-md bg-background " \
9
9
  "px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
10
10
  "hover:bg-accent hover:text-accent-foreground " \
11
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
12
- "data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground"
11
+ "#{UI::Styles::FOCUS_RING} " \
12
+ "data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground " \
13
+ "data-[state=open]:hover:bg-accent"
13
14
 
14
- PANEL_CLS = "absolute left-0 top-full z-50 mt-1.5 w-full overflow-hidden rounded-md border " \
15
- "bg-popover text-popover-foreground shadow-lg"
15
+ PANEL_CLS = "#{UI::Styles::POPOVER_PANEL} left-0 top-full mt-2 w-full overflow-hidden"
16
16
 
17
17
  INNER_CLS = "container mx-auto grid gap-6 p-6"
18
18
 
19
19
  COLUMN_HEADING = "mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
20
20
 
21
- ITEM_CLS = "group flex items-start gap-3 rounded-sm p-2 text-sm transition-colors outline-none " \
21
+ ITEM_CLS = "group flex items-start gap-3 rounded-md p-2 text-sm transition-all outline-none " \
22
22
  "hover:bg-accent hover:text-accent-foreground " \
23
- "focus-visible:ring-[3px] focus-visible:ring-ring/50"
23
+ "#{UI::Styles::FOCUS_RING}"
24
24
 
25
25
  ITEM_TITLE = "font-medium leading-none"
26
- ITEM_DESC = "mt-1 text-xs text-muted-foreground group-hover:text-accent-foreground/70"
26
+ ITEM_DESC = "mt-1 text-xs leading-normal text-muted-foreground"
27
27
 
28
28
  CHEVRON_PATH = "m6 9 6 6 6-6"
29
29
 
@@ -87,7 +87,7 @@ module UI
87
87
  fill: "none",
88
88
  stroke: "currentColor",
89
89
  "stroke-width": "2",
90
- class: "size-3 transition-transform duration-200 data-[state=open]:rotate-180",
90
+ class: "size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180",
91
91
  "aria-hidden": "true",
92
92
  data: { mega_menu_target: "chevron" })
93
93
  end
@@ -4,13 +4,13 @@ module UI
4
4
  class MenubarComponent < ApplicationComponent
5
5
  renders_many :menus, "UI::MenubarMenuComponent"
6
6
 
7
- BAR = "flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs"
8
- ITEM = "relative flex cursor-default select-none items-center gap-2 rounded-sm " \
9
- "px-2 py-1.5 text-sm outline-none " \
10
- "hover:bg-accent hover:text-accent-foreground " \
7
+ BAR = "flex h-9 items-center gap-1 rounded-md #{UI::Styles::BORDER} bg-background p-1 shadow-xs"
8
+ ITEM = "#{UI::Styles::MENU_ITEM} w-full whitespace-nowrap hover:bg-accent hover:text-accent-foreground " \
9
+ "data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 " \
10
+ "dark:data-[variant=destructive]:focus:bg-destructive/20 " \
11
11
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
12
12
  "[&_svg:not([class*='text-'])]:text-muted-foreground"
13
- SEPARATOR = "-mx-1 my-1 h-px bg-border"
13
+ SEPARATOR = UI::Styles::MENU_SEPARATOR
14
14
  LABEL_CLS = "px-2 py-1.5 text-sm font-medium"
15
15
 
16
16
  def initialize(**html_attrs)
@@ -2,12 +2,11 @@
2
2
 
3
3
  module UI
4
4
  class MenubarMenuComponent < ApplicationComponent
5
- TRIGGER = "flex cursor-default select-none items-center rounded-sm px-2 py-1 text-sm font-medium " \
6
- "outline-none transition-colors " \
7
- "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
5
+ TRIGGER = "flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none " \
6
+ "focus:bg-accent focus:text-accent-foreground " \
7
+ "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
8
8
 
9
- PANEL = "absolute left-0 top-full z-50 mt-1 min-w-[12rem] overflow-hidden rounded-md border " \
10
- "bg-popover p-1 text-popover-foreground shadow-md"
9
+ PANEL = "#{UI::Styles::POPOVER_PANEL} left-0 top-full z-50 mt-1 w-max min-w-[12rem] overflow-hidden p-1"
11
10
 
12
11
  def initialize(label:, **html_attrs)
13
12
  @label = label
@@ -2,9 +2,13 @@
2
2
 
3
3
  module UI
4
4
  class NavbarComponent < ApplicationComponent
5
- LINK_BASE = "text-sm font-medium transition-colors hover:text-foreground"
5
+ LINK_BASE = "inline-flex h-9 items-center rounded-md px-3 text-sm font-medium transition-[color,box-shadow] " \
6
+ "outline-none hover:bg-accent hover:text-accent-foreground " \
7
+ "#{UI::Styles::FOCUS_RING}"
6
8
  LINK_IDLE = "text-muted-foreground"
7
- LINK_ACTIVE = "text-foreground"
9
+ LINK_ACTIVE = "bg-accent/50 text-accent-foreground"
10
+ MOBILE_LINK = "block rounded-md px-3 py-2 text-sm font-medium transition-colors outline-none " \
11
+ "hover:bg-accent hover:text-accent-foreground #{UI::Styles::FOCUS_RING}"
8
12
 
9
13
  # items: [{ label:, href:, active: (optional) }]
10
14
  # Block content is placed in the right action area (e.g. a Sign in button).
@@ -18,21 +22,27 @@ module UI
18
22
 
19
23
  def call
20
24
  content_tag(:nav,
21
- class: cn("sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60", @extra_class),
25
+ class: cn("sticky top-0 z-50 w-full border-b border-border bg-background/95 shadow-xs backdrop-blur supports-[backdrop-filter]:bg-background/80", @extra_class),
26
+ "aria-label": "Main navigation",
22
27
  data: { controller: "navbar" },
23
28
  **@html_attrs) do
24
- content_tag(:div, class: "container mx-auto flex h-14 items-center gap-4 px-4") do
25
- concat brand_link
26
- concat desktop_menu
27
- concat spacer
28
- concat action_area
29
- concat hamburger
30
- end
29
+ concat header_bar
30
+ concat mobile_menu if @items.any?
31
31
  end
32
32
  end
33
33
 
34
34
  private
35
35
 
36
+ def header_bar
37
+ content_tag(:div, class: "container mx-auto flex h-14 items-center gap-4 px-4") do
38
+ concat brand_link
39
+ concat desktop_menu
40
+ concat spacer
41
+ concat action_area
42
+ concat hamburger
43
+ end
44
+ end
45
+
36
46
  def brand_link
37
47
  return "" unless @brand
38
48
 
@@ -70,13 +80,43 @@ module UI
70
80
 
71
81
  content_tag(:button, nil,
72
82
  type: "button",
73
- class: "md:hidden inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground",
83
+ class: "inline-flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors outline-none hover:bg-accent hover:text-accent-foreground #{UI::Styles::FOCUS_RING} md:hidden",
74
84
  data: { action: "click->navbar#toggle", navbar_target: "toggle" },
85
+ "aria-expanded": "false",
86
+ "aria-controls": mobile_menu_id,
75
87
  "aria-label": "Toggle menu") do
76
88
  hamburger_icon
77
89
  end
78
90
  end
79
91
 
92
+ def mobile_menu
93
+ content_tag(:div,
94
+ id: mobile_menu_id,
95
+ class: "md:hidden border-b border-border bg-background px-4 py-3",
96
+ data: { navbar_target: "menu" },
97
+ hidden: true) do
98
+ concat content_tag(:div, class: "flex flex-col gap-1") {
99
+ safe_join(@items.map { |item| mobile_nav_link(item) })
100
+ }
101
+ concat mobile_action_area if content?
102
+ end
103
+ end
104
+
105
+ def mobile_nav_link(item)
106
+ content_tag(:a, item[:label],
107
+ href: item[:href],
108
+ class: cn(MOBILE_LINK, item[:active] ? LINK_ACTIVE : LINK_IDLE),
109
+ data: { action: "click->navbar#close" })
110
+ end
111
+
112
+ def mobile_action_area
113
+ content_tag(:div, content, class: "mt-3 flex flex-col gap-2 border-t border-border pt-3")
114
+ end
115
+
116
+ def mobile_menu_id
117
+ @mobile_menu_id ||= "navbar-menu-#{object_id}"
118
+ end
119
+
80
120
  def hamburger_icon
81
121
  raw(<<~SVG)
82
122
  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -4,8 +4,13 @@ export default class extends Controller {
4
4
  static targets = ["menu", "toggle"]
5
5
 
6
6
  toggle() {
7
- const menu = this.element.nextElementSibling
8
- if (!menu?.dataset.navbarTarget?.includes("menu")) return
9
- menu.hidden = !menu.hidden
7
+ const open = this.menuTarget.hidden
8
+ this.menuTarget.hidden = !open
9
+ this.toggleTarget.setAttribute("aria-expanded", open ? "true" : "false")
10
+ }
11
+
12
+ close() {
13
+ this.menuTarget.hidden = true
14
+ this.toggleTarget.setAttribute("aria-expanded", "false")
10
15
  }
11
16
  }
@@ -2,32 +2,28 @@
2
2
 
3
3
  module UI
4
4
  class NavigationMenuComponent < ApplicationComponent
5
- ROOT = "relative flex max-w-max flex-1 items-center justify-center"
6
- LIST = "flex flex-1 list-none items-center justify-center gap-1"
5
+ ROOT = "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center"
6
+ LIST = "group flex flex-1 list-none items-center justify-center gap-1"
7
7
 
8
- # Trigger button style (item with flyout content)
9
8
  TRIGGER = "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background " \
10
9
  "px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
11
- "hover:bg-accent hover:text-accent-foreground " \
12
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
10
+ "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground " \
11
+ "#{UI::Styles::FOCUS_RING} " \
13
12
  "disabled:pointer-events-none disabled:opacity-50 " \
14
- "data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground"
13
+ "data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground " \
14
+ "data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
15
15
 
16
- # Plain link style (item without flyout)
17
16
  LINK_CLS = "inline-flex h-9 w-max items-center justify-center rounded-md bg-background " \
18
17
  "px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none " \
19
- "hover:bg-accent hover:text-accent-foreground " \
20
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
18
+ "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground " \
19
+ "#{UI::Styles::FOCUS_RING} " \
21
20
  "aria-[current]:bg-accent/50 aria-[current]:text-accent-foreground"
22
21
 
23
- # Flyout panel
24
- CONTENT = "absolute top-full left-0 z-50 mt-1.5 min-w-48 overflow-hidden rounded-md border " \
25
- "bg-popover p-1 text-popover-foreground shadow"
22
+ CONTENT = "#{UI::Styles::POPOVER_PANEL} top-full left-0 mt-1.5 min-w-48 overflow-hidden p-2 shadow"
26
23
 
27
- # Styled link inside a flyout panel
28
- PANEL_LINK = "flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none " \
29
- "hover:bg-accent hover:text-accent-foreground " \
30
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
24
+ PANEL_LINK = "flex flex-col gap-1 rounded-md p-2 text-sm transition-all outline-none " \
25
+ "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground " \
26
+ "#{UI::Styles::FOCUS_RING} " \
31
27
  "aria-[current]:bg-accent/50 aria-[current]:text-accent-foreground"
32
28
 
33
29
  CHEVRON_PATH = "m6 9 6 6 6-6"
@@ -2,14 +2,8 @@
2
2
 
3
3
  module UI
4
4
  class NumberInputComponent < 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
- "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:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " \
11
- "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none " \
12
- "md:text-sm dark:bg-input/30"
5
+ SPINNER_HIDE = "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none " \
6
+ "[&::-webkit-outer-spin-button]:appearance-none"
13
7
 
14
8
  # min / max / step: native number input attributes
15
9
  # value: initial value
@@ -18,12 +12,11 @@ module UI
18
12
  @max = max
19
13
  @step = step
20
14
  @value = value
21
- @extra_class = html_attrs.delete(:class)
22
- @html_attrs = html_attrs
15
+ extract_html_attrs(**html_attrs)
23
16
  end
24
17
 
25
18
  def call
26
- attrs = { type: "number", class: cn(BASE, @extra_class) }
19
+ attrs = { type: "number", class: cn(UI::Styles::INPUT, SPINNER_HIDE, @extra_class) }
27
20
  attrs[:min] = @min unless @min.nil?
28
21
  attrs[:max] = @max unless @max.nil?
29
22
  attrs[:step] = @step unless @step.nil?
@@ -2,9 +2,10 @@
2
2
 
3
3
  module UI
4
4
  class PaginationComponent < ApplicationComponent
5
- ITEM = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium " \
6
- "transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " \
7
- "h-9 w-9 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground"
5
+ ITEM = "inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-md border border-input " \
6
+ "bg-background text-sm font-medium shadow-xs transition-colors outline-none " \
7
+ "hover:bg-accent hover:text-accent-foreground " \
8
+ "#{UI::Styles::FOCUS_RING}"
8
9
  ACTIVE = "bg-primary text-primary-foreground shadow hover:bg-primary/90 border-transparent"
9
10
  MUTED = "cursor-not-allowed opacity-50 pointer-events-none"
10
11
 
@@ -54,7 +54,8 @@ module UI
54
54
  private
55
55
 
56
56
  def fallback_img
57
- attrs = { src: @src, alt: @alt, loading: @loading, class: cn("max-w-full", @extra_class) }
57
+ attrs = { src: @src, alt: @alt, loading: @loading,
58
+ class: cn("h-auto w-full max-w-full rounded-md", @extra_class) }
58
59
  attrs[:width] = @width if @width
59
60
  attrs[:height] = @height if @height
60
61
  tag.img(**attrs)
@@ -4,8 +4,7 @@ module UI
4
4
  class PopoverComponent < ApplicationComponent
5
5
  renders_one :trigger
6
6
 
7
- PANEL_BASE = "absolute z-50 w-72 rounded-md border bg-popover p-4 " \
8
- "text-sm text-popover-foreground shadow-md outline-none"
7
+ PANEL_BASE = "#{UI::Styles::POPOVER_PANEL} w-72 p-4"
9
8
 
10
9
  ALIGN = {
11
10
  start: "left-0",
@@ -16,12 +16,14 @@ module UI
16
16
  def call
17
17
  content_tag(:div,
18
18
  class: cn(TRACK, @extra_class),
19
+ data: { slot: "progress" },
19
20
  role: "progressbar",
20
21
  "aria-valuenow": @value,
21
22
  "aria-valuemin": 0,
22
23
  "aria-valuemax": @max,
23
24
  **@html_attrs) do
24
- content_tag(:div, nil, class: BAR, style: "width: #{@pct.round(2)}%")
25
+ content_tag(:div, nil, class: BAR, style: "width: #{@pct.round(2)}%",
26
+ data: { slot: "progress-indicator" })
25
27
  end
26
28
  end
27
29
  end
@@ -12,7 +12,7 @@ module UI
12
12
  # <%%= RQRCode::QRCode.new("https://example.com").as_svg(viewbox: true).html_safe %>
13
13
  # <%% end %>
14
14
 
15
- WRAPPER_CLS = "inline-flex items-center justify-center overflow-hidden rounded-lg bg-white p-3"
15
+ WRAPPER_CLS = "inline-flex items-center justify-center overflow-hidden rounded-md #{UI::Styles::BORDER} bg-card p-4 shadow-xs"
16
16
 
17
17
  # src: image URL for a pre-rendered QR (renders an <img>)
18
18
  # alt: accessible label for the <img> (default: "QR code")
@@ -12,7 +12,7 @@ module UI
12
12
 
13
13
  def call
14
14
  content_tag(:div,
15
- class: cn("grid gap-2", @extra_class),
15
+ class: cn("grid gap-3", @extra_class),
16
16
  role: "radiogroup",
17
17
  **@html_attrs) do
18
18
  if @items.any?
@@ -35,9 +35,12 @@ module UI
35
35
 
36
36
  def radio_input(item, id)
37
37
  attrs = { type: "radio", name: @name, value: item[:value], id: id,
38
- class: "h-4 w-4 border border-primary text-primary accent-primary " \
39
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " \
40
- "disabled:cursor-not-allowed disabled:opacity-50" }
38
+ class: "aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs " \
39
+ "transition-[color,box-shadow] outline-none accent-primary " \
40
+ "#{UI::Styles::FOCUS_RING} " \
41
+ "disabled:cursor-not-allowed disabled:opacity-50 " \
42
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 " \
43
+ "dark:bg-input/30 dark:aria-invalid:ring-destructive/40" }
41
44
  attrs[:checked] = true if item[:checked]
42
45
  content_tag(:input, nil, **attrs)
43
46
  end
@@ -45,7 +48,7 @@ module UI
45
48
  def radio_label(item, id)
46
49
  content_tag(:label, item[:label],
47
50
  for: id,
48
- class: "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
51
+ class: "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50")
49
52
  end
50
53
  end
51
54
  end
@@ -2,9 +2,8 @@
2
2
 
3
3
  module UI
4
4
  class RangeComponent < ApplicationComponent
5
- BASE = "w-full cursor-pointer appearance-none rounded-full bg-input outline-none " \
6
- "h-2 accent-primary " \
7
- "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
5
+ BASE = "h-2 w-full cursor-pointer appearance-none rounded-full bg-muted outline-none accent-primary " \
6
+ "#{UI::Styles::FOCUS_RING} " \
8
7
  "disabled:pointer-events-none disabled:opacity-50 " \
9
8
  "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none " \
10
9
  "[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary " \
@@ -30,7 +30,7 @@ module UI
30
30
  def star(filled)
31
31
  content_tag(:svg,
32
32
  content_tag(:path, nil, d: STAR_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
33
- class: filled ? "size-5 text-yellow-400" : "size-5 text-muted-foreground",
33
+ class: filled ? "size-4 fill-primary text-primary" : "size-4 fill-muted text-muted",
34
34
  xmlns: "http://www.w3.org/2000/svg",
35
35
  viewBox: "0 0 24 24",
36
36
  fill: filled ? "currentColor" : "none",
@@ -21,7 +21,7 @@ export default class extends Controller {
21
21
  #render(upTo) {
22
22
  this.starTargets.forEach((star, i) => {
23
23
  const filled = i < upTo
24
- star.classList.toggle("text-yellow-400", filled)
24
+ star.classList.toggle("text-primary", filled)
25
25
  star.classList.toggle("text-muted-foreground", !filled)
26
26
  const svg = star.querySelector("svg")
27
27
  if (svg) svg.setAttribute("fill", filled ? "currentColor" : "none")
@@ -45,8 +45,9 @@ module UI
45
45
  star_svg(filled),
46
46
  type: "button",
47
47
  class: cn(
48
- "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
49
- filled ? "text-yellow-400" : "text-muted-foreground"
48
+ "rounded-sm transition-colors outline-none",
49
+ UI::Styles::FOCUS_RING,
50
+ filled ? "text-primary" : "text-muted-foreground"
50
51
  ),
51
52
  data: {
52
53
  rating_target: "star",
@@ -59,7 +60,7 @@ module UI
59
60
  def star_svg(filled)
60
61
  content_tag(:svg,
61
62
  content_tag(:path, nil, d: STAR_PATH, "stroke-linecap": "round", "stroke-linejoin": "round"),
62
- class: "size-6 pointer-events-none",
63
+ class: "size-5 pointer-events-none",
63
64
  xmlns: "http://www.w3.org/2000/svg",
64
65
  viewBox: "0 0 24 24",
65
66
  fill: filled ? "currentColor" : "none",