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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c95b5b096babd8416b53c83c883124d89f992814d19ef7d262c731abc9ac5ffa
4
- data.tar.gz: 16744aa30b37e6bad9995dc8e792b5fdbe28300555577aab8361db78f4cc4e6f
3
+ metadata.gz: cc1d6eea996ab223f1175ccb81a475051e949ee3a7bf1f0c9d9102b5b744a8f8
4
+ data.tar.gz: b41ca1269df44e350e40e87d039b5ac2c97385bdf638e37a89da40ed8b463895
5
5
  SHA512:
6
- metadata.gz: 5b588f79626af5d772a3616518198def45d7e918ff703fb937ce32ff17cc78edc135452b22243c2b14e878f6076149ea647cb93f65113deb88ca70dc96e99417
7
- data.tar.gz: 18f9fca991217855a0c169ca9b8d8c44cbca7f31e5e642f851b366e71b43ddc5960afcc4145e197dd1e50e7885001dc351cfe4cc96134a08c22fcde6f8e5df8c
6
+ metadata.gz: 32adf0c91b8b894420a2441b53ea82c25bc0e8fc542e89b33aec7c64cdaa3ef292d0b3550e9fbc83924ec0873560b41686c1d6c6ac4198cacdeadb43b19e3c31
7
+ data.tar.gz: 6b669fe4ecb2c66b42f952da057e658a1a6d0f20c71550a10fca288b2af91259f6609d3e2925a901bef0bd3e10f4604496f1c2e0a31a8363e7ea1e0b8b4a3a30
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-06-08
11
+
12
+ ### Added
13
+
14
+ - **TagsInput** — multi-select input that renders selected values as removable chips; submits as `name[]`; supports pre-selected `values:`, keyboard navigation (Enter to select, Backspace to remove last chip, Escape to close), and filterable dropdown
15
+ - `rails g view_primitives:update` — refresh installed components, CSS bundle, and `UI::Styles` from gem templates; supports `--only`, `--skip-components`, `--skip-css`, `--skip-styles`, `--force`
16
+ - `rails g view_primitives:theme <name>` — install an optional color theme (e.g. `rose`) and enable its `@import` in `view_primitives.css`
17
+ - `UI::Styles` module (`app/components/ui/styles.rb`) — shared primitive class names: `FOCUS_RING`, `BORDER`, `OVERLAY`, `INPUT`, `MENU_SEPARATOR`, `FIELD_PANEL`, `PICKER_TRIGGER`, etc.
18
+ - CSS bundle split into `tokens.css`, `utilities.css` (`vp-*` primitives), and `themes/` — run `rails g view_primitives:update --skip-components` to pull in the new structure
19
+ - `--force` flag on both `add` and `update` generators — skips the per-file overwrite confirmation prompt
20
+ - Focus trap in `dialog`, `alert_dialog`, `sheet`, `drawer`, and `command` Stimulus controllers
21
+ - Mobile nav panel in `NavbarComponent` with working hamburger toggle
22
+ - Optional `tailwind_merge` support in `cn()` when the gem is present in the project
23
+ - [docs/components/README.md](docs/components/README.md) — shared prerequisites guide
24
+
25
+ ### Changed
26
+
27
+ - ~45 component templates migrated to `UI::Styles` constants and `vp-*` utilities for consistent theming
28
+ - `add` generator prompts for confirmation before overwriting an existing file (previously overwrote silently); use `--force` to restore previous behaviour
29
+ - `update` generator default changed to prompt before overwriting; use `--force` to overwrite unconditionally
30
+ - Sheet and drawer share `dialog_controller.js` instead of maintaining separate Stimulus controllers
31
+ - **DataTable**, **Accordion**, **DeviceMockup**, **Navbar**, **BottomNav**, **Footer**, **Drawer**, **Sheet**, **ListGroup**, and menu components visually realigned with the shadcn/ui reference
32
+
33
+ ### Fixed
34
+
35
+ - `add` and `update` generators recorded a failed component copy as success when the template directory was missing
36
+ - `add` generator printed a misleading "Copied" summary line for components with a missing template directory
37
+ - Setup notes (e.g. Chart.js importmap instructions) were never printed when the component list also contained an unknown name, because `abort` fired before `report_setup_notes` ran
38
+ - `ComponentCopier#with_source_root` used `source_paths.shift` in `ensure` — replaced with `source_paths.delete(path)` to always remove the correct entry
39
+ - `Components.supported` included non-directory entries from `Dir.children`; now filters to directories only
40
+ - `copy_js_controller` did not guard against an empty stimulus name (edge case: file named `_controller.js`)
41
+ - **Avatar** `lg` size accidentally reduced from `size-12` to `size-10` in the data-attribute refactor; restored to 48 px
42
+ - **Avatar** emitted `data-size="default"` on every default-size avatar; attribute is now omitted for `:default`
43
+ - **AlertDialog** content div lost `text-sm text-muted-foreground`; restored
44
+ - **Breadcrumb** inactive links lost `text-muted-foreground`; restored
45
+ - Sheet and drawer Stimulus actions used `sheet#` / `drawer#` targets while the controller was `dialog`; corrected to `dialog#open` / `dialog#close`
46
+ - `install --force` skipped `ApplicationComponent` regeneration; fixed
47
+ - Bare `border` / `border-b` / `border-t` classes without a color token rendered as black lines on some themes; replaced with `border-border`
48
+
10
49
  ## [0.1.3] - 2026-06-04
