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,7 +2,7 @@
2
2
 
3
3
  module UI
4
4
  class CardFooterComponent < ApplicationComponent
5
- BASE = "flex items-center px-6"
5
+ BASE = "flex items-center px-6 [.border-t]:pt-6"
6
6
 
7
7
  def initialize(**html_attrs)
8
8
  @extra_class = html_attrs.delete(:class)
@@ -10,18 +10,22 @@ module UI
10
10
  # c.with_slide { image_tag "slide2.jpg" }
11
11
  # end
12
12
 
13
- TRACK_CLS = "flex"
14
- SLIDE_CLS = "min-w-full shrink-0"
13
+ CONTENT_CLS = "overflow-hidden"
14
+ TRACK_CLS = "flex transition-transform duration-300 ease-in-out"
15
+ SLIDE_CLS = "min-w-0 shrink-0 grow-0 basis-full"
15
16
 
16
- BTN_BASE = "absolute top-1/2 z-10 -translate-y-1/2 inline-flex size-9 items-center justify-center " \
17
- "rounded-full bg-background/80 backdrop-blur border border-border shadow-sm " \
18
- "transition hover:bg-background disabled:opacity-40 " \
19
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none"
17
+ BTN_BASE = "absolute top-1/2 z-10 -translate-y-1/2 inline-flex size-8 shrink-0 items-center " \
18
+ "justify-center rounded-full border border-input bg-background text-sm font-medium shadow-xs " \
19
+ "transition-all outline-none " \
20
+ "hover:bg-accent hover:text-accent-foreground " \
21
+ "#{UI::Styles::FOCUS_RING} " \
22
+ "disabled:pointer-events-none disabled:opacity-50 " \
23
+ "dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
20
24
  BTN_PREV = "left-2"
21
25
  BTN_NEXT = "right-2"
22
26
 
23
- DOTS_CLS = "mt-3 flex justify-center gap-1.5"
24
- DOT_CLS = "size-2 rounded-full bg-muted-foreground/40 transition " \
27
+ DOTS_CLS = "mt-4 flex justify-center gap-1.5"
28
+ DOT_CLS = "size-2 rounded-full bg-muted transition-colors " \
25
29
  "data-[active=true]:bg-primary data-[active=true]:w-4"
26
30
 
27
31
  CHEVRON_L = "m15 18-6-6 6-6"
@@ -42,14 +46,17 @@ module UI
42
46
 
43
47
  def call
44
48
  content_tag(:div,
45
- class: cn("relative overflow-hidden", @extra_class),
49
+ class: cn("relative", @extra_class),
50
+ role: "region",
51
+ "aria-roledescription": "carousel",
46
52
  data: {
53
+ slot: "carousel",
47
54
  controller: "carousel",
48
55
  carousel_loop_value: @loop,
49
56
  carousel_autoplay_value: @autoplay
50
57
  },
51
58
  **@html_attrs) do
52
- concat track
59
+ concat content_tag(:div, track, class: CONTENT_CLS, data: { slot: "carousel-content" })
53
60
  concat prev_btn
54
61
  concat next_btn
55
62
  concat dots if @indicators && slides.size > 1
@@ -60,7 +67,13 @@ module UI
60
67
 
61
68
  def track
62
69
  content_tag(:div, class: TRACK_CLS, data: { carousel_target: "track" }) do
63
- safe_join(slides.map { |s| content_tag(:div, s, class: SLIDE_CLS) })
70
+ safe_join(slides.map { |s|
71
+ content_tag(:div, s,
72
+ class: SLIDE_CLS,
73
+ role: "group",
74
+ "aria-roledescription": "slide",
75
+ data: { slot: "carousel-item" })
76
+ })
64
77
  end
65
78
  end
66
79
 
@@ -68,14 +81,14 @@ module UI
68
81
  content_tag(:button, type: "button",
69
82
  class: cn(BTN_BASE, BTN_PREV),
70
83
  "aria-label": "Previous slide",
71
- data: { action: "click->carousel#prev" }) { chevron(CHEVRON_L) }
84
+ data: { slot: "carousel-previous", action: "click->carousel#prev" }) { chevron(CHEVRON_L) }
72
85
  end
73
86
 
74
87
  def next_btn
