ariadne_view_components 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # Use `FlashComponent` to inform users of successful messages, pending actions, or urgent notices.
5
+ class FlashComponent < Ariadne::Component
6
+ include IconHelper
7
+
8
+ DEFAULT_SCHEME = :default
9
+
10
+ DISMISSIBLE_SCHEME_CLASS_MAPPINGS = {
11
+ default: "text-blue-500 bg-blue-50 hover:bg-blue-100 focus:ring-offset-blue-50 focus:ring-blue-600",
12
+ info: "text-blue-500 bg-blue-50 hover:bg-blue-100 focus:ring-offset-blue-50 focus:ring-blue-600",
13
+ success: "text-green-500 bg-green-50 hover:bg-green-100 focus:ring-offset-green-50 focus:ring-green-600",
14
+ warning: "text-yellow-500 bg-yellow-50 hover:bg-yellow-100 focus:ring-offset-yellow-50 focus:ring-yellow-600",
15
+ danger: "text-red-500 bg-red-50 hover:bg-red-100 focus:ring-offset-red-50 focus:ring-red-600",
16
+ }.freeze
17
+ VALID_DISMISSIBLE_SCHEMES = DISMISSIBLE_SCHEME_CLASS_MAPPINGS.keys.freeze
18
+
19
+ BG_SCHEME_CLASS_MAPPINGS = {
20
+ default: "bg-blue-50",
21
+ info: "bg-blue-50",
22
+ success: "bg-green-50",
23
+ warning: "bg-yellow-50",
24
+ danger: "bg-red-50",
25
+ }.freeze
26
+ VALID_BG_SCHEMES = BG_SCHEME_CLASS_MAPPINGS.keys.freeze
27
+
28
+ CONTENT_SCHEME_CLASS_MAPPINGS = {
29
+ default: "text-blue-700",
30
+ info: "text-blue-700",
31
+ success: "text-green-700",
32
+ warning: "text-yellow-700",
33
+ danger: "text-red-700",
34
+ }.freeze
35
+ VALID_CONTENT_SCHEMES = CONTENT_SCHEME_CLASS_MAPPINGS.keys.freeze
36
+
37
+ # Optional visuals appearing to the left of the flash banner.
38
+ #
39
+ # Use:
40
+ #
41
+ # - `icon` for a <%= link_to_component(Ariadne::HeroiconComponent) %>.
42
+ #
43
+ # @option params [Symbol] :icon The rendered tag name
44
+ # @option params [Symbol] :icon Name of <%= link_to_heroicons %> to use.
45
+ # @option params [Symbol] :variant <%= one_of(HeroiconsHelper::Icon::VARIANTS, sort: false) %>
46
+ # @option params [String] :classes <%= link_to_classes_docs %>
47
+ # @option params [Hash] :attributes Same arguments as <%= link_to_component(Ariadne::HeroiconComponent) %>.
48
+ renders_one :icon, lambda { |tag: :svg, icon:, variant:, classes: "", attributes: {}|
49
+ @icon = icon
50
+ @variant = variant
51
+
52
+ tag = check_incoming_tag(:svg, tag)
53
+ Ariadne::HeroiconComponent.new(tag: tag, icon: icon, variant: variant, classes: classes, attributes: attributes)
54
+ }
55
+
56
+ # Optional action content showed at the bottom of the component.
57
+ #
58
+ # @param tag [Symbol, String] The rendered tag name
59
+ # @param classes [String] <%= link_to_classes_docs %>
60
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
61
+ renders_one :action, lambda { |tag: :div, classes: "", attributes: {}|
62
+ tag = check_incoming_tag(:div, tag)
63
+
64
+ actual_classes = class_names(classes)
65
+
66
+ Ariadne::BaseComponent.new(tag: tag, classes: actual_classes, attributes: attributes)
67
+ }
68
+
69
+ # @example Schemes
70
+ # <%= render(Ariadne::FlashComponent.new) { "This is a flash message!" } %>
71
+ # <%= render(Ariadne::FlashComponent.new(scheme: :warning)) { "This is a warning flash message!" } %>
72
+ # <%= render(Ariadne::FlashComponent.new(scheme: :danger)) { "This is a danger flash message!" } %>
73
+ # <%= render(Ariadne::FlashComponent.new(scheme: :success)) { "This is a success flash message!" } %>
74
+ #
75
+ # @example Dismissible
76
+ # <%= render(Ariadne::FlashComponent.new(dismissible: true)) { "This is a dismissible flash message!" } %>
77
+ #
78
+ # @example Icon
79
+ # <%= render(Ariadne::FlashComponent.new) do |component| %>
80
+ # <% component.icon(icon: :"user-group", variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) %>
81
+ # Look at this icon.
82
+ # <% end %>
83
+ #
84
+ # @example With actions
85
+ # <%= render(Ariadne::FlashComponent.new) do |component| %>
86
+ # <% component.action do %>
87
+ # <%= render(Ariadne::ButtonComponent.new(size: :s)) { "Take action" } %>
88
+ # <% end %>
89
+ # This is a flash message with actions!
90
+ # <% end %>
91
+ #
92
+ # @param tag [Symbol, String] The rendered tag name.
93
+ # @param dismissible [Boolean] Whether the component can be dismissed with an X button.
94
+ # @param icon [Symbol, String] Name of <%= link_to_heroicons %> to use.
95
+ # @param scheme [Symbol] <%= one_of(Ariadne::FlashComponent::VALID_CONTENT_SCHEMES) %>
96
+ # @param classes [String] <%= link_to_classes_docs %>
97
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
98
+ def initialize(tag: :div, dismissible: false, scheme: DEFAULT_SCHEME, classes: "", attributes: {})
99
+ @dismissible = dismissible
100
+
101
+ @tag = check_incoming_tag(:div, tag)
102
+
103
+ @scheme = fetch_or_raise(VALID_CONTENT_SCHEMES, scheme)
104
+
105
+ @classes = class_names(
106
+ CONTENT_SCHEME_CLASS_MAPPINGS[@scheme],
107
+ classes
108
+ )
109
+
110
+ @attributes = attributes
111
+ end
112
+
113
+ private def has_action?
114
+ action.present?
115
+ end
116
+
117
+ private def dismissible?
118
+ @dismissible.present?
119
+ end
120
+
121
+ private def dismissible_classes
122
+ DISMISSIBLE_SCHEME_CLASS_MAPPINGS[@scheme]
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # `Heading` should be used to communicate page organization and hierarchy.
5
+ #
6
+ # - Set tag to one of `:h1`, `:h2`, `:h3`, `:h4`, `:h5`, `:h6` based on what is appropriate for the page context.
7
+ # - Use `Heading` as the title of a section or sub section.
8
+ # - Do not use `Heading` for styling alone. For simply styling text, consider using <%= link_to_component(Ariadne::Text) %> with relevant <%= link_to_typography_docs %>
9
+ # such as `font_size` and `font_weight`.
10
+ # - Do not jump heading levels. For instance, do not follow a `<h1>` with an `<h3>`. Heading levels should increase by one in ascending order.
11
+ #
12
+ # @accessibility
13
+ # While sighted users rely on visual cues such as font size changes to determine what the heading is, assistive technology users rely on programatic cues that can be read out.
14
+ # When text on a page is visually implied to be a heading, ensure that it is coded as a heading. Additionally, visually implied heading level and coded heading level should be
15
+ # consistent. [See WCAG success criteria: 1.3.1: Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
16
+ #
17
+ # Headings allow assistive technology users to quickly navigate around a page. Navigation to text that is not meant to be a heading can be a confusing experience.
18
+ # <%= link_to_heading_practices %>
19
+ class HeadingComponent < Ariadne::Component
20
+ TAG_OPTIONS = [:h1, :h2, :h3, :h4, :h5, :h6].freeze
21
+
22
+ TAG_TO_CLASSES = {
23
+ h1: "font-bold leading-7 sm:text-3xl sm:truncate",
24
+ h2: "text-3xl font-extrabold text-gray-900",
25
+ h3: "text-2xl font-extrabold text-gray-900",
26
+ }
27
+ # @example Default
28
+ # <%= render(Ariadne::HeadingComponent.new(tag: :h1)) { "H1 Text" } %>
29
+ # <%= render(Ariadne::HeadingComponent.new(tag: :h2)) { "H2 Text" } %>
30
+ # <%= render(Ariadne::HeadingComponent.new(tag: :h3)) { "H3 Text" } %>
31
+ # <%= render(Ariadne::HeadingComponent.new(tag: :h4)) { "H4 Text" } %>
32
+ # <%= render(Ariadne::HeadingComponent.new(tag: :h5)) { "H5 Text" } %>
33
+ # <%= render(Ariadne::HeadingComponent.new(tag: :h6)) { "H6 Text" } %>
34
+ #
35
+ # @param tag [String] <%= one_of(Ariadne::HeadingComponent::TAG_OPTIONS) %>
36
+ # @param classes [String] <%= link_to_classes_docs %>
37
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
38
+ def initialize(tag: nil, classes: "", attributes: {})
39
+ @tag = fetch_or_raise(TAG_OPTIONS, tag)
40
+ @attributes = attributes
41
+
42
+ @classes = class_names(*TAG_TO_CLASSES[tag], classes)
43
+ end
44
+
45
+ def call
46
+ render(Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes)) { content }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,7 @@
1
+ <%= render(Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes)) do %>
2
+ <% if @use_symbol %>
3
+ <use href="#heroicon_<%= [@icon.symbol, @icon.height].join("_") %>"></use>
4
+ <% else %>
5
+ <%= @icon.path.html_safe %>
6
+ <% end %>
7
+ <% end %>
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "heroicons_helper"
4
+
5
+ module Ariadne
6
+ # `Heroicon` renders an <%= link_to_heroicons %> with <%= link_to_attributes_docs %>.
7
+ # `Heroicon` can also be rendered with the `heroicon` helper.
8
+ class HeroiconComponent < Ariadne::Component
9
+ include IconHelper
10
+ include HeroiconsHelper
11
+
12
+ SIZE_DEFAULT = :small
13
+ SIZE_MEDIUM = :medium
14
+
15
+ SIZE_MAPPINGS = {
16
+ SIZE_DEFAULT => 16,
17
+ SIZE_MEDIUM => 24,
18
+ }.freeze
19
+ SIZE_OPTIONS = SIZE_MAPPINGS.keys
20
+
21
+ PRELOADED_ICONS = [
22
+ { name: "bell",
23
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
24
+ { name: "check",
25
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
26
+ { name: "chevron-down",
27
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
28
+ { name: "clipboard",
29
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
30
+ { name: "clock",
31
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
32
+ { name: "information-circle",
33
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
34
+ { name: "dots-horizontal",
35
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
36
+ { name: "link",
37
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
38
+ { name: "lock-closed",
39
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
40
+ { name: "mail",
41
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
42
+ { name: "pencil",
43
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
44
+ { name: "plus-sm",
45
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
46
+ { name: "question-mark-circle",
47
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
48
+ { name: "search",
49
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
50
+ { name: "search",
51
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
52
+ { name: "trash",
53
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
54
+ { name: "x",
55
+ variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, },
56
+ ].freeze
57
+
58
+ # @example Default
59
+ # <%= render(Ariadne::HeroiconComponent.new(icon: :check, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE)) %>
60
+ # <%= render(Ariadne::HeroiconComponent.new(icon: :check, variant: HeroiconsHelper::Icon::VARIANT_SOLID)) %>
61
+ #
62
+ # @example Medium
63
+ # <%= render(Ariadne::HeroiconComponent.new(icon: :"user-group", variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, size: :medium)) %>
64
+ #
65
+ # @example Helper
66
+ # <%= ariadne_heroicon(icon: :check, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE) %>
67
+ #
68
+ # @param tag [Symbol, String] The rendered tag name
69
+ # @param classes [String] <%= link_to_classes_docs %>
70
+ # @param icon [Symbol, String] Name of <%= link_to_heroicons %> to use.
71
+ # @param variant [String] <%= one_of(HeroiconsHelper::Icon::VARIANTS, sort: false) %>
72
+ # @param size [Symbol] <%= one_of(Ariadne::HeroiconComponent::SIZE_MAPPINGS, sort: false) %>
73
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
74
+ def initialize(tag: :svg, icon:, variant:, size: SIZE_DEFAULT, classes: "", attributes: {})
75
+ @tag = check_incoming_tag(:svg, tag)
76
+
77
+ check_icon_presence!(icon, variant)
78
+
79
+ @attributes = attributes
80
+ @attributes[:aria] ||= {}
81
+
82
+ if @attributes[:aria][:label] || @attributes[:"aria-label"]
83
+ @attributes[:role] = "img"
84
+ else
85
+ @attributes[:aria][:hidden] = true
86
+ end
87
+
88
+ # Don't allow sizes under 16px
89
+ if attributes[:height].present? && attributes[:height].to_i < 16 || attributes[:width].present? && attributes[:width].to_i < 16
90
+ attributes.delete(:height)
91
+ attributes.delete(:width)
92
+ end
93
+
94
+ # Filter out classify options to prevent them from becoming invalid html attributes.
95
+ # Note height and width are both classify options and valid html attributes.
96
+ attributes = {
97
+ height: @attributes[:height] || SIZE_MAPPINGS[fetch_or_raise(SIZE_OPTIONS, size)],
98
+ width: @attributes[:width],
99
+ }
100
+
101
+ @icon = heroicon(icon, variant: variant, **attributes) # rubocop:disable Ariadne/AriadneHeroicon
102
+
103
+ @classes = class_names(
104
+ @icon.attributes[:class],
105
+ classes
106
+ )
107
+ @attributes.merge!(@icon.attributes.except(:class, :"aria-hidden"))
108
+ end
109
+
110
+ def self._after_compile
111
+ HeroiconsHelper::Cache.preload!(PRELOADED_ICONS) do |found, icon|
112
+ HeroiconComponent.new(icon: icon[:name], variant: icon[:variant]) unless found
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # Use `Image` to render images.
5
+ #
6
+ # @accessibility
7
+ # Always provide a meaningful `alt`.
8
+ class ImageComponent < Ariadne::Component
9
+ # @example Default
10
+ #
11
+ # <%= render(Ariadne::ImageComponent.new(src: "https://github.com/github.png", alt: "GitHub")) %>
12
+ #
13
+ # @example Helper
14
+ #
15
+ # <%= ariadne_image(src: "https://github.com/github.png", alt: "GitHub") %>
16
+ #
17
+ # @example Lazy loading
18
+ #
19
+ # <%= render(Ariadne::ImageComponent.new(src: "https://github.com/github.png", alt: "GitHub", lazy: true)) %>
20
+ #
21
+ # @example Custom size
22
+ #
23
+ # <%= render(Ariadne::ImageComponent.new(src: "https://github.com/github.png", alt: "GitHub", attributes: { height: 100, width: 100 })) %>
24
+ #
25
+ # @param tag [Symbol, String] The rendered tag name
26
+ # @param classes [String] <%= link_to_classes_docs %>
27
+ # @param src [String] The source url of the image.
28
+ # @param alt [String] Specifies an alternate text for the image.
29
+ # @param lazy [Boolean] Whether or not to lazily load the image.
30
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
31
+ def initialize(tag: :img, src:, alt:, lazy: false, classes: "", attributes: {})
32
+ @attributes = attributes
33
+
34
+ @src = src
35
+ @tag = check_incoming_tag(:img, tag)
36
+ @classes = classes
37
+
38
+ @attributes[:alt] = alt
39
+ @attributes[:src] = image_path(@src)
40
+
41
+ return unless lazy
42
+
43
+ @attributes[:loading] = :lazy
44
+ @attributes[:decoding] = :async
45
+ end
46
+
47
+ def call
48
+ render(Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes))
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # `Text` is a wrapper component that will apply typography styles to the text inside.
5
+ class Text < Ariadne::Component
6
+ DEFAULT_TAG = :span
7
+
8
+ # @example Default
9
+ # <%= render(Ariadne::Text.new(tag: :p, attributes: { font_weight: :bold })) { "Bold Text" } %>
10
+ # <%= render(Ariadne::Text.new(tag: :p, attributes: { color: :danger })) { "Danger Text" } %>
11
+ #
12
+ # @param tag [Symbol, String] The rendered tag name
13
+ # @param classes [String] <%= link_to_classes_docs %>
14
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
15
+ def initialize(tag: DEFAULT_TAG, classes: "", attributes: {})
16
+ @tag = tag
17
+ @classes = classes
18
+ @attributes = attributes
19
+ end
20
+
21
+ def call
22
+ render(Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes)) { content }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ariadne
4
+ # `Tooltip` only appears on mouse hover or keyboard focus and contain a label or description text.
5
+ # Use tooltips sparingly and as a last resort.
6
+ #
7
+ # When using a tooltip, follow the provided guidelines to avoid accessibility issues.
8
+ #
9
+ # - Tooltip text should be brief and to the point. The tooltip content must be a string.
10
+ # - Tooltips should contain only **non-essential text**. Tooltips can easily be missed and are not accessible on touch devices so never
11
+ # use tooltips to convey critical information.
12
+ #
13
+ # @accessibility
14
+ # - **Never set tooltips on static elements.** Tooltips should only be used on interactive elements like buttons or links to avoid excluding keyboard-only users
15
+ # and screen reader users.
16
+ # - Place `Tooltip` adjacent after its trigger element in the DOM. This allows screen reader users to navigate to and copy the tooltip
17
+ # content.
18
+ # ### Which `type` should I set?
19
+ # Setting `:description` establishes an `aria-describedby` relationship, while `:label` establishes an `aria-labelledby` relationship between the trigger element and the tooltip,
20
+ #
21
+ # The `type` drastically changes semantics and screen reader behavior so follow these guidelines carefully:
22
+ # - When there is already a visible label text on the trigger element, the tooltip is likely intended to supplement the existing text, so set `type: :description`.
23
+ # The majority of tooltips will fall under this category.
24
+ # - When there is no visible text on the trigger element and the tooltip content is appropriate as a label for the element, set `type: :label`.
25
+ # This type is usually only appropriate for an icon-only control.
26
+ class TooltipComponent < Ariadne::Component
27
+ DIRECTION_DEFAULT = :s
28
+ DIRECTION_OPTIONS = [DIRECTION_DEFAULT, :n, :e, :w, :ne, :nw, :se, :sw].freeze
29
+
30
+ TYPE_DEFAULT = :description
31
+ TYPE_OPTIONS = [:label, TYPE_DEFAULT].freeze
32
+ # @example As a description for an icon-only button
33
+ # @description
34
+ # If the tooltip content provides supplementary description, set `type: :description` to establish an `aria-describedby` relationship.
35
+ # The trigger element should also have a _concise_ accessible label via `aria-label`.
36
+ # @code
37
+ # <%= render(Ariadne::HeroiconComponent.new(icon: :moon, variant: HeroiconsHelper::Icon::VARIANT_OUTLINE, attributes: { id: "bold-button-0" })) %>
38
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "bold-button-0" }, type: :description, text: "Add bold text", direction: :ne)) %>
39
+ # @example As a label for an icon-only button
40
+ # @description
41
+ # If the tooltip labels the icon-only button, set `type: :label`. This tooltip content becomes the accessible name for the button.
42
+ # @code
43
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "like-button"})) { "👍" } %>
44
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "like-button" }, type: :label, text: "Like", direction: :n)) %>
45
+ #
46
+ # @example As a description for a button with visible label
47
+ # @description
48
+ # If the button already has visible label text, the tooltip content is likely supplementary so set `type: :description`.
49
+ # @code
50
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "save-button"}, scheme: :success)) { "Save" } %>
51
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "save-button"}, type: :description, text: "This will immediately impact all organization members", direction: :ne)) %>
52
+ # @example With direction
53
+ # @description
54
+ # Set direction of tooltip with `direction`. The tooltip is responsive and will automatically adjust direction to avoid cutting off.
55
+ # @code
56
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "North", m: 2})) { "North" } %>
57
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "North"}, type: :description, text: "This is a North-facing tooltip, and is responsive.", direction: :n)) %>
58
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "South", m: 2})) { "South" } %>
59
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "South"}, type: :description, text: "This is a South-facing tooltip and is responsive.", direction: :s)) %>
60
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "East", m: 2})) { "East" } %>
61
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "East"}, type: :description, text: "This is a East-facing tooltip and is responsive.", direction: :e)) %>
62
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "West", m: 2})) { "West" } %>
63
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "West"}, type: :description, text: "This is a West-facing tooltip and is responsive.", direction: :w)) %>
64
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "Northeast", m: 2})) { "Northeast" } %>
65
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "Northeast"}, type: :description, text: "This is a Northeast-facing tooltip and is responsive.", direction: :ne)) %>
66
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "Southeast", m: 2})) { "Southeast" } %>
67
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "Southeast"}, type: :description, text: "This is a Southeast-facing tooltip and is responsive.", direction: :se)) %>
68
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "Northwest", m: 2})) { "Northwest" } %>
69
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "Northwest"}, type: :description, text: "This is a Northwest-facing tooltip and is responsive.", direction: :nw)) %>
70
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "Southwest", m: 2})) { "Southwest" } %>
71
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "Southwest"}, type: :description, text: "This is a Southwest-facing tooltip and is responsive.", direction: :sw)) %>
72
+ # @example With relative parent
73
+ # @description
74
+ # When the tooltip and trigger element have a parent container with `relative: position`, it should not affect width of the tooltip.
75
+ # @code
76
+ # <span style="position: relative;">
77
+ # <%= render(Ariadne::ButtonComponent.new(attributes: {id: "test-button"}, scheme: :info)) { "Test" } %>
78
+ # <%= render(Ariadne::TooltipComponent.new(attributes: { for: "test-button" }, type: :description, text: "This tooltip should take up the full width", direction: :ne)) %>
79
+ # </span>
80
+ # @param tag [Symbol, String] The rendered tag name
81
+ # @param type [Symbol] <%= one_of(Ariadne::TooltipComponent::TYPE_OPTIONS) %>
82
+ # @param text [String] The text content of the tooltip. This should be brief and no longer than a sentence.
83
+ # @param direction [Symbol] <%= one_of(Ariadne::TooltipComponent::DIRECTION_OPTIONS) %>
84
+ # @param classes [String] <%= link_to_classes_docs %>
85
+ # @param attributes [Hash] <%= link_to_attributes_docs %>
86
+ def initialize(tag: :"tool-tip", type: TYPE_DEFAULT, text:, direction: DIRECTION_DEFAULT, classes: "", attributes: {})
87
+ raise TypeError, "tooltip text must be a string" unless text.is_a?(String)
88
+
89
+ @tag = check_incoming_tag(:"tool-tip", tag)
90
+
91
+ @text = text
92
+ @classes = classes
93
+
94
+ @attributes = attributes
95
+ @attributes[:hidden] = true
96
+ @attributes[:visible] ||= false
97
+ @attributes[:"data-direction"] = fetch_or_raise(DIRECTION_OPTIONS, direction)
98
+ @attributes[:"data-type"] = fetch_or_raise(TYPE_OPTIONS, type)
99
+ end
100
+
101
+ def call
102
+ render(Ariadne::BaseComponent.new(tag: @tag, classes: @classes, attributes: @attributes)) { @text }
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Ariadne
6
+ # :nodoc:
7
+ module Audited
8
+ # DSL to register when a component has passed an accessibility audit.
9
+ #
10
+ # Example:
11
+ #
12
+ # class MyComponent < ViewComponent::Base
13
+ # include Ariadne::Audited::Dsl
14
+ # audited_at 'YYYY-MM-DD'
15
+ # end
16
+ module Dsl
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ class_attribute :audit_date, instance_writer: false
21
+ end
22
+
23
+ class_methods do
24
+ def audited_at(date = nil)
25
+ return audit_date if date.nil?
26
+
27
+ self.audit_date = date
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helps build a list of conditional class names
4
+ module Ariadne
5
+ # :nodoc:
6
+ module ClassNameHelper
7
+ def class_names(*args)
8
+ [].tap do |classes|
9
+ args.each do |class_name|
10
+ next if class_name.blank?
11
+
12
+ case class_name
13
+ when String
14
+ classes << class_name
15
+ else
16
+ raise ArgumentError, "Expected String class name, got #{class_name.class}"
17
+ end
18
+ end
19
+ end.join(" ")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ariadne::FetchOrFallbackHelper
4
+ # A little helper to enable graceful fallbacks
5
+ #
6
+ # Use this helper to quietly ensure a value is
7
+ # one that you expect:
8
+ #
9
+ # allowed_values - allowed options for *value*
10
+ # given_value - input being coerced
11
+ # fallback - returned if *given_value* is not included in *allowed_values*
12
+ #
13
+ # fetch_or_raise([1,2,3], 5) => 2
14
+ # fetch_or_raise([1,2,3], 1) => 1
15
+ # fetch_or_raise([1,2,3], nil) => 2
16
+ module Ariadne
17
+ # :nodoc:
18
+ module FetchOrFallbackHelper
19
+ include LoggerHelper
20
+
21
+ mattr_accessor :fallback_raises, default: true
22
+
23
+ InvalidValueError = Class.new(StandardError)
24
+
25
+ TRUE_OR_FALSE = [true, false].freeze
26
+
27
+ def fetch_or_raise(allowed_values, given_value)
28
+ raise ArgumentError, "allowed_values must be an array; it was #{allowed_values.class}" unless allowed_values.is_a?(Array)
29
+
30
+ if allowed_values.include?(given_value)
31
+ given_value
32
+ else
33
+ raise InvalidValueError, <<~MSG
34
+ fetch_or_raise was called with an invalid value.
35
+
36
+ Expected one of: #{allowed_values.inspect}
37
+ Got: #{given_value.inspect}
38
+ MSG
39
+ end
40
+ end
41
+
42
+ def fetch_or_raise_boolean(given_value)
43
+ fetch_or_raise(TRUE_OR_FALSE, given_value)
44
+ end
45
+
46
+ # TODO: test this
47
+ def check_incoming_tag(preferred_tag, given_tag)
48
+ return preferred_tag if given_tag.blank? || preferred_tag == given_tag
49
+
50
+ unless silence_warnings?
51
+ message = <<~MSG
52
+ Ariadne: note that `#{preferred_tag}` is the preferred tag here;
53
+ you passed `#{given_tag}` (which will still be used)
54
+ MSG
55
+
56
+ logger.warn(message)
57
+ end
58
+
59
+ given_tag
60
+ end
61
+
62
+ # TODO: test this
63
+ def check_incoming_attribute(preferred_attribute, given_attribute)
64
+ return preferred_attribute if given_attribute.blank? || preferred_attribute != given_attribute
65
+
66
+ unless silence_warnings?
67
+ message = <<~MSG
68
+ Ariadne: note that `#{preferred_attribute}` is the preferred attribute here;
69
+ you passed `#{given_attribute}` (which will still be used)
70
+ MSG
71
+
72
+ logger.warn(message)
73
+ end
74
+
75
+ given_attribute
76
+ end
77
+
78
+ # TODO: test this
79
+ def check_incoming_value(preferred_value, given_pair)
80
+ return preferred_value if given_pair.blank? || !given_pair.is_a?(Hash)
81
+
82
+ given_key = given_pair.keys.first
83
+ given_value = given_pair.values.first
84
+
85
+ return preferred_value if given_value.blank?
86
+
87
+ unless silence_warnings?
88
+
89
+ message = <<~MSG
90
+ Ariadne: note that `#{preferred_value}` is the preferred value for `#{given_key}` here;
91
+ you passed `#{given_value}` (which will still be used)
92
+ MSG
93
+
94
+ logger.warn(message)
95
+ end
96
+
97
+ given_value
98
+ end
99
+ end
100
+ end