11
50
 
12
51
  ### Fixed
@@ -102,4 +141,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102
141
  - `view_primitives:add` exits with status 1 on unknown components; prints copy summary
103
142
  - Requires `view_component >= 4.0` and Rails `>= 7.1`
104
143
 
144
+ [0.2.0]: https://github.com/alec-c4/view_primitives/releases/tag/v0.2.0
105
145
  [0.1.0]: https://github.com/alec-c4/view_primitives/releases/tag/v0.1.0
data/README.md CHANGED
@@ -28,11 +28,13 @@ Then run the install generator:
28
28
 
29
29
  ```bash
30
30
  rails g view_primitives:install
31
+ rails g view_primitives:install --force # refresh ApplicationComponent and CSS on upgrade
31
32
  ```
32
33
 
33
34
  This will:
34
- - Create `app/components/application_component.rb` with `ViewPrimitives::ClassHelper` included (skipped if you already have one — add `include ViewPrimitives::ClassHelper` manually)
35
- - Create `app/assets/stylesheets/view_primitives.css` with the design token definitions (`@theme inline` + oklch light/dark theme)
35
+ - Create `app/components/application_component.rb` with `ViewPrimitives::ClassHelper`, `cn()`, and `extract_html_attrs` (skipped if you already have one — use `--force` to overwrite)
36
+ - Create `app/components/ui/styles.rb` with shared Tailwind primitive class names (`UI::Styles::*`)
37
+ - Create `app/assets/stylesheets/view_primitives.css` (entry file) plus `view_primitives/tokens.css`, `utilities.css`, and `themes/default.css`
36
38
 
37
39
  Then import it in your Tailwind CSS entry point:
38
40
 
@@ -52,6 +54,31 @@ Components live under `UI::` (files in `app/components/ui/`). The gem registers
52
54
 
53
55
  That's it — no `tailwind.config.js` required. Tailwind 4 reads the `@theme inline` block directly from the CSS.
54
56
 
57
+ ### CSS layout
58
+
59
+ ```
60
+ app/assets/stylesheets/
61
+ view_primitives.css # entry — @import tokens, utilities, themes
62
+ view_primitives/
63
+ tokens.css # @theme inline design tokens
64
+ utilities.css # vp-* shared primitives
65
+ themes/
66
+ default.css # :root and .dark color variables
67
+ rose.css # optional — via view_primitives:theme
68
+ ```
69
+
70
+ ### Generators
71
+
72
+ | Generator | Purpose |
73
+ |-----------|---------|
74
+ | `view_primitives:install` | Base setup — ApplicationComponent, styles.rb, CSS bundle |
75
+ | `view_primitives:add` | Copy one or more components into the app |
76
+ | `view_primitives:update` | Refresh installed components, CSS, and styles.rb from the gem |
77
+ | `view_primitives:theme` | Install an optional color theme |
78
+ | `view_primitives:list` | List available and installed components |
79
+
80
+ See [docs/components/README.md](docs/components/README.md) for setup details, Stimulus notes, and `class:` overrides.
81
+
55
82
  ## Adding components