75
88
  content_tag(:button, type: "button",
76
89
  class: cn(BTN_BASE, BTN_NEXT),
77
90
  "aria-label": "Next slide",
78
- data: { action: "click->carousel#next" }) { chevron(CHEVRON_R) }
91
+ data: { slot: "carousel-next", action: "click->carousel#next" }) { chevron(CHEVRON_R) }
79
92
  end
80
93
 
81
94
  def dots
@@ -22,22 +22,26 @@ module UI
22
22
  # datasets: array of dataset hashes; snake_case keys are camelized for Chart.js
23
23
  # (e.g. background_color: → backgroundColor:)
24
24
  # options: hash merged into Chart.js `options` (e.g. { responsive: false })
25
+ # colors: optional array overriding default --chart-1…5 palette for datasets
25
26
 
26
27
  TYPES = %w[bar line pie doughnut radar polarArea].freeze
27
28
 
28
- WRAPPER_CLS = "relative"
29
+ WRAPPER_CLS = "flex aspect-video w-full justify-center text-xs"
29
30
 
30
- def initialize(type: :bar, labels: [], datasets: [], options: {}, **html_attrs)
31
+ def initialize(type: :bar, labels: [], datasets: [], options: {}, colors: nil, **html_attrs)
31
32
  @type = TYPES.include?(type.to_s) ? type.to_s : "bar"
32
33
  @labels = labels
33
34
  @datasets = datasets
34
35
  @chart_options = options
36
+ @colors = colors
35
37
  @extra_class = html_attrs.delete(:class)
36
38
  @html_attrs = html_attrs
37
39
  end
38
40
 
39
41
  def call
