ariadne_view_components 0.0.1

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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +68 -0
  4. data/app/assets/javascripts/ariadne_view_components.js +2 -0
  5. data/app/assets/javascripts/ariadne_view_components.js.map +1 -0
  6. data/app/assets/stylesheets/application.tailwind.css +3 -0
  7. data/app/components/ariadne/ariadne.ts +14 -0
  8. data/app/components/ariadne/base_button.rb +60 -0
  9. data/app/components/ariadne/base_component.rb +155 -0
  10. data/app/components/ariadne/button_component.html.erb +4 -0
  11. data/app/components/ariadne/button_component.rb +158 -0
  12. data/app/components/ariadne/clipboard_copy_component.html.erb +8 -0
  13. data/app/components/ariadne/clipboard_copy_component.rb +50 -0
  14. data/app/components/ariadne/clipboard_copy_component.ts +19 -0
  15. data/app/components/ariadne/component.rb +123 -0
  16. data/app/components/ariadne/content.rb +12 -0
  17. data/app/components/ariadne/counter_component.rb +100 -0
  18. data/app/components/ariadne/flash_component.html.erb +31 -0
  19. data/app/components/ariadne/flash_component.rb +125 -0
  20. data/app/components/ariadne/heading_component.rb +49 -0
  21. data/app/components/ariadne/heroicon_component.html.erb +7 -0
  22. data/app/components/ariadne/heroicon_component.rb +116 -0
  23. data/app/components/ariadne/image_component.rb +51 -0
  24. data/app/components/ariadne/text.rb +25 -0
  25. data/app/components/ariadne/tooltip_component.rb +105 -0
  26. data/app/lib/ariadne/audited/dsl.rb +32 -0
  27. data/app/lib/ariadne/class_name_helper.rb +22 -0
  28. data/app/lib/ariadne/fetch_or_fallback_helper.rb +100 -0
  29. data/app/lib/ariadne/icon_helper.rb +47 -0
  30. data/app/lib/ariadne/join_style_arguments_helper.rb +14 -0
  31. data/app/lib/ariadne/logger_helper.rb +23 -0
  32. data/app/lib/ariadne/status/dsl.rb +41 -0
  33. data/app/lib/ariadne/tab_nav_helper.rb +35 -0
  34. data/app/lib/ariadne/tabbed_component_helper.rb +39 -0
  35. data/app/lib/ariadne/test_selector_helper.rb +20 -0
  36. data/app/lib/ariadne/underline_nav_helper.rb +44 -0
  37. data/app/lib/ariadne/view_helper.rb +22 -0
  38. data/lib/ariadne/classify/utilities.rb +199 -0
  39. data/lib/ariadne/classify/utilities.yml +1817 -0
  40. data/lib/ariadne/classify/validation.rb +18 -0
  41. data/lib/ariadne/classify.rb +210 -0
  42. data/lib/ariadne/view_components/constants.rb +53 -0
  43. data/lib/ariadne/view_components/engine.rb +30 -0
  44. data/lib/ariadne/view_components/linters.rb +3 -0
  45. data/lib/ariadne/view_components/statuses.rb +14 -0
  46. data/lib/ariadne/view_components/version.rb +7 -0
  47. data/lib/ariadne/view_components.rb +59 -0
  48. data/lib/rubocop/config/default.yml +14 -0
  49. data/lib/rubocop/cop/ariadne/ariadne_heroicon.rb +252 -0
  50. data/lib/rubocop/cop/ariadne/base_cop.rb +26 -0
  51. data/lib/rubocop/cop/ariadne/component_name_migration.rb +35 -0
  52. data/lib/rubocop/cop/ariadne/no_tag_memoize.rb +43 -0
  53. data/lib/rubocop/cop/ariadne/system_argument_instead_of_class.rb +57 -0
  54. data/lib/rubocop/cop/ariadne.rb +3 -0
  55. data/lib/tasks/ariadne_view_components.rake +47 -0
  56. data/lib/tasks/coverage.rake +19 -0
  57. data/lib/tasks/custom_utilities.yml +310 -0
  58. data/lib/tasks/docs.rake +525 -0
  59. data/lib/tasks/helpers/ast_processor.rb +44 -0
  60. data/lib/tasks/helpers/ast_traverser.rb +77 -0
  61. data/lib/tasks/static.rake +15 -0
  62. data/lib/tasks/tailwind.rake +31 -0
  63. data/lib/tasks/utilities.rake +121 -0
  64. data/lib/yard/docs_helper.rb +83 -0
  65. data/lib/yard/renders_many_handler.rb +19 -0
  66. data/lib/yard/renders_one_handler.rb +19 -0
  67. data/static/arguments.yml +251 -0
  68. data/static/assets/view-components.svg +18 -0
  69. data/static/audited_at.json +14 -0
  70. data/static/classes.yml +89 -0
  71. data/static/constants.json +243 -0
  72. data/static/statuses.json +14 -0
  73. data/static/tailwindcss.yml +727 -0
  74. metadata +193 -0
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ariadne/classify"
4
+
5
+ module Ariadne
6
+ # All Ariadne ViewComponents accept a standard set of options: classes, which match Tailwind CSS classes, and attributes, which match HTML attributes..
7
+ #
8
+ # Under the hood, classes are [mapped](https://github.com/ariadne/view_components/blob/main/lib/ariadne/classify.rb) to Tailwind CSS classes, with any unconsumed attributes passed to Rails' [`content_tag`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-content_tag).
9
+ class BaseComponent < Ariadne::Component
10
+ SELF_CLOSING_TAGS = [:area, :base, :br, :col, :embed, :hr, :img, :input, :link, :meta, :param, :source, :track, :wbr].freeze
11
+
12
+ # ## HTML attributes
13
+ #
14
+ # System arguments include most HTML attributes. For example:
15
+ #
16
+ # | Name | Type | Description |
17
+ # | :- | :- | :- |
18
+ # | `aria` | `Hash` | Aria attributes: `aria: { label: "foo" }` renders `aria-label='foo'`. |
19
+ # | `data` | `Hash` | Data attributes: `data: { foo: :bar }` renders `data-foo='bar'`. |
20
+ # | `height` | `Integer` | The height. |
21
+ # | `hidden` | `Boolean` | Whether to assign the `hidden` attribute. |
22
+ # | `style` | `String` | Inline styles. |
23
+ # | `title` | `String` | The `title` attribute. |
24
+ # | `width` | `Integer` | The width. |
25
+ #
26
+ # ## Animation
27
+ #
28
+ # | Name | Type | Description |
29
+ # | :- | :- | :- |
30
+ # | `animation` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:animation)) %> |
31
+ #
32
+ # ## Border
33
+ #
34
+ # | Name | Type | Description |
35
+ # | :- | :- | :- |
36
+ # | `border_bottom` | Integer | Set to `0` to remove the bottom border. |
37
+ # | `border_left` | Integer | Set to `0` to remove the left border. |
38
+ # | `border_radius` | Integer | <%= one_of([0, 1, 2, 3]) %> |
39
+ # | `border_right` | Integer | Set to `0` to remove the right border. |
40
+ # | `border_top` | Integer | Set to `0` to remove the top border. |
41
+ # | `border` | Symbol | <%= one_of([:left, :top, :bottom, :right, :y, :x, true]) %> |
42
+ # | `box_shadow` | Boolean, Symbol | Box shadow. <%= one_of([true, :medium, :large, :extra_large, :none]) %> |
43
+ #
44
+ # ## Color
45
+ #
46
+ # | Name | Type | Description |
47
+ # | :- | :- | :- |
48
+ # | `bg` | Symbol | Background color. <%= one_of(Ariadne::Classify::Utilities.mappings(:bg)) %> |
49
+ # | `border_color` | Symbol | Border color. <%= one_of(Ariadne::Classify::Utilities.mappings(:border_color)) %> |
50
+ # | `color` | Symbol | Text color. <%= one_of(Ariadne::Classify::Utilities.mappings(:color)) %> |
51
+ #
52
+ # ## Flex
53
+ #
54
+ # | Name | Type | Description |
55
+ # | :- | :- | :- |
56
+ # | `align_items` | Symbol | <%= one_of(Ariadne::Classify::FLEX_ALIGN_ITEMS_VALUES) %> |
57
+ # | `align_self` | Symbol | <%= one_of(Ariadne::Classify::FLEX_ALIGN_SELF_VALUES) %> |
58
+ # | `direction` | Symbol | <%= one_of(Ariadne::Classify::FLEX_DIRECTION_VALUES) %> |
59
+ # | `flex` | Integer, Symbol | <%= one_of(Ariadne::Classify::FLEX_VALUES) %> |
60
+ # | `flex_grow` | Integer | To enable, set to `0`. |
61
+ # | `flex_shrink` | Integer | To enable, set to `0`. |
62
+ # | `flex_wrap` | Symbol | <%= one_of(Ariadne::Classify::FLEX_WRAP_MAPPINGS.keys) %> |
63
+ # | `justify_content` | Symbol | <%= one_of(Ariadne::Classify::FLEX_JUSTIFY_CONTENT_VALUES) %> |
64
+ #
65
+ # ## Grid
66
+ #
67
+ # | Name | Type | Description |
68
+ # | :- | :- | :- |
69
+ # | `clearfix` | Boolean | Whether to assign the `clearfix` class. |
70
+ # | `col` | Integer | Number of columns. <%= one_of(Ariadne::Classify::Utilities.mappings(:col)) %> |
71
+ # | `container` | Symbol | Size of the container. <%= one_of(Ariadne::Classify::Utilities.mappings(:container)) %> |
72
+ #
73
+ # ## Layout
74
+ #
75
+ # | Name | Type | Description |
76
+ # | :- | :- | :- |
77
+ # | `display` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:display)) %> |
78
+ # | `w` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:w)) %> |
79
+ # | `h` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:h)) %> |
80
+ # | `hide` | Symbol | Hide the element at a specific breakpoint. <%= one_of(Ariadne::Classify::Utilities.mappings(:hide)) %> |
81
+ # | `visibility` | Symbol | Visibility. <%= one_of(Ariadne::Classify::Utilities.mappings(:visibility)) %> |
82
+ # | `vertical_align` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:vertical_align)) %> |
83
+ #
84
+ # ## Position
85
+ #
86
+ # | Name | Type | Description |
87
+ # | :- | :- | :- |
88
+ # | `bottom` | Boolean | If `false`, sets `bottom: 0`. |
89
+ # | `float` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:float)) %> |
90
+ # | `left` | Boolean | If `false`, sets `left: 0`. |
91
+ # | `position` | Symbol | <%= one_of(Ariadne::Classify::Utilities.mappings(:position)) %> |
92
+ # | `right` | Boolean | If `false`, sets `right: 0`. |
93
+ # | `top` | Boolean | If `false`, sets `top: 0`. |
94
+ #
95
+ # ## Spacing
96
+ #
97
+ # | Name | Type | Description |
98
+ # | :- | :- | :- |
99
+ # | `m` | Integer | Margin. <%= one_of(Ariadne::Classify::Utilities.mappings(:m)) %> |
100
+ # | `mb` | Integer | Margin bottom. <%= one_of(Ariadne::Classify::Utilities.mappings(:mb)) %> |
101
+ # | `ml` | Integer | Margin left. <%= one_of(Ariadne::Classify::Utilities.mappings(:ml)) %> |
102
+ # | `mr` | Integer | Margin right. <%= one_of(Ariadne::Classify::Utilities.mappings(:mr)) %> |
103
+ # | `mt` | Integer | Margin top. <%= one_of(Ariadne::Classify::Utilities.mappings(:mt)) %> |
104
+ # | `mx` | Integer | Horizontal margins. <%= one_of(Ariadne::Classify::Utilities.mappings(:mx)) %> |
105
+ # | `my` | Integer | Vertical margins. <%= one_of(Ariadne::Classify::Utilities.mappings(:my)) %> |
106
+ # | `p` | Integer | Padding. <%= one_of(Ariadne::Classify::Utilities.mappings(:p)) %> |
107
+ # | `pb` | Integer | Padding bottom. <%= one_of(Ariadne::Classify::Utilities.mappings(:pb)) %> |
108
+ # | `pl` | Integer | Padding left. <%= one_of(Ariadne::Classify::Utilities.mappings(:pl)) %> |
109
+ # | `pr` | Integer | Padding right. <%= one_of(Ariadne::Classify::Utilities.mappings(:pr)) %> |
110
+ # | `pt` | Integer | Padding left. <%= one_of(Ariadne::Classify::Utilities.mappings(:pt)) %> |
111
+ # | `px` | Integer | Horizontal padding. <%= one_of(Ariadne::Classify::Utilities.mappings(:px)) %> |
112
+ # | `py` | Integer | Vertical padding. <%= one_of(Ariadne::Classify::Utilities.mappings(:py)) %> |
113
+ #
114
+ # ## Typography
115
+ #
116
+ # | Name | Type | Description |
117
+ # | :- | :- | :- |
118
+ # | `font_family` | Symbol | Font weight. <%= one_of([:mono]) %> |
119
+ # | `font_size` | String, Integer, Symbol | <%= one_of(["00", 0, 1, 2, 3, 4, 5, 6, :small, :normal]) %> |
120
+ # | `font_style` | Symbol | Font weight. <%= one_of([:italic]) %> |
121
+ # | `font_weight` | Symbol | Font weight. <%= one_of([:light, :normal, :bold, :emphasized]) %> |
122
+ # | `text_align` | Symbol | Text alignment. <%= one_of([:left, :right, :center]) %> |
123
+ # | `text_transform` | Symbol | Text alignment. <%= one_of([:uppercase]) %> |
124
+ # | `underline` | Boolean | Whether text should be underlined. |
125
+ # | `word_break` | Symbol | Whether to break words on line breaks. <%= one_of(Ariadne::Classify::Utilities.mappings(:word_break)) %> |
126
+ #
127
+ # ## Other
128
+ #
129
+ # | Name | Type | Description |
130
+ # | :- | :- | :- |
131
+ # | classes | String | CSS class name value to be concatenated with generated Tailwind CSS classes. |
132
+ # | test_selector | String | Adds `data-test-selector='given value'` in non-Production environments for testing purposes. |
133
+ def initialize(tag:, classes: "", attributes: {})
134
+ @tag = tag
135
+
136
+ @attributes = validate_attributes(tag: tag, attributes: attributes)
137
+ @attributes[:"data-ariadne-view-component"] = true
138
+
139
+ classes = classes.present? ? @attributes.merge(classes: classes) : @attributes.fetch(:classes, "")
140
+ @classes = Ariadne::Classify.call(classes)
141
+
142
+ # Filter out Ariadne keys so they don't get assigned as HTML attributes
143
+ @content_tag_args = add_test_selector(@attributes).except(*Ariadne::Classify::Utilities::UTILITIES.keys)
144
+ end
145
+
146
+ def call
147
+ options = @content_tag_args.merge(@classes)
148
+ if SELF_CLOSING_TAGS.include?(@tag)
149
+ tag(@tag, options)
150
+ else
151
+ content_tag(@tag, content, options)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,4 @@
1
+ <%= render Ariadne::BaseButton.new(tag: @tag, type: Ariadne::BaseButton::DEFAULT_TYPE, classes: @classes, attributes: @attributes) do -%>
2
+ <%= icon %><%= trimmed_content %><%= counter %>
3
+ <%= tooltip %>
4
+ <% end -%>
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # Use `Button` for actions (e.g. in forms). Use links for destinations, or moving from one page to another.
5
+ class ButtonComponent < Ariadne::Component
6
+ include IconHelper
7
+
8
+ DEFAULT_SCHEME = :default
9
+ LINK_SCHEME = :link
10
+
11
+ SCHEME_CLASS_MAPPINGS = {
12
+ default: "text-blue-800 bg-blue-50 hover:bg-blue-100 border-blue-300 focus:ring-offset-blue-50 focus:ring-blue-600",
13
+ info: "text-blue-800 bg-blue-50 hover:bg-blue-100 border-blue-300 focus:ring-offset-blue-50 focus:ring-blue-600",
14
+ success: "text-green-800 bg-green-50 hover:bg-green-100 border-green-300 focus:ring-offset-green-50 focus:ring-green-600",
15
+ warning: "text-yellow-800 bg-yellow-50 hover:bg-yellow-100 border-yellow-300 focus:ring-offset-yellow-50 focus:ring-yellow-600",
16
+ danger: "text-red-800 bg-red-50 hover:bg-red-100 border-red-300 focus:ring-offset-red-50 focus:ring-red-600",
17
+ }.freeze
18
+ VALID_SCHEMES = SCHEME_CLASS_MAPPINGS.keys.freeze
19
+
20
+ # Leading visuals appear to the left of the button text.
21
+ #
22
+ # Use:
23
+ #
24
+ # - `icon` for a <%= link_to_component(Ariadne::HeroiconComponent) %>.
25
+ #
26
+ # @param tag [Symbol, String] The rendered tag name
27
+ # @param classes [String] <%= link_to_classes_docs %>
28
+ # @param icon [String] Name of <%= link_to_heroicons %> to use.
29
+ # @param variant [String] <%= one_of(HeroiconsHelper::Icon::VARIANTS, sort: false) %>
30
+ # @param attributes [Hash] Same arguments as <%= link_to_component(Ariadne::HeroiconComponent) %>.
31
+ renders_one :icon, lambda { |tag: :svg, icon:, variant:, classes: "", attributes: {}|
32
+ @icon = icon
33
+ @variant = variant
34
+ tag = check_incoming_tag(:svg, tag)
35
+ Ariadne::HeroiconComponent.new(tag: tag, icon: icon, variant: variant, classes: classes, attributes: attributes)
36
+ }
37
+
38
+ # Trailing visuals appear to the right of the button text.
39
+ #
40
+ # Use:
41
+ #
42
+ # - `counter` for a <%= link_to_component(Ariadne::CounterComponent) %>.
43
+ #
44
+ # @param tag [Symbol, String] The rendered tag name
45
+ # @param classes [String] <%= link_to_classes_docs %>
46
+ # @param counter [Number] The starting counter value
47
+ # @param attributes [Hash] Same arguments as <%= link_to_component(Ariadne::CounterComponent) %>.
48
+ renders_one :counter, lambda { |tag: :span, count: 0, classes: "", attributes: {}|
49
+ tag = check_incoming_tag(:span, tag)
50
+ attributes[:ml] = check_incoming_value(2, ml: attributes[:ml])
51
+
52
+ Ariadne::CounterComponent.new(tag: tag, count: count, classes: classes, attributes: attributes)
53
+ }
54
+
55
+ # `Tooltip` that appears on mouse hover or keyboard focus over the button. Use tooltips sparingly and as a last resort.
56
+ # **Important:** This tooltip defaults to `type: :description`. In a few scenarios, `type: :label` may be more appropriate.
57
+ # Consult the <%= link_to_component(Ariadne::TooltipComponent) %> documentation for more information.
58
+ #
59
+ # @param tag [Symbol, String] The rendered tag name
60
+ # @param text [String] The text content of the tooltip. This should be brief and no longer than a sentence.
61
+ # @param direction [Symbol] <%= one_of(Ariadne::TooltipComponent::DIRECTION_OPTIONS) %>
62
+ # @param classes [String] <%= link_to_classes_docs %>
63
+ # @param attributes [Hash] Same arguments as <%= link_to_component(Ariadne::TooltipComponent) %>.
64
+ renders_one :tooltip, lambda { |tag: :"tool-tip", text:, direction: Ariadne::TooltipComponent::DIRECTION_DEFAULT, type: Ariadne::TooltipComponent::TYPE_DEFAULT, classes: "", attributes: {}|
65
+ raise ArgumentError, "Buttons with a tooltip must have a unique `id` set on the `Button`." if @id.blank?
66
+
67
+ # TODO: test this
68
+ tag = check_incoming_tag(:"tool-tip", tag)
69
+ attributes[:for] = @id
70
+ attributes[:type] = check_incoming_attribute(:description, attributes[:type])
71
+
72
+ Ariadne::TooltipComponent.new(tag: tag, text: text, direction: direction, type: type, classes: classes, attributes: attributes)
73
+ }
74
+
75
+ # @example Schemes
76
+ # <%= render(Ariadne::ButtonComponent.new) { "Default" } %>
77
+ # <%= render(Ariadne::ButtonComponent.new(scheme: :default)) { "Default" } %>
78
+ # <%= render(Ariadne::ButtonComponent.new(scheme: :info)) { "Info" } %>
79
+ # <%= render(Ariadne::ButtonComponent.new(scheme: :success)) { "Success" } %>
80
+ # <%= render(Ariadne::ButtonComponent.new(scheme: :warning)) { "Warning" } %>
81
+ # <%= render(Ariadne::ButtonComponent.new(scheme: :danger)) { "Danger" } %>
82
+ #
83
+ # @example Sizes
84
+ # <%= render(Ariadne::ButtonComponent.new(size: :s)) { "Small" } %>
85
+ # <%= render(Ariadne::ButtonComponent.new(size: :m)) { "Medium" } %>
86
+ #
87
+ # @example With leading visual
88
+ # <%= render(Ariadne::ButtonComponent.new) do |c| %>
89
+ # <% c.icon(icon: :star, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, classes: "text-yellow-600") %>
90
+ # Button
91
+ # <% end %>
92
+ #
93
+ # @example With trailing visual
94
+ # <%= render(Ariadne::ButtonComponent.new) do |c| %>
95
+ # <% c.counter(count: 15) %>
96
+ # Button
97
+ # <% end %>
98
+ #
99
+ # @example With leading and trailing visuals
100
+ # <%= render(Ariadne::ButtonComponent.new) do |c| %>
101
+ # <% c.icon(icon: :star, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) %>
102
+ # <% c.counter(count: 15) %>
103
+ # Button
104
+ # <% end %>
105
+ #
106
+ # @example With tooltip
107
+ # @description
108
+ # Use tooltips sparingly and as a last resort. Consult the <%= link_to_component(Ariadne::TooltipComponent) %> documentation for more information.
109
+ # @code
110
+ # <%= render(Ariadne::ButtonComponent.new(attributes: { id: "button-with-tooltip" })) do |c| %>
111
+ # <% c.tooltip(text: "Tooltip text") %>
112
+ # Button
113
+ # <% end %>
114
+ #
115
+ # @param tag [Symbol] <%= one_of(Ariadne::BaseButton::TAG_OPTIONS) %>
116
+ # @param scheme [Symbol] <%= one_of(Ariadne::ButtonComponent::VALID_SCHEMES) %>
117
+ # @param size [Symbol] <%= one_of(Ariadne::BaseButton::VALID_SIZES) %>
118
+ # @param type [Symbol] <%= one_of(Ariadne::BaseButton::TYPE_OPTIONS) %>
119
+ # @param classes [String] <%= link_to_classes_docs %>
120
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
121
+ def initialize(
122
+ tag: Ariadne::BaseButton::DEFAULT_TAG,
123
+ scheme: DEFAULT_SCHEME,
124
+ size: BaseButton::DEFAULT_SIZE,
125
+ classes: "",
126
+ attributes: {}
127
+ )
128
+ @tag = tag
129
+ @scheme = scheme
130
+
131
+ @attributes = attributes
132
+ @id = @attributes[:id]
133
+
134
+ @size = fetch_or_raise(Ariadne::BaseButton::VALID_SIZES, size)
135
+ @scheme = fetch_or_raise(VALID_SCHEMES, scheme)
136
+
137
+ @classes = class_names(
138
+ SCHEME_CLASS_MAPPINGS[@scheme],
139
+ classes
140
+ )
141
+ end
142
+
143
+ private def trimmed_content
144
+ return if content.blank?
145
+
146
+ trimmed_content = content.strip
147
+
148
+ return trimmed_content unless content.html_safe?
149
+
150
+ # strip unsets `html_safe`, so we have to set it back again to guarantee that HTML blocks won't break
151
+ trimmed_content.html_safe # rubocop:disable Rails/OutputSafety
152
+ end
153
+
154
+ private def link?
155
+ @scheme == LINK_SCHEME
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,8 @@
1
+ <%= render Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes.merge(:"data-controller" => DATA_CONTROLLER, :"data-action" => DATA_ACTION)) do %>
2
+ <% if content.present? %>
3
+ <%= content %>
4
+ <% else %>
5
+ <%= render Ariadne::HeroiconComponent.new(icon: :duplicate, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) %>
6
+ <%= render Ariadne::HeroiconComponent.new(icon: :check, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, attributes: { color: :success, style: "display: none;" }) %>
7
+ <% end %>
8
+ <% end %>
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # Use `ClipboardCopyComponent` to copy element text content or input values to the clipboard.
5
+ #
6
+ # @accessibility
7
+ # Always set an accessible label to help the user interact with the component.
8
+ class ClipboardCopyComponent < Ariadne::Component
9
+ DEFAULT_TAG = :"clipboard-copy"
10
+
11
+ DATA_CONTROLLER = "clipboard_copy_component"
12
+ DATA_ACTION = "click->clipboard_copy_component#copy"
13
+
14
+ # @example Default
15
+ # <%= render(Ariadne::ClipboardCopyComponent.new(value: "Text to copy", aria_label: "Copy text to the system clipboard" )) %>
16
+ #
17
+ # @example With text instead of icons
18
+ # <%= render(Ariadne::ClipboardCopyComponent.new(value: "Text to copy", aria_label: "Copy text to the system clipboard" )) do %>
19
+ # Click to copy!
20
+ # <% end %>
21
+ #
22
+ # @example Copying from an element
23
+ # <%= render(Ariadne::ClipboardCopyComponent.new(attributes: { for: "blob-path"}, aria_label: "Copy text to the system clipboard" )) %>
24
+ # <div id="blob-path">src/index.js</div>
25
+ #
26
+ # @param tag [Symbol, String] The rendered tag name
27
+ # @param classes [String] <%= link_to_classes_docs %>
28
+ # @param value [String] Text to copy into the users clipboard when they click the component.
29
+ # @param aria_label [String] Text for accessibility. Can also be passed in as part of `attributes`, but it must be present.
30
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
31
+ def initialize(tag: DEFAULT_TAG, value: "", aria_label: "", classes: "", attributes: {})
32
+ @attributes = attributes
33
+ @value = value
34
+
35
+ @attributes[:"aria-label"] = aria_label
36
+
37
+ validate!
38
+
39
+ @classes = classes
40
+ @tag = check_incoming_tag(DEFAULT_TAG, tag)
41
+ @attributes[:value] = value if value.present?
42
+ end
43
+
44
+ private def validate!
45
+ validate_aria_label!
46
+ raise ArgumentError, "Must provide either `value` or `for`" if @value.blank? && @attributes[:for].blank?
47
+ raise ArgumentError, "Must provide only `value` or `for`, not both" if @value.present? && @attributes[:for].present?
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ import {Controller} from '@hotwired/stimulus'
2
+
3
+ export default class ClipboardCopyComponent extends Controller {
4
+ copy() {
5
+ const value = this.element.attributes.getNamedItem('value')
6
+
7
+ const forNode = this.element.attributes.getNamedItem('for')
8
+
9
+ if (value) {
10
+ navigator.clipboard.writeText(value.value)
11
+ } else if (forNode) {
12
+ const node = document.getElementById(forNode.value)
13
+ navigator.clipboard.writeText(node?.textContent || '')
14
+ } else {
15
+ // just copy inner text
16
+ navigator.clipboard.writeText(this.element.textContent || '')
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "view_component/version"
4
+
5
+ require "heroicons_helper"
6
+
7
+ module Ariadne
8
+ # @private
9
+ class Component < ViewComponent::Base
10
+ include ViewComponent::SlotableV2 unless ViewComponent::Base < ViewComponent::SlotableV2
11
+ include ViewComponent::PolymorphicSlots
12
+ include ClassNameHelper
13
+ include FetchOrFallbackHelper
14
+ include TestSelectorHelper
15
+ include JoinStyleArgumentsHelper
16
+ include ViewHelper
17
+ include Status::Dsl
18
+ include Audited::Dsl
19
+ include LoggerHelper
20
+
21
+ INVALID_ARIA_LABEL_TAGS = [:div, :span, :p].freeze
22
+
23
+ private def raise_on_invalid_options?
24
+ Rails.application.config.ariadne_view_components.raise_on_invalid_options
25
+ end
26
+
27
+ private def raise_on_invalid_aria?
28
+ Rails.application.config.ariadne_view_components.raise_on_invalid_aria
29
+ end
30
+
31
+ private def deprecated_component_warning(new_class: nil, version: nil)
32
+ return if silence_deprecations?
33
+
34
+ message = "#{self.class.name} is deprecated"
35
+ message += " and will be removed in v#{version}." if version
36
+ message += " Use #{new_class.name} instead." if new_class
37
+
38
+ ActiveSupport::Deprecation.warn(message)
39
+ end
40
+
41
+ private def aria(val, attributes)
42
+ attributes[:"aria-#{val}"] || attributes.dig(:aria, val.to_sym)
43
+ end
44
+
45
+ private def validate_aria_label!
46
+ aria_label = aria("label", @attributes)
47
+ raise ArgumentError, "`aria-label` is required." if aria_label.blank?
48
+ end
49
+
50
+ private def check_denylist(denylist = [], attributes = {})
51
+ if should_raise_error?
52
+
53
+ # Convert denylist from:
54
+ # { [:p, :pt] => "message" } to:
55
+ # { p: "message", pt: "message" }
56
+ unpacked_denylist =
57
+ denylist.each_with_object({}) do |(keys, value), memo|
58
+ keys.each { |key| memo[key] = value }
59
+ end
60
+
61
+ violations = unpacked_denylist.keys & attributes.keys
62
+
63
+ if violations.any?
64
+ message = "Found #{violations.count} #{"violation".pluralize(violations)}:"
65
+ violations.each do |violation|
66
+ message += "\n The #{violation} argument is not allowed here. #{unpacked_denylist[violation]}"
67
+ end
68
+
69
+ raise(ArgumentError, message)
70
+ end
71
+ end
72
+
73
+ attributes
74
+ end
75
+
76
+ private def validate_attributes(tag:, denylist_name: :attributes_denylist, attributes: {})
77
+ deny_single_argument(:class, "Use `classes` instead.", attributes)
78
+
79
+ if (denylist = attributes[denylist_name])
80
+ check_denylist(denylist, attributes)
81
+
82
+ # Remove :attributes_denylist key and any denied keys from system arguments
83
+ attributes.except!(denylist_name)
84
+ attributes.except!(*denylist.keys.flatten)
85
+ end
86
+
87
+ deny_aria_label(tag: tag, attributes: attributes)
88
+
89
+ attributes
90
+ end
91
+
92
+ private def deny_single_argument(key, help_text, attributes)
93
+ raise ArgumentError, "`#{key}` is an invalid argument. #{help_text}" \
94
+ if should_raise_error? && attributes.key?(key)
95
+
96
+ attributes.except!(key)
97
+ end
98
+
99
+ private def deny_aria_label(tag:, attributes:)
100
+ return attributes.except!(:skip_aria_label_check) if attributes[:skip_aria_label_check]
101
+ return if attributes[:role]
102
+ return unless INVALID_ARIA_LABEL_TAGS.include?(tag)
103
+
104
+ deny_aria_key(
105
+ :label,
106
+ "Don't use `aria-label` on `#{tag}` elements. See https://www.tpgi.com/short-note-on-aria-label-aria-labelledby-and-aria-describedby/",
107
+ attributes
108
+ )
109
+ end
110
+
111
+ private def deny_aria_key(key, help_text, attributes)
112
+ raise ArgumentError, help_text if should_raise_aria_error? && aria(key, attributes)
113
+ end
114
+
115
+ private def should_raise_error?
116
+ raise_on_invalid_options? && !ENV["ARIADNE_WARNINGS_DISABLED"]
117
+ end
118
+
119
+ private def should_raise_aria_error?
120
+ raise_on_invalid_aria? && !ENV["ARIADNE_WARNINGS_DISABLED"]
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ #
5
+ # Use `Content` as a helper to render content passed to a slot without adding any tags.
6
+ #
7
+ class Content < Ariadne::Component
8
+ def call
9
+ content
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # Use `Counter` to add a count to navigational elements and buttons.
5
+ #
6
+ # @accessibility
7
+ # Always use `Counter` with adjacent text that provides supplementary information regarding what the count is for. For instance, `Counter`
8
+ # should be accompanied with text such as `issues` or `pull requests`.
9
+ #
10
+ class CounterComponent < Ariadne::Component
11
+ DEFAULT_CLASSES = "inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
12
+ #
13
+ # @example Default
14
+ # <%= render(Ariadne::CounterComponent.new(count: 25)) %>
15
+ #
16
+ # @example Schemes
17
+ # <%= render(Ariadne::CounterComponent.new(count: 25)) %>
18
+ # <%= render(Ariadne::CounterComponent.new(count: 25)) %>
19
+ #
20
+ # @param tag [Symbol, String] The rendered tag name
21
+ # @param classes [String] <%= link_to_classes_docs %>
22
+ # @param count [Integer, Float::INFINITY, nil] The number to be displayed (e.x. # of issues, pull requests)
23
+ # @param limit [Integer, nil] Maximum value to display. Pass `nil` for no limit. (e.x. if `count` == 6,000 and `limit` == 5000, counter will display "5,000+")
24
+ # @param hide_if_zero [Boolean] If true, a `hidden` attribute is added to the counter if `count` is zero.
25
+ # @param text [String] Text to display instead of count.
26
+ # @param round [Boolean] Whether to apply rounding logic to value.
27
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
28
+ def initialize(
29
+ tag: :span,
30
+ count: 0,
31
+ limit: 9_000,
32
+ hide_if_zero: false,
33
+ text: "",
34
+ round: false,
35
+ classes: "",
36
+ attributes: {}
37
+ )
38
+ @count = count
39
+ @limit = limit
40
+ @hide_if_zero = hide_if_zero
41
+ @text = text
42
+ @round = round
43
+ @attributes = attributes
44
+
45
+ @has_limit = !@limit.nil?
46
+
47
+ @tag = check_incoming_tag(:span, tag)
48
+
49
+ @attributes[:title] = title
50
+
51
+ @classes = class_names(
52
+ DEFAULT_CLASSES,
53
+ classes
54
+ )
55
+ @attributes[:hidden] = true if count == 0 && hide_if_zero
56
+ end
57
+
58
+ def call
59
+ render(Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes)) { value }
60
+ end
61
+
62
+ private def title
63
+ if @text.present?
64
+ @text
65
+ elsif @count.nil?
66
+ "Not available"
67
+ elsif @count == Float::INFINITY
68
+ "Infinity"
69
+ else
70
+ count = @count.to_i
71
+ str = number_with_delimiter(@has_limit ? [count, @limit].min : count)
72
+ str += "+" if @has_limit && count > @limit
73
+ str
74
+ end
75
+ end
76
+
77
+ private def value
78
+ if @text.present?
79
+ @text
80
+ elsif @count.nil?
81
+ "" # CSS will hide it
82
+ elsif @count == Float::INFINITY
83
+ "∞"
84
+ else
85
+ if @round
86
+ count = @has_limit ? [@count.to_i, @limit].min : @count.to_i
87
+ precision = count.between?(100_000, 999_999) ? 0 : 1
88
+ units = { thousand: "k", million: "m", billion: "b" }
89
+ str = number_to_human(count, precision: precision, significant: false, units: units, format: "%n%u")
90
+ else
91
+ @count = @count.to_i
92
+ str = number_with_delimiter(@has_limit ? [@count, @limit].min : @count)
93
+ end
94
+
95
+ str += "+" if @has_limit && @count.to_i > @limit
96
+ str
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,31 @@
1
+ <%= render Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes) do %>
2
+ <div class="rounded-md p-4 <%= BG_SCHEME_CLASS_MAPPINGS[@scheme] %>">
3
+ <div class="flex">
4
+ <div class="flex-shrink-0">
5
+ <%= icon %>
6
+ </div>
7
+ <div class="ml-3">
8
+ <div class="mt-2 text-sm <%= CONTENT_SCHEME_CLASS_MAPPINGS[@scheme] %>">
9
+ <p><%= content %></p>
10
+ </div>
11
+ <% if has_action? %>
12
+ <div class="mt-4 pt-5">
13
+ <div class="-mx-2 -my-1.5 flex">
14
+ <%= action %>
15
+ </div>
16
+ </div>
17
+ <% end %>
18
+ </div>
19
+ <% if dismissible? %>
20
+ <div class="pl-3">
21
+ <div class="-mx-1.5 -my-1.5">
22
+ <button type="button" class="inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 <%= dismissible_classes %>">
23
+ <span class="sr-only">Dismiss</span>
24
+ <%= ariadne_heroicon icon: :x, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE %>
25
+ </button>
26
+ </div>
27
+ </div>
28
+ <% end %>
29
+ </div>
30
+ </div>
31
+ <% end %>