56
83
 
57
84
  ```bash
@@ -62,6 +89,32 @@ rails g view_primitives:add button alert accordion # multiple at once
62
89
 
63
90
  Each component is copied into `app/components/ui/` as plain Ruby and ERB files you own and can modify freely. Re-running `add` overwrites existing files (a warning is printed). Unknown component names fail with a non-zero exit code.
64
91
 
92
+ Component docs live in [docs/components/](docs/components/). Start with [component setup](docs/components/README.md) if you have not run `install` yet.
93
+
94
+ ### Updating installed components
95
+
96
+ After upgrading the gem, refresh every component you have already copied:
97
+
98
+ ```bash
99
+ rails g view_primitives:update
100
+ rails g view_primitives:update --only button input # selected components
101
+ rails g view_primitives:update --skip-css # components only
102
+ ```
103
+
104
+ The update generator overwrites installed component files, the CSS bundle, and `styles.rb` from the latest gem templates. Back up local edits first.
105
+
106
+ ### Optional color themes
107
+
108
+ ```bash
109
+ rails g view_primitives:theme rose
110
+ ```
111
+
112
+ This copies the theme CSS and enables its `@import` in `view_primitives.css`. Apply it with `data-theme="rose"` on your layout root (`.dark` still works for dark mode).
113
+
114
+ ### Optional `tailwind_merge`
115
+
116
+ `cn()` deduplicates class strings. For Tailwind-aware conflict resolution, add the `tailwind_merge` gem to your Gemfile — ViewPrimitives picks it up automatically when present.
117
+
65
118
  ## View helpers
66
119
 
67
120
  ViewPrimitives adds the `ui` helper to views and mailers:
@@ -139,6 +192,7 @@ ViewPrimitives adds the `ui` helper to views and mailers:
139
192
  | Menubar | Horizontal application-style menu bar | [docs](docs/components/menubar.md) |
140
193
  | Command | Modal command palette with live search filtering | [docs](docs/components/command.md) |
141
194
  | Combobox | Autocomplete select with live search | [docs](docs/components/combobox.md) |
195
+ | Tags Input | Multi-select input that renders selected values as removable chips | [docs](docs/components/tags_input.md) |
142
196
  | Calendar | Date picker calendar grid | [docs](docs/components/calendar.md) |
143
197
  | Date Picker | Input that opens a Calendar popover | [docs](docs/components/date_picker.md) |
144
198
  | Timepicker | Input for selecting a time value | [docs](docs/components/timepicker.md) |
@@ -174,6 +228,7 @@ See [ROADMAP.md](ROADMAP.md) for the full component list organised by phase.
174
228
  See **[docs/customization.md](docs/customization.md)** for the full guide covering:
175
229
 
176
230
  - Design tokens (OKLCH colors, radius) — change the whole palette in one file
231
+ - Shared `UI::Styles` primitives and border conventions (`border-border`, `MENU_SEPARATOR`)
177
232
  - Editing component constants — add variants, change classes
178
233
  - Per-instance `class:` overrides — append utilities without touching the file
179
234
  - Full brand theming example
@@ -1,25 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../components"
4
+ require_relative "../component_copier"
4
5
  require_relative "../detector"
5
6
 
6
7
  module ViewPrimitives
7
8
  module Generators
8
9
  class AddGenerator < Rails::Generators::Base
9
10
  include Detector
11
+ include ComponentCopier
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
 
13
15
  argument :components, type: :array
14
16
 
17
+ class_option :force, type: :boolean, default: false,
18
+ desc: "Overwrite existing files without prompting"
19
+
15
20
  def copy_components
16
21
  @copied = []
17
22
  @unknown = []
18
23
 
19
24
  components.each do |name|
20
25
  if Components.supported.include?(name)
21
- copy_component(name)
22
- @copied << name
26
+ @copied << name if copy_component(name)
23
27
  else
24
28
  @unknown << name
25
29
  say " Unknown component: #{name}. Supported: #{Components.supported.join(", ")}", :red
@@ -34,7 +38,7 @@ module ViewPrimitives
34
38
 
35
39
  say " Failed: #{@unknown.join(", ")} (unknown)", :red
36
40
  say " Run `rails g view_primitives:list` to see all available components.", :cyan
37
- abort
41
+ @abort_after_notes = true
38
42
  end
39
43
 
40
44
  def report_setup_notes
@@ -47,65 +51,7 @@ module ViewPrimitives
47
51
  note.each_line { |line| say " #{line.chomp}", :cyan }
48
52
  say ""
49
53
  end
50
- end
51
-
52
- no_tasks do
53
- def template(source, *args, **options, &block)
54
- destination = args.first || options[:to]
55
- warn_overwrite(destination) if destination
56
- super
57
- end
58
-
59
- def copy_file(source, *args, **options)
60
- destination = args.first || options[:to]
61
- warn_overwrite(destination) if destination
62
- super
63
- end
64
- end
65
-
66
- private
67
-
68
- def copy_component(name)
69
- dir = File.join(self.class.source_root, name)
70
- Dir.each_child(dir).sort.each { |file| copy_template_file(name, file) }
71
- copy_extra_stimulus(name)
72
- end
73
-
74
- def copy_template_file(component, file)
75
- source = "#{component}/#{file}"
76
-
77
- case file
78
- when /\.rb\.tt\z/
79
- template source, "app/components/ui/#{file.delete_suffix(".tt")}"
80
- when /\.html\.erb\z/
81
- copy_file source, "app/components/ui/#{file}"
82
- when /_controller\.js\z/
83
- copy_js_controller source, file.delete_suffix("_controller.js")
84
- end
85
- end
86
-
87
- def copy_extra_stimulus(name)
88
- config = Components::EXTRA_STIMULUS[name]
89
- copy_js_controller(config[:source], config[:name]) if config
90
- end
91
-
92
- def copy_js_controller(source, stimulus_name)
93
- dir = js_controllers_dir
94
- unless dir
95
- say " Could not detect a JS controllers directory.", :yellow
96
- say " Copy #{source} manually and register Stimulus `#{stimulus_name}`.", :cyan
97
- return
98
- end
99
-
100
- dest = "#{dir}/#{stimulus_name}_controller.js"
101
- copy_file source, dest
102
- say " Stimulus `#{stimulus_name}` → #{dest}", :green
103
- end
104
-
105
- def warn_overwrite(destination)
106
- return unless File.exist?(File.join(destination_root, destination))
107
-
108
- say " #{destination} already exists — overwriting.", :yellow
54
+ abort if @abort_after_notes
109
55
  end