40
- content_tag(:div, class: cn(WRAPPER_CLS, @extra_class)) do
42
+ content_tag(:div,
43
+ class: cn(WRAPPER_CLS, @extra_class),
44
+ data: { slot: "chart" }) do
41
45
  tag.canvas(
42
46
  data: {
43
47
  controller: "chart",
@@ -53,7 +57,9 @@ module UI
53
57
 
54
58
  def config_json
55
59
  ds = @datasets.map { |d| camelize_keys(d).compact }
56
- {labels: @labels, datasets: ds, options: @chart_options}.to_json
60
+ payload = {labels: @labels, datasets: ds, options: @chart_options}
61
+ payload[:colors] = @colors if @colors
62
+ payload.to_json
57
63
  end
58
64
 
59
65
  def camelize_keys(hash)
@@ -5,6 +5,14 @@ import { Chart, registerables } from "chart.js"
5
5
 
6
6
  Chart.register(...registerables)
7
7
 
8
+ const DEFAULT_COLORS = [
9
+ "var(--chart-1)",
10
+ "var(--chart-2)",
11
+ "var(--chart-3)",
12
+ "var(--chart-4)",
13
+ "var(--chart-5)"
14
+ ]
15
+
8
16
  export default class extends Controller {
9
17
  static values = {
10
18
  type: { type: String, default: "bar" },
@@ -14,11 +22,26 @@ export default class extends Controller {
14
22
  #chart = null
15
23
 
16
24
  connect() {
17
- const { labels, datasets, options = {} } = JSON.parse(this.configValue)
25
+ const { labels, datasets, options = {}, colors } = JSON.parse(this.configValue)
26
+ const palette = colors?.length ? colors : DEFAULT_COLORS
27
+ const coloredDatasets = datasets.map((dataset, index) => {
28
+ const color = palette[index % palette.length]
29
+ return {
30
+ ...dataset,
31
+ backgroundColor: dataset.backgroundColor ?? color,
32
+ borderColor: dataset.borderColor ?? color
33
+ }
34
+ })
35
+
18
36
  this.#chart = new Chart(this.element, {
19
37
  type: this.typeValue,
20
- data: { labels, datasets },
21
- options: { responsive: true, maintainAspectRatio: true, ...options }
38
+ data: { labels, datasets: coloredDatasets },
39
+ options: {
40
+ responsive: true,
41
+ maintainAspectRatio: true,
42
+ color: "var(--muted-foreground)",
43
+ ...options
44
+ }
22
45
  })
23
46
  }
24
47
 
@@ -5,9 +5,9 @@ module UI
5
5
  # sent: true → right-aligned, primary-colored bubble
6
6
  # sent: false → left-aligned, muted bubble (default)
7
7
 
8
- BUBBLE_BASE = "max-w-[80%] rounded-2xl px-4 py-2 text-sm leading-relaxed"
9
- BUBBLE_SENT = "bg-primary text-primary-foreground rounded-br-none"
10
- BUBBLE_RECV = "bg-muted text-foreground rounded-bl-none"
8
+ BUBBLE_BASE = "max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed shadow-xs"
9
+ BUBBLE_SENT = "rounded-br-md bg-primary text-primary-foreground"
10
+ BUBBLE_RECV = "rounded-bl-md #{UI::Styles::BORDER} bg-muted text-foreground"
11
11
 
12
12
  TIMESTAMP_BASE = "mt-1 text-xs text-muted-foreground"
13
13
 
@@ -37,7 +37,7 @@ module UI
37
37
  content_tag(:img, nil,
38
38
  src: @avatar,
39
39
  alt: "",
40
- class: "size-7 rounded-full object-cover shrink-0",
40
+ class: "size-8 shrink-0 rounded-full object-cover ring-2 ring-background",
41
41
  "aria-hidden": "true")
42
42
  end
43
43
 
@@ -3,7 +3,7 @@
3
3
  module UI
4
4
  class CheckboxComponent < ApplicationComponent
5
5
  BASE = "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none " \
6
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
6
+ "#{UI::Styles::FOCUS_RING} " \
7
7
  "disabled:cursor-not-allowed disabled:opacity-50 " \
8
8
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
9
9
  "checked:border-primary checked:bg-primary checked:text-primary-foreground " \
@@ -6,9 +6,12 @@ module UI
6
6
  # trigger slot: content for the summary row (button, icon, label, etc.)
7
7
  # open: render pre-expanded (default: false)
8
8
 
9
- SUMMARY_CLS = "flex cursor-pointer list-none items-center justify-between gap-2 " \
9
+ SUMMARY_CLS = "flex cursor-pointer list-none items-center justify-between gap-4 rounded-md py-2 " \
10
+ "text-left text-sm font-medium transition-all outline-none " \
11
+ "hover:underline " \
12
+ "#{UI::Styles::FOCUS_RING} " \
10
13
  "[&::-webkit-details-marker]:hidden"
11
- CONTENT_CLS = "mt-2"
14
+ CONTENT_CLS = "pb-2 pt-0 text-sm text-muted-foreground"
12
15
 
13
16
  renders_one :trigger
14
17
 
@@ -19,12 +22,16 @@ module UI
19
22
  end
20
23
 
21
24
  def call
22
- attrs = { class: cn(@extra_class), **@html_attrs }
25
+ attrs = { class: cn("group", @extra_class), data: { slot: "collapsible" }, **@html_attrs }
23
26
  attrs[:open] = true if @open
24
27
 
25
28
  content_tag(:details, **attrs) do
26
- concat content_tag(:summary, trigger, class: SUMMARY_CLS)
27
- concat content_tag(:div, content, class: CONTENT_CLS)
29
+ concat content_tag(:summary, trigger,
30
+ class: SUMMARY_CLS,
31
+ data: { slot: "collapsible-trigger" })
32
+ concat content_tag(:div, content,
33
+ class: CONTENT_CLS,
34
+ data: { slot: "collapsible-content" })
28
35
  end
29
36
  end
30
37
  end
@@ -2,13 +2,10 @@
2
2
 
3
3
  module UI
4
4
  class ComboboxComponent < ApplicationComponent
5
- INPUT = "flex h-9 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs " \
6
- "placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
7
- PANEL = "absolute z-50 top-full left-0 mt-1 w-full overflow-hidden rounded-md border " \
8
- "bg-popover text-popover-foreground shadow-md"
5
+ INPUT = UI::Styles::INPUT
6
+ PANEL = "#{UI::Styles::POPOVER_PANEL} top-full left-0 mt-1 w-full overflow-hidden"
9
7
  LIST = "max-h-[200px] overflow-y-auto p-1"
10
- OPTION = "relative flex w-full cursor-pointer select-none items-center rounded-sm " \
11
- "px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
8
+ OPTION = "#{UI::Styles::MENU_ITEM} w-full cursor-pointer hover:bg-accent hover:text-accent-foreground"
12
9
  EMPTY = "py-4 text-center text-sm text-muted-foreground"
13
10
 
14
11
  def initialize(name:, options: [], value: nil, placeholder: "Select...", **html_attrs)
@@ -4,10 +4,12 @@ module UI
4
4
  class CommandComponent < ApplicationComponent
5
5
  renders_one :trigger
6
6
 
7
- OVERLAY = "fixed inset-0 z-50 bg-black/80"
8
- DIALOG = "fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] " \
9
- "overflow-hidden rounded-lg border bg-background shadow-lg"
10
- SEARCH = "flex h-10 w-full items-center gap-2 border-b px-3"
7
+ OVERLAY = UI::Styles::OVERLAY
8
+ DIALOG = "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
9
+ "translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg #{UI::Styles::BORDER} bg-background " \
10
+ "p-0 shadow-lg duration-200 sm:max-w-lg"
11
+ COMMAND = "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground"
12
+ SEARCH = "flex h-9 items-center gap-2 border-b border-border px-3"
11
13
  LIST = "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto"
12
14
  EMPTY = "py-6 text-center text-sm text-muted-foreground"
13
15
 
@@ -16,15 +18,14 @@ module UI
16
18
  # Apply to the heading element (p/span) inside a group wrapper.
17
19
  GROUP = "px-2 py-1.5 text-xs font-medium text-muted-foreground"
18
20
  # Apply to each actionable item button/link.
19
- ITEM = "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm " \
20
- "px-2 py-1.5 text-sm outline-none " \
21
- "hover:bg-accent hover:text-accent-foreground " \
21
+ ITEM = "#{UI::Styles::MENU_ITEM} w-full cursor-default data-[selected=true]:bg-accent " \
22
+ "data-[selected=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground " \
22
23
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
23
24
  "[&_svg:not([class*='text-'])]:text-muted-foreground"
24
25
  # Place inside an ITEM as the last child to show a keyboard shortcut on the right.
25
26
  SHORTCUT = "ml-auto text-xs tracking-widest text-muted-foreground"
26
- # Horizontal rule between groups (use a plain <hr> tag).
27
- SEPARATOR = "-mx-1 h-px bg-border"
27
+ # Horizontal rule between groups (`<div role="separator">`).
28
+ SEPARATOR = UI::Styles::MENU_SEPARATOR
28
29
 
29
30
  def initialize(**html_attrs)
30
31
  @extra_class = html_attrs.delete(:class)
@@ -51,14 +52,16 @@ module UI
51
52
  role: "dialog",
52
53
  "aria-modal": "true",
53
54
  data: { action: "keydown.escape@window->command#close" }) {
54
- concat search_bar
55
- concat content_tag(:div, class: LIST, data: { command_target: "list" }) {
56
- concat content
55
+ concat content_tag(:div, class: COMMAND) {
56
+ concat search_bar
57
+ concat content_tag(:div, class: LIST, data: { command_target: "list" }) {
58
+ concat content
59
+ }
60
+ concat content_tag(:div, "No results found.",
61
+ class: EMPTY,
62
+ data: { command_target: "empty" },
63
+ hidden: true)
57
64
  }
58
- concat content_tag(:div, "No results found.",
59
- class: EMPTY,
60
- data: { command_target: "empty" },
61
- hidden: true)
62
65
  }
63
66
  end
64
67
  end
@@ -69,7 +72,8 @@ module UI
69
72
  concat tag.input(
70
73
  type: "text",
71
74
  placeholder: "Type a command or search...",
72
- class: "flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground",
75
+ class: "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden " \
76
+ "placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
73
77
  data: {
74
78
  command_target: "input",
75
79
  action: "input->command#filter"
@@ -79,7 +83,7 @@ module UI
79
83
  end
80
84
 
81
85
  def search_icon
82
- 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="shrink-0 text-muted-foreground" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>')
86
+ 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 shrink-0 opacity-50" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>')
83
87
  end
84
88
  end
85
89
  end
@@ -10,6 +10,7 @@ export default class extends Controller {
10
10
 
11
11
  disconnect() {
12
12
  document.removeEventListener("keydown", this._onKeydown)
13
+ this._releaseFocusTrap()
13
14
  }
14
15
 
15
16
  _onKeydown(event) {
@@ -22,6 +23,12 @@ export default class extends Controller {
22
23
  open() {
23
24
  this.panelTarget.hidden = false
24
25
  document.body.style.overflow = "hidden"
26
+
27
+ this._previouslyFocused = document.activeElement
28
+ this._dialog = this.panelTarget.querySelector("[role=dialog]")
29
+ this._trapFocus = this.#trapFocus.bind(this)
30
+ this._dialog?.addEventListener("keydown", this._trapFocus)
31
+
25
32
  this.inputTarget.value = ""
26
33
  this.inputTarget.focus()
27
34
  this.filter()
@@ -30,6 +37,7 @@ export default class extends Controller {
30
37
  close() {
31
38
  this.panelTarget.hidden = true
32
39
  document.body.style.overflow = ""
40
+ this._releaseFocusTrap()
33
41
  }
34
42
 
35
43
  filter() {
@@ -47,4 +55,46 @@ export default class extends Controller {
47
55
  const totalVisible = Array.from(items).filter(i => !i.hidden).length
48
56
  this.emptyTarget.hidden = totalVisible > 0
49
57
  }
58
+
59
+ #trapFocus(event) {
60
+ if (event.key !== "Tab" || !this._dialog) return
61
+
62
+ const focusable = this.#focusableElements(this._dialog)
63
+ if (focusable.length === 0) return
64
+
65
+ const first = focusable[0]
66
+ const last = focusable[focusable.length - 1]
67
+
68
+ if (event.shiftKey && document.activeElement === first) {
69
+ event.preventDefault()
70
+ last.focus()
71
+ } else if (!event.shiftKey && document.activeElement === last) {
72
+ event.preventDefault()
73
+ first.focus()
74
+ }
75
+ }
76
+
77
+ #focusableElements(container) {
78
+ if (!container) return []
79
+
80
+ const selector = [
81
+ "a[href]",
82
+ "button:not([disabled])",
83
+ "input:not([disabled])",
84
+ "select:not([disabled])",
85
+ "textarea:not([disabled])",
86
+ "[tabindex]:not([tabindex='-1'])",
87
+ ].join(", ")
88
+
89
+ return Array.from(container.querySelectorAll(selector)).filter(
90
+ (el) => !el.hasAttribute("hidden") && el.offsetParent !== null
91
+ )
92
+ }
93
+
94
+ _releaseFocusTrap() {
95
+ this._dialog?.removeEventListener("keydown", this._trapFocus)
96
+ this._previouslyFocused?.focus?.()
97
+ this._previouslyFocused = null
98
+ this._dialog = null
99
+ }
50
100
  }
@@ -4,14 +4,15 @@ module UI
4
4
  class ContextMenuComponent < ApplicationComponent
5
5
  renders_one :menu
6
6
 
7
- PANEL = "fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 " \
8
- "text-popover-foreground shadow-md"
9
- ITEM = "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm " \
10
- "px-2 py-1.5 text-sm outline-none " \
11
- "hover:bg-accent hover:text-accent-foreground " \
12
- "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
13
- "[&_svg:not([class*='text-'])]:text-muted-foreground"
14
- SEPARATOR = "-mx-1 my-1 h-px bg-border"
7
+ PANEL = "#{UI::Styles::POPOVER_PANEL} fixed min-w-[8rem] overflow-x-hidden overflow-y-auto p-1"
8
+
9
+ ITEM = "#{UI::Styles::MENU_ITEM} w-full hover:bg-accent hover:text-accent-foreground " \
10
+ "data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 " \
11
+ "dark:data-[variant=destructive]:focus:bg-destructive/20 " \
12
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " \
13
+ "[&_svg:not([class*='text-'])]:text-muted-foreground"
14
+
15
+ SEPARATOR = UI::Styles::MENU_SEPARATOR
15
16
  LABEL_CLS = "px-2 py-1.5 text-sm font-medium text-foreground"
16
17
 
17
18
  def initialize(**html_attrs)
@@ -3,29 +3,36 @@
3
3
  module UI
4
4
  class DataTableComponent < ApplicationComponent
5
5
  # Sortable, filterable data table with client-side pagination.
6
+ # Table cell styles follow shadcn/ui Table primitives.
6
7
  #
7
8
  # columns: array of { key:, label:, sortable: true }
8
9
  # rows: array of hashes (keys must match column keys)
9
10
  # per_page: rows per page (default 10, 0 = no pagination)
10
11
  # caption: optional <caption> text
11
12
 
12
- WRAPPER = "w-full overflow-auto rounded-lg border border-border"
13
- TOOLBAR = "flex items-center gap-3 border-b border-border bg-background px-4 py-3"
14
- SEARCH_CLS = "flex h-8 flex-1 items-center gap-2 rounded-md border border-input bg-background " \
15
- "px-3 text-sm text-muted-foreground focus-within:border-ring focus-within:ring-[3px] " \
16
- "focus-within:ring-ring/50 transition"
17
- SEARCH_INPUT = "w-full bg-transparent outline-none placeholder:text-muted-foreground text-foreground text-sm"
13
+ WRAPPER = "w-full overflow-hidden rounded-md #{UI::Styles::BORDER} bg-background"
14
+ TABLE_WRAP = "relative w-full overflow-x-auto"
15
+ TOOLBAR = "flex items-center gap-3 border-b border-border px-4 py-3"
16
+ SEARCH_CLS = "flex h-9 w-full max-w-sm items-center gap-2 rounded-md border border-input bg-transparent px-3 " \
17
+ "text-sm shadow-xs transition-[color,box-shadow] " \
18
+ "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 " \
19
+ "dark:bg-input/30"
20
+ SEARCH_INPUT = "w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
18
21
  TABLE_CLS = "w-full caption-bottom text-sm"
19
- THEAD_CLS = "bg-muted/40"
20
- TH_CLS = "h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap"
21
- TH_SORT = "cursor-pointer select-none hover:text-foreground transition-colors"
22
- TR_CLS = "border-t border-border transition-colors hover:bg-muted/30"
23
- TD_CLS = "px-4 py-3 align-middle"
24
- FOOTER_CLS = "flex items-center justify-between border-t border-border bg-background px-4 py-3 " \
25
- "text-sm text-muted-foreground"
26
- PAGE_BTN = "inline-flex h-8 w-8 items-center justify-center rounded-md border border-border " \
27
- "hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none " \
28
- "disabled:opacity-40 transition"
22
+ THEAD_CLS = "[&_tr]:border-b [&_tr]:border-border"
23
+ TH_CLS = "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground"
24
+ TH_SORT = "cursor-pointer select-none transition-colors hover:text-foreground/80"
25
+ TBODY_CLS = "[&_tr:last-child]:border-0"
26
+ TR_CLS = "border-b border-border transition-colors hover:bg-muted/50"
27
+ TD_CLS = "p-2 align-middle whitespace-nowrap"
28
+ CAPTION_CLS = "mt-4 text-sm text-muted-foreground"
29
+ FOOTER_CLS = "flex items-center justify-between gap-4 border-t border-border px-4 py-3 text-sm text-muted-foreground"
30
+ PAGE_BTN = "inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input bg-background " \
31
+ "text-sm font-medium shadow-xs transition-all outline-none " \
32
+ "hover:bg-accent hover:text-accent-foreground " \
33
+ "#{UI::Styles::FOCUS_RING} " \
34
+ "disabled:pointer-events-none disabled:opacity-50 " \
35
+ "dark:border-input dark:bg-input/30 dark:hover:bg-input/50"
29
36
  SORT_ASC = "▲"
30
37
  SORT_DESC = "▼"
31
38
 
@@ -48,7 +55,7 @@ module UI
48
55
  },
49
56
  **@html_attrs) do
50
57
  concat toolbar
51
- concat table_element
58
+ concat table_section
52
59
  concat footer if @per_page > 0
53
60
  end
54
61
  end
@@ -74,14 +81,20 @@ module UI
74
81
  end
75
82
  end
76
83
 
77
- def table_element
78
- content_tag(:table, class: TABLE_CLS) do
79
- concat content_tag(:caption, @caption, class: "mt-2 text-sm text-muted-foreground") if @caption
80
- concat thead
81
- concat tbody
84
+ def table_section
85
+ content_tag(:div, class: TABLE_WRAP) do
86
+ content_tag(:table, class: TABLE_CLS) do
87
+ concat thead
88
+ concat tbody
89
+ concat caption_element if @caption
90
+ end
82
91
  end
83
92
  end
84
93
 
94
+ def caption_element
95
+ content_tag(:caption, @caption, class: CAPTION_CLS)
96
+ end
97
+
85
98
  def thead
86
99
  content_tag(:thead, class: THEAD_CLS) do
87
100
  content_tag(:tr) do
@@ -98,7 +111,8 @@ module UI
98
111
  content_tag(:th, class: cn(TH_CLS, sortable ? TH_SORT : nil),
99
112
  data: sortable ? {
100
113
  action: "click->data-table#sort",
101
- data_table_key_param: key
114
+ data_table_key_param: key,
115
+ data_table_column_key: key
102
116
  } : {}) do
103
117
  content_tag(:span, class: "flex items-center gap-1") do
104
118
  concat label
@@ -115,7 +129,7 @@ module UI
115
129
  end
116
130
 
117
131
  def tbody
118
- content_tag(:tbody, data: { data_table_target: "body" }) do
132
+ content_tag(:tbody, class: TBODY_CLS, data: { data_table_target: "body" }) do
119
133
  safe_join(@rows.map { |row| tr_row(row) })
120
134
  end
121
135
  end
@@ -132,15 +146,32 @@ module UI
132
146
 
133
147
  def footer
134
148
  content_tag(:div, class: FOOTER_CLS) do
135
- concat content_tag(:span, "Page 1",
149
+ concat content_tag(:span, "",
136
150
  data: { data_table_target: "pageLabel" })
137
- concat(content_tag(:div, class: "flex items-center gap-1") {
138
- concat page_btn("‹", "click->data-table#prevPage", "Previous page")
139
- concat page_btn("›", "click->data-table#nextPage", "Next page")
151
+ concat(content_tag(:div, class: "flex shrink-0 items-center gap-1") {
152
+ concat page_btn(prev_icon, "click->data-table#prevPage", "Previous page")
153
+ concat page_btn(next_icon, "click->data-table#nextPage", "Next page")
140
154
  })
141
155
  end
142
156
  end
143
157
 
158
+ def prev_icon
159
+ chevron_svg("left")
160
+ end
161
+
162
+ def next_icon
163
+ chevron_svg("right")
164
+ end
165
+
166
+ def chevron_svg(direction)
167
+ path = direction == "left" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"
168
+ content_tag(:svg, content_tag(:path, nil, d: path),
169
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
170
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
171
+ "stroke-linecap": "round", "stroke-linejoin": "round",
172
+ class: "size-4", "aria-hidden": "true")
173
+ end
174
+
144
175
  def page_btn(label, action, aria)
145
176
  content_tag(:button, label, type: "button",
146
177
  class: PAGE_BTN,
@@ -157,7 +188,7 @@ module UI
157
188
  xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
158
189
  fill: "none", stroke: "currentColor", "stroke-width": "2",
159
190
  "stroke-linecap": "round", "stroke-linejoin": "round",
160
- class: "size-4 shrink-0", "aria-hidden": "true")
191
+ class: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true")
161
192
  end
162
193
  end
163
194
  end
@@ -97,8 +97,8 @@ export default class extends Controller {
97
97
  }
98
98
 
99
99
  #columnIndex(key) {
100
- const headers = this.element.querySelectorAll("th[data-data-table-key-param]")
101
- return Array.from(headers).findIndex(h => h.dataset.dataTableKeyParam === key)
100
+ const headers = this.element.querySelectorAll("th[data-data-table-column-key]")
101
+ return Array.from(headers).findIndex(h => h.dataset.dataTableColumnKey === key)
102
102
  }
103
103
 
104
104
  get #totalPages() {
@@ -10,13 +10,11 @@ module UI
10
10
  # min/max: Date bounds passed to the calendar
11
11
 
12
12
  WRAPPER = "relative inline-block"
13
- TRIGGER = "flex h-9 w-48 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-0 shadow-md data-[open=true]:block"
13
+ TRIGGER = "#{UI::Styles::PICKER_TRIGGER} w-48"
14
+ ICON_CLS = "size-4 shrink-0 text-muted-foreground pointer-events-none"
15
+ LABEL_PLACEHOLDER = "text-muted-foreground"
16
+ # Positioning shell only — visual chrome comes from CalendarComponent::CONTAINER
17
+ POPOVER = "absolute left-0 top-full z-50 mt-2 hidden w-max data-[open=true]:block"
20
18
 
21
19
  def initialize(value: nil, name: nil, placeholder: "Pick a date", min: nil, max: nil, **html_attrs)
22
20
  @value = value
@@ -58,7 +56,9 @@ module UI
58
56
  action: "click->date-picker#toggle"
59
57
  }) do
60
58
  concat calendar_icon
61
- concat content_tag(:span, label_text, data: { date_picker_target: "label" })
59
+ concat content_tag(:span, label_text,
60
+ class: (@value ? nil : LABEL_PLACEHOLDER),
61
+ data: { date_picker_target: "label" })
62
62
  end
63
63
  end
64
64