110
56
  end
111
57
  end
@@ -2,11 +2,20 @@
2
2
 
3
3
  module UI
4
4
  class AccordionItemComponent < ApplicationComponent
5
- SUMMARY_CLASSES = "flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium " \
6
- "transition-all outline-none hover:underline cursor-pointer list-none " \
7
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
5
+ ITEM_CLASSES = "border-b border-border last:border-b-0 group"
6
+
7
+ SUMMARY_CLASSES = "flex flex-1 cursor-pointer list-none items-start justify-between gap-4 rounded-md py-4 " \
8
+ "text-left text-sm font-medium transition-all outline-none hover:underline " \
9
+ "[&::-webkit-details-marker]:hidden #{UI::Styles::FOCUS_RING} " \
8
10
  "disabled:pointer-events-none disabled:opacity-50"
9
11
 
12
+ TITLE_CLASSES = "flex-1 text-left font-medium leading-none"
13
+
14
+ CONTENT_CLASSES = "overflow-hidden pb-4 pt-0 text-sm"
15
+
16
+ CHEVRON_CLASSES = "pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground " \
17
+ "transition-transform duration-200 group-open:rotate-180"
18
+
10
19
  def initialize(title:, open: false, **html_attrs)
11
20
  @title = title
12
21
  @open = open
@@ -21,7 +30,7 @@ module UI
21
30
  private
22
31
 
23
32
  def details_attrs
24
- attrs = @html_attrs.merge(class: cn("border-b last:border-b-0 group", @extra_class))
33
+ attrs = @html_attrs.merge(class: cn(ITEM_CLASSES, @extra_class))
25
34
  attrs[:open] = true if @open
26
35
  attrs
27
36
  end
@@ -29,19 +38,29 @@ module UI
29
38
  def details_content
30
39
  safe_join([
31
40
  content_tag(:summary, summary_content, class: SUMMARY_CLASSES),
32
- content_tag(:div, content, class: "pb-4 pt-0 text-sm")
41
+ content_tag(:div, content, class: CONTENT_CLASSES)
33
42
  ])
34
43
  end
35
44
 
36
45
  def summary_content
37
46
  safe_join([
38
- @title,
39
- content_tag(:svg, content_tag(:path, nil, d: "m6 9 6 6 6-6"),
40
- xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
41
- fill: "none", stroke: "currentColor", stroke_width: "2",
42
- stroke_linecap: "round", stroke_linejoin: "round",
43
- class: "pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200 group-open:rotate-180")
47
+ content_tag(:span, @title, class: TITLE_CLASSES),
48
+ chevron_icon
44
49
  ])
45
50
  end
51
+
52
+ def chevron_icon
53
+ content_tag(:svg,
54
+ content_tag(:path, nil, d: "m6 9 6 6 6-6"),
55
+ xmlns: "http://www.w3.org/2000/svg",
56
+ viewBox: "0 0 24 24",
57
+ fill: "none",
58
+ stroke: "currentColor",
59
+ "stroke-width": "2",
60
+ "stroke-linecap": "round",
61
+ "stroke-linejoin": "round",
62
+ class: CHEVRON_CLASSES,
63
+ "aria-hidden": "true")
64
+ end
46
65
  end
47
66
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module UI
4
4
  class AlertComponent < ApplicationComponent
5
- OUTER_CLASSES = "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border " \
5
+ OUTER_CLASSES = "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg #{UI::Styles::BORDER} " \
6
6
  "px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] " \
7
7
  "has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current"
8
8
 
@@ -5,10 +5,10 @@ module UI
5
5
  renders_one :trigger
6
6
  renders_one :footer
7
7
 
8
- OVERLAY = "fixed inset-0 z-50 bg-black/80"
9
- PANEL = "fixed left-[50%] top-[50%] z-50 w-full max-w-lg " \
10
- "translate-x-[-50%] translate-y-[-50%] " \
11
- "rounded-lg border bg-background p-6 shadow-lg"
8
+ OVERLAY = UI::Styles::OVERLAY
9
+ PANEL = "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
10
+ "translate-x-[-50%] translate-y-[-50%] gap-4 " \
11
+ "rounded-lg #{UI::Styles::BORDER} bg-background p-6 shadow-lg duration-200 sm:max-w-lg"
12
12
 
13
13
  def initialize(title: nil, description: nil, **html_attrs)
14
14
  @title = title
@@ -37,8 +37,8 @@ module UI
37
37
  "aria-modal": "true",
38
38
  "aria-label": @title) {
39
39
  concat header_area
40
- concat content_tag(:div, content, class: "py-1 text-sm text-muted-foreground") unless content.blank?
41
- concat content_tag(:div, footer, class: "mt-6 flex justify-end gap-2") if footer
40
+ concat content_tag(:div, content, class: "text-sm text-muted-foreground") unless content.blank?
41
+ concat content_tag(:div, footer, class: "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end") if footer
42
42
  }
43
43
  end
44
44
  end
@@ -46,9 +46,9 @@ module UI
46
46
  def header_area
47
47
  return "" if @title.nil? && @description.nil?
48
48
 
49
- content_tag(:div, class: "mb-4") do
50
- concat content_tag(:h2, @title, class: "text-lg font-semibold leading-none tracking-tight") if @title
51
- concat content_tag(:p, @description, class: "mt-2 text-sm text-muted-foreground") if @description
49
+ content_tag(:div, class: "flex flex-col gap-2 text-center sm:text-left") do
50
+ concat content_tag(:h2, @title, class: "text-lg leading-none font-semibold") if @title
51
+ concat content_tag(:p, @description, class: "text-sm text-muted-foreground") if @description
52
52
  end
53
53
  end
54
54
  end
@@ -11,7 +11,7 @@ module UI
11
11
  def call
12
12
  content_tag(:div, content,
13
13
  style: "aspect-ratio: #{@ratio}",
14
- class: cn("overflow-hidden", @extra_class),
14
+ class: cn("overflow-hidden rounded-md", @extra_class),
15
15
  **@html_attrs)
16
16
  end
17
17
  end
@@ -22,7 +22,7 @@ module UI
22
22
  end
23
23
 
24
24
  def call
25
- attrs = { preload: @preload, class: @extra_class }
25
+ attrs = { preload: @preload, class: cn("w-full rounded-md", @extra_class) }
26
26
  attrs[:controls] = true if @controls
27
27
  attrs[:autoplay] = true if @autoplay
28
28
  attrs[:muted] = true if @muted
@@ -2,10 +2,11 @@
2
2
 
3
3
  module UI
4
4
  class AvatarComponent < ApplicationComponent
5
- SIZES = { sm: "size-6", default: "size-8", lg: "size-12" }.freeze
6
- BASE = "relative flex shrink-0 overflow-hidden rounded-full select-none"
5
+ BASE = "group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none " \
6
+ "data-[size=sm]:size-6 data-[size=lg]:size-12"
7
7
  IMAGE = "aspect-square size-full object-cover"
8
- FALLBACK = "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground"
8
+ FALLBACK = "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground " \
9
+ "group-data-[size=sm]/avatar:text-xs"
9
10
 
10
11
  def initialize(src: nil, alt: "", fallback: nil, size: :default, **html_attrs)
11
12
  @src = src
@@ -17,7 +18,10 @@ module UI
17
18
  end
18
19
 
19
20
  def call
20
- content_tag(:div, class: cn(BASE, SIZES[@size], @extra_class), **@html_attrs) do
21
+ content_tag(:div,
22
+ class: cn(BASE, @extra_class),
23
+ "data-size": (@size == :default ? nil : @size.to_s),
24
+ **@html_attrs) do
21
25
  if @src
22
26
  content_tag(:img, nil, src: @src, alt: @alt, class: IMAGE)
23
27
  else
@@ -5,7 +5,7 @@ module UI
5
5
  BASE = "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full " \
6
6
  "border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap " \
7
7
  "transition-[color,box-shadow] " \
8
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
8
+ "#{UI::Styles::FOCUS_RING} " \
9
9
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " \
10
10
  "[&>svg]:pointer-events-none [&>svg]:size-3"
11
11
 
@@ -2,14 +2,14 @@
2
2
 
3
3
  module UI
4
4
  class BannerComponent < ApplicationComponent
5
- BASE = "flex items-center gap-3 rounded-lg border p-4 text-sm"
5
+ BASE = "relative flex w-full items-center gap-3 rounded-lg #{UI::Styles::BORDER} px-4 py-3 text-sm"
6
6
 
7
7
  VARIANTS = {
8
- default: "bg-background text-foreground",
9
- info: "border-blue-200 bg-blue-50 text-blue-900",
10
- warning: "border-yellow-200 bg-yellow-50 text-yellow-900",
11
- destructive: "border-destructive/40 bg-destructive/10 text-destructive",
12
- success: "border-green-200 bg-green-50 text-green-900"
8
+ default: "bg-card text-card-foreground",
9
+ info: "border-border bg-muted/50 text-foreground",
10
+ warning: "border-border bg-chart-4/10 text-foreground",
11
+ destructive: "border-destructive/40 bg-card text-destructive",
12
+ success: "border-border bg-chart-2/10 text-foreground"
13
13
  }.freeze
14
14
 
15
15
  def initialize(message = nil, variant: :default, **html_attrs)
@@ -2,7 +2,8 @@
2
2
 
3
3
  module UI
4
4
  class BottomNavComponent < ApplicationComponent
5
- BASE = "fixed bottom-0 left-0 z-50 w-full border-t bg-background"
5
+ BASE = "fixed bottom-0 left-0 z-50 w-full border-t border-border bg-background/95 shadow-xs " \
6
+ "backdrop-blur supports-[backdrop-filter]:bg-background/80"
6
7
 
7
8
  # items: [{ label:, href:, active: (optional), icon: (optional HTML string) }]
8
9
  def initialize(items: [], **html_attrs)
@@ -12,8 +13,11 @@ module UI
12
13
  end
13
14
 
14
15
  def call
15
- content_tag(:nav, class: cn(BASE, @extra_class), **@html_attrs) do
16
- content_tag(:div, class: "mx-auto flex h-16 max-w-lg items-center justify-around") do
16
+ content_tag(:nav,
17
+ class: cn(BASE, @extra_class),
18
+ "aria-label": "Bottom navigation",
19
+ **@html_attrs) do
20
+ content_tag(:div, class: "mx-auto flex h-14 max-w-lg items-center justify-around") do
17
21
  safe_join(@items.map { |item| nav_item(item) })
18
22
  end
19
23
  end
@@ -26,7 +30,10 @@ module UI
26
30
  content_tag(:a,
27
31
  href: item[:href],
28
32
  class: cn(
29
- "flex flex-col items-center justify-center gap-1 px-4 py-2 text-xs font-medium transition-colors",
33
+ "flex flex-col items-center justify-center gap-1 px-3 py-2 text-xs font-medium " \
34
+ "transition-colors outline-none",
35
+ UI::Styles::FOCUS_RING,
36
+ "rounded-md",
30
37
  active ? "text-primary" : "text-muted-foreground hover:text-foreground"
31
38
  ),
32
39
  "aria-current": (active ? "page" : nil)) do
@@ -2,8 +2,8 @@
2
2
 
3
3
  module UI
4
4
  class BreadcrumbComponent < ApplicationComponent
5
- LINK = "text-muted-foreground hover:text-foreground transition-colors"
6
- CURRENT = "text-foreground font-medium"
5
+ LINK = "text-muted-foreground transition-colors hover:text-foreground"
6
+ CURRENT = "font-normal text-foreground"
7
7
 
8
8
  # items: [{ label:, href: }, ..., { label: }] — last item is the current page (no href)
9
9
  def initialize(items: [], separator: "/", **html_attrs)
@@ -3,8 +3,8 @@
3
3
  module UI
4
4
  class ButtonComponent < ApplicationComponent
5
5
  BASE_CLASSES = "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm " \
6
- "font-medium transition-all outline-none " \
7
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " \
6
+ "font-medium transition-all " \
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
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
@@ -13,8 +13,8 @@ module UI
13
13
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
14
14
  destructive: "bg-destructive text-white hover:bg-destructive/90 " \
15
15
  "focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
16
- outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " \
17
- "dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
16
+ outline: "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " \
17
+ "dark:bg-input/30 dark:hover:bg-input/50",
18
18
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
19
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
20
20
  link: "text-primary underline-offset-4 hover:underline"
@@ -25,7 +25,10 @@ module UI
25
25
  xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
26
26
  sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
27
27
  lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
- icon: "size-9"
28
+ icon: "size-9",
29
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
30
+ "icon-sm": "size-8",
31
+ "icon-lg": "size-10"
29
32
  }.freeze
30
33
 
31
34
  # label — positional or keyword shorthand for plain-text buttons without a block.
@@ -2,11 +2,10 @@
2
2
 
3
3
  module UI
4
4
  class ButtonGroupComponent < ApplicationComponent
5
- BASE = "inline-flex rounded-md shadow-sm " \
6
- "[&>*]:rounded-none " \
7
- "[&>*:first-child]:rounded-l-md " \
8
- "[&>*:last-child]:rounded-r-md " \
9
- "[&>*:not(:first-child)]:-ml-px"
5
+ BASE = "flex w-fit items-stretch " \
6
+ "[&>*]:focus-visible:relative [&>*]:focus-visible:z-10 " \
7
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 " \
8
+ "[&>*:not(:last-child)]:rounded-r-none"
10
9
 
11
10
  def initialize(**html_attrs)
12
11
  @extra_class = html_attrs.delete(:class)
@@ -17,6 +16,7 @@ module UI
17
16
  content_tag(:div, content,
18
17
  class: cn(BASE, @extra_class),
19
18
  role: "group",
19
+ "data-slot": "button-group",
20
20
  **@html_attrs)
21
21
  end
22
22
  end
@@ -9,21 +9,23 @@ module UI
9
9
  # name: form field name for the hidden input (if used in a form)
10
10
  # min/max: Date bounds for disabled days
11
11
 
12
- CONTAINER = "w-fit rounded-lg border border-border bg-popover p-4 text-sm shadow"
13
- HEADER_CLS = "mb-3 flex items-center justify-between"
14
- MONTH_CLS = "font-medium text-foreground"
15
- NAV_BTN = "inline-flex size-7 items-center justify-center rounded-md " \
16
- "text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
17
- "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition"
18
- GRID_CLS = "grid grid-cols-7 gap-px"
19
- DOW_CLS = "py-1.5 text-center text-xs text-muted-foreground font-medium"
20
- DAY_BASE = "h-9 w-9 rounded-md text-center text-sm transition-colors outline-none " \
21
- "focus-visible:ring-[3px] focus-visible:ring-ring/50"
22
- DAY_NORMAL = "hover:bg-accent hover:text-accent-foreground"
23
- DAY_TODAY = "font-semibold text-foreground ring-1 ring-border"
24
- DAY_SEL = "bg-primary text-primary-foreground hover:bg-primary/90"
25
- DAY_MUTED = "text-muted-foreground/50"
26
- DAY_DISABLED = "pointer-events-none opacity-30"
12
+ CONTAINER = "group/calendar w-fit #{UI::Styles::FIELD_PANEL} p-3 text-sm"
13
+ HEADER_CLS = "relative mb-2 flex items-center justify-between"
14
+ MONTH_CLS = "flex h-8 w-full items-center justify-center text-sm font-medium select-none"
15
+ NAV_BTN = "inline-flex size-8 shrink-0 items-center justify-center rounded-md p-0 " \
16
+ "font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground " \
17
+ "#{UI::Styles::FOCUS_RING} " \
18
+ "disabled:pointer-events-none disabled:opacity-50 transition"
19
+ GRID_CLS = "grid w-full grid-cols-7"
20
+ DOW_CLS = "flex-1 rounded-md py-0 text-center text-[0.8rem] font-normal text-muted-foreground select-none"
21
+ DAY_BASE = "inline-flex aspect-square size-8 items-center justify-center rounded-md p-0 " \
22
+ "text-sm font-normal transition-colors outline-none " \
23
+ "#{UI::Styles::FOCUS_RING}"
24
+ DAY_NORMAL = "hover:bg-accent hover:text-accent-foreground dark:hover:text-accent-foreground"
25
+ DAY_TODAY = "rounded-md bg-accent text-accent-foreground"
26
+ DAY_SEL = "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
27
+ DAY_MUTED = "text-muted-foreground opacity-50"
28
+ DAY_DISABLED = "pointer-events-none opacity-50"
27
29
 
28
30
  DAYS_OF_WEEK = %w[Su Mo Tu We Th Fr Sa].freeze
29
31
  CHEVRON_L = "m15 18-6-6 6-6"
@@ -42,7 +44,7 @@ module UI
42
44
  def call
43
45
  content_tag(:div,
44
46
  class: cn(CONTAINER, @extra_class),
45
- data: { controller: "calendar", calendar_month_value: @month.iso8601 },
47
+ data: { slot: "calendar", controller: "calendar", calendar_month_value: @month.iso8601 },
46
48
  **@html_attrs) do
47
49
  concat hidden_input if @name && @selected
48
50
  concat header_row
@@ -2,7 +2,7 @@
2
2
 
3
3
  module UI
4
4
  class CardComponent < ApplicationComponent
5
- BASE = "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm"
5
+ BASE = "flex flex-col gap-6 rounded-xl #{UI::Styles::BORDER} bg-card py-6 text-card-foreground shadow-sm"
6
6
 
7
7
  def initialize(**html_attrs)
8
8
  @extra_class = html_attrs.delete(:class)