lostcoords-ui 0.4.1 → 0.6.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +364 -96
  3. data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
  4. data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +23 -22
  6. data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
  7. data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
  8. data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +6 -6
  9. data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
  10. data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
  11. data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
  12. data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
  14. data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
  15. data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
  16. data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
  17. data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
  18. data/app/assets/tailwind/lostcoords/ui/components.css +12 -0
  19. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  20. data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
  21. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  22. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +86 -88
  23. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  24. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  25. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  26. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  27. data/app/assets/tailwind/lostcoords/ui/prose.css +66 -0
  28. data/app/assets/tailwind/lostcoords/ui/sheet.css +62 -63
  29. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  30. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  31. data/app/assets/tailwind/lostcoords/ui/theme.css +209 -225
  32. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  33. data/app/assets/tailwind/lostcoords/ui/tooltip.css +51 -0
  34. data/app/assets/tailwind/lostcoords/ui/typography.css +31 -61
  35. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  36. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  37. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  38. data/app/helpers/lostcoords/ui/button_helper.rb +44 -35
  39. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  40. data/app/helpers/lostcoords/ui/choice_helper.rb +87 -0
  41. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  42. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  43. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  44. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  45. data/app/helpers/lostcoords/ui/dialog_helper.rb +15 -17
  46. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  47. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  48. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  49. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  50. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  51. data/app/helpers/lostcoords/ui/menu_helper.rb +45 -10
  52. data/app/helpers/lostcoords/ui/navigation_helper.rb +144 -0
  53. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  54. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  55. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  56. data/app/helpers/lostcoords/ui/sheet_helper.rb +7 -45
  57. data/app/helpers/lostcoords/ui/structures_helper.rb +26 -125
  58. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  59. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  60. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  61. data/app/helpers/lostcoords/ui/tooltip_helper.rb +17 -0
  62. data/app/helpers/lostcoords/ui/typography_helper.rb +41 -11
  63. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  64. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +4 -4
  65. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  66. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  67. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +38 -10
  68. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  69. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +25 -3
  70. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  71. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +7 -15
  72. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +17 -17
  73. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  74. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +42 -0
  75. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  76. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  77. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +54 -0
  78. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +77 -25
  79. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  80. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  81. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +30 -7
  82. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +7 -8
  83. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  84. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  85. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  86. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  87. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  88. data/app/javascript/lostcoords/ui/index.js +57 -0
  89. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +46 -46
  90. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  91. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  92. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  93. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  94. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  95. data/config/importmap.rb +10 -2
  96. data/config/locales/en.yml +23 -0
  97. data/config/locales/es.yml +23 -0
  98. data/config/locales/ja.yml +23 -0
  99. data/lib/lostcoords/ui/action_view.rb +6 -3
  100. data/lib/lostcoords/ui/builders.rb +2 -2
  101. data/lib/lostcoords/ui/form_builder.rb +126 -0
  102. data/lib/lostcoords/ui/parts.rb +154 -0
  103. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  104. data/lib/lostcoords/ui/tokens.rb +24 -1
  105. data/lib/lostcoords/ui/version.rb +1 -1
  106. data/lib/tasks/lostcoords_ui.rake +11 -0
  107. metadata +54 -21
  108. data/app/assets/tailwind/lostcoords/ui/reading.css +0 -60
  109. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  110. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  111. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  112. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  113. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  114. data/config/helper_renames.json +0 -113
  115. data/script/migrate_helpers.rb +0 -136
  116. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  117. /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
@@ -1,17 +1,10 @@
1
1
  module Lostcoords::Ui
2
2
  module PrimitivesHelper
3
- BUTTON_STYLES = {
4
- primary: :accent,
5
- secondary: :secondary,
6
- ghost: :ghost,
7
- destructive: :danger
8
- }.freeze
9
-
10
- def lc_button(content = nil, variant: :primary, size: :base, icon: nil, icon_position: :start, **attributes, &block)
3
+ def lc_button(content = nil, variant: :primary, size: :md, icon: nil, icon_position: :start, **attributes, &block)
11
4
  tag.button(lc_button_content(content, icon, icon_position, &block), **lc_button_attributes(variant, size, { type: "button" }, attributes))
12
5
  end
13
6
 
14
- def lc_button_link_to(content = nil, url = nil, variant: :primary, size: :base, icon: nil, icon_position: :start, **attributes, &block)
7
+ def lc_button_link_to(content = nil, url = nil, variant: :primary, size: :md, icon: nil, icon_position: :start, **attributes, &block)
15
8
  attributes = lc_button_attributes(variant, size, {}, attributes)
16
9
  raise ArgumentError, "Links cannot be disabled; use lc_button for a disabled control" if attributes[:disabled]
17
10
 
@@ -23,7 +16,7 @@ module Lostcoords::Ui
23
16
  link_to(lc_button_content(content, icon, icon_position), url, attributes)
24
17
  end
25
18
 
26
- def lc_button_to(content = nil, url = nil, variant: :primary, size: :base, icon: nil, icon_position: :start, **attributes, &block)
19
+ def lc_button_to(content = nil, url = nil, variant: :primary, size: :md, icon: nil, icon_position: :start, **attributes, &block)
27
20
  if block
28
21
  url ||= content
29
22
  content = capture(&block)
@@ -32,10 +25,10 @@ module Lostcoords::Ui
32
25
  button_to(lc_button_content(content, icon, icon_position), url, lc_button_attributes(variant, size, {}, attributes))
33
26
  end
34
27
 
35
- def lc_icon_button(label, icon:, size: :base, shape: :circle, **attributes)
28
+ def lc_icon_button(label, icon:, size: :md, shape: :circle, **attributes)
36
29
  size = lc_option!(:size, size, ButtonHelper::ICON_BUTTON_SIZES.keys)
37
30
  shape = lc_option!(:shape, shape, ButtonHelper::ICON_BUTTON_SHAPES.keys)
38
- defaults = { type: "button", class: lc_icon_button_class(size: size, shape: shape), aria: { label: label } }
31
+ defaults = { type: "button", class: lc_icon_button_class(size: size, shape: shape), aria: { label: label }, data: { slot: "icon-button", size: size } }
39
32
  attributes = lc_attributes(defaults, attributes)
40
33
  accessible_label = attributes.fetch(:"aria-label", attributes.dig(:aria, :label))
41
34
  raise ArgumentError, "lc_icon_button requires a nonblank accessible label" if label.blank? || accessible_label.blank?
@@ -47,36 +40,18 @@ module Lostcoords::Ui
47
40
 
48
41
  def lc_badge(content = nil, tone: :neutral, **attributes, &block)
49
42
  tone = lc_option!(:tone, tone, BadgeHelper::BADGE_TONES.keys)
50
- tag.span(content, **lc_attributes({ class: lc_badge_class(tone: tone) }, attributes), &block)
43
+ tag.span(content, **lc_attributes({ class: lc_badge_class(tone: tone), data: { slot: "badge", tone: tone } }, attributes), &block)
51
44
  end
52
45
 
53
- def lc_status_pill(content = nil, tone: :accent, **attributes, &block)
54
- tone = lc_option!(:tone, tone, BadgeHelper::STATUS_PILL_TONES.keys)
55
- tag.span(content, **lc_attributes({ class: lc_status_pill_class(tone: tone) }, attributes), &block)
56
- end
57
-
58
- def lc_eyebrow(content = nil, **attributes, &block)
59
- tag.span(content, **lc_attributes({ class: lc_eyebrow_class }, attributes), &block)
60
- end
61
-
62
- def lc_icon_badge(icon:, size: :base, **attributes)
63
- size = lc_option!(:size, size, BadgeHelper::ICON_BADGE_SIZES.keys)
64
- tag.span(**lc_attributes({ class: lc_icon_badge_class(size: size) }, attributes)) do
46
+ def lc_icon_badge(icon:, size: :md, shape: :square, tone: :neutral, **attributes)
47
+ tag.span(**lc_attributes({ class: lc_icon_badge_class(size: size, shape: shape, tone: tone), data: { slot: "icon-badge" } }, attributes)) do
65
48
  lc_icon(icon, options: { class: "size-[1em]", "aria-hidden": true })
66
49
  end
67
50
  end
68
51
 
69
- def lc_medallion(icon:, size: :sm, tone: :accent, **attributes)
70
- size = lc_option!(:size, size, BadgeHelper::ICON_MEDALLION_SIZES.keys)
71
- tone = lc_option!(:tone, tone, BadgeHelper::ICON_MEDALLION_TONES.keys)
72
- tag.span(**lc_attributes({ class: lc_icon_medallion_class(size: size, tone: tone) }, attributes)) do
73
- lc_icon(icon, options: { class: "size-1/2", "aria-hidden": true })
74
- end
75
- end
76
-
77
- def lc_avatar(content = nil, src: nil, alt: nil, size: :base, ink: false, linked: false, **attributes, &block)
52
+ def lc_avatar(content = nil, src: nil, alt: nil, size: :md, ink: false, linked: false, **attributes, &block)
78
53
  size = lc_option!(:size, size, BadgeHelper::AVATAR_SIZES.keys)
79
- defaults = { class: lc_avatar_class(size: size, ink: ink, linked: linked) }
54
+ defaults = { class: lc_avatar_class(size: size, ink: ink, linked: linked), data: { slot: "avatar" } }
80
55
  if src
81
56
  tag.img(**lc_attributes(defaults.merge(src: src, alt: alt.to_s, class: lc_tw(defaults[:class], "object-cover")), attributes))
82
57
  else
@@ -85,51 +60,55 @@ module Lostcoords::Ui
85
60
  end
86
61
 
87
62
  def lc_avatar_stack(compact: false, **attributes, &block)
88
- tag.span(**lc_attributes({ class: lc_avatar_stack_class(compact: compact) }, attributes)) do
63
+ tag.span(**lc_attributes({ class: lc_avatar_stack_class(compact: compact), data: { slot: "avatar-stack" } }, attributes)) do
89
64
  tag.span(class: lc_avatar_stack_row_class, &block)
90
65
  end
91
66
  end
92
67
 
93
- def lc_card(href: nil, interactive: false, dense: false, **attributes, &block)
94
- defaults = { class: lc_card_class(interactive: interactive || !href.nil?, dense: dense) }
68
+ # Yields a card builder with header, title, description, content and footer parts. The card stacks
69
+ # its children with the same inset it pads with.
70
+ def lc_card(href: nil, interactive: false, padding: :md, **attributes, &block)
71
+ defaults = { class: lc_card_class(interactive: interactive || !href.nil?, padding: padding, extra: "flex flex-col gap-inset"), data: { slot: "card" } }
72
+ content = capture(Parts::Card.new(self), &block) if block
95
73
  if href.nil?
96
- tag.div(**lc_attributes(defaults, attributes), &block)
74
+ tag.div(content, **lc_attributes(defaults, attributes))
97
75
  else
98
- defaults.merge!(href: href, class: lc_tw(defaults[:class], "block no-underline text-inherit"))
99
- tag.a(**lc_attributes(defaults, attributes), &block)
76
+ defaults.merge!(href: href, class: lc_tw(defaults[:class], "no-underline text-inherit"))
77
+ tag.a(content, **lc_attributes(defaults, attributes))
100
78
  end
101
79
  end
102
80
 
103
- def lc_card_surface(**attributes, &block)
104
- tag.div(**lc_attributes({ class: lc_card_surface_class }, attributes), &block)
105
- end
106
-
107
- def lc_form_card(padding: :default, **attributes, &block)
108
- padding = lc_option!(:padding, padding, CardHelper::FORM_CARD_PADDINGS.keys)
109
- tag.div(**lc_attributes({ class: lc_form_card_class(padding: padding) }, attributes), &block)
110
- end
111
-
112
81
  def lc_empty_state(**attributes, &block)
113
- tag.div(**lc_attributes({ class: lc_empty_state_class }, attributes), &block)
82
+ tag.div(**lc_attributes({ class: lc_empty_state_class, data: { slot: "empty-state" } }, attributes), &block)
114
83
  end
115
84
 
116
- def lc_spinner(size: :base, label: "Loading…", **attributes)
85
+ def lc_spinner(size: :md, label: lc_t("lostcoords.ui.spinner.label"), **attributes)
117
86
  size = lc_option!(:size, size, FeedbackHelper::SPINNER_SIZES.keys)
118
- defaults = { class: lc_spinner_class(size: size), role: ("status" if label.present?), aria: { hidden: (true if label.blank?) } }
87
+ defaults = { class: lc_spinner_class(size: size), role: ("status" if label.present?), aria: { hidden: (true if label.blank?) }, data: { slot: "spinner" } }
119
88
  tag.span(**lc_attributes(defaults, attributes)) do
120
89
  tag.span(label, class: "sr-only") if label.present?
121
90
  end
122
91
  end
123
92
 
124
93
  def lc_skeleton(**attributes)
125
- tag.span(**lc_attributes({ class: lc_tw(lc_skeleton_class, "h-4 w-full"), aria: { hidden: true } }, attributes))
94
+ tag.span(**lc_attributes({ class: lc_tw(lc_skeleton_class, "h-4 w-full"), aria: { hidden: true }, data: { slot: "skeleton" } }, attributes))
126
95
  end
127
96
 
128
- def lc_form_alert(message, tone: :error, **attributes)
129
- tone = lc_option!(:tone, tone, FormHelper::FORM_ALERT_ICONS.keys)
130
- defaults = { class: lc_form_alert_class, role: (tone == :error ? "alert" : "status"), data: { tone: tone } }
97
+ # A block adds content below the message and yields a builder with an actions part.
98
+ def lc_alert(message = nil, title: nil, tone: :destructive, **attributes, &block)
99
+ tone = lc_option!(:tone, tone, FeedbackHelper::ALERT_ICONS.keys)
100
+ raise ArgumentError, "lc_alert needs a message, a title or a block" if message.blank? && title.blank? && block.nil?
101
+
102
+ defaults = { class: lc_alert_class, role: (tone == :destructive ? "alert" : "status"), data: { tone: tone, slot: "alert" } }
103
+ extra = capture(Parts::Alert.new(self), &block) if block
104
+ text = "m-0 min-w-0 text-pretty [overflow-wrap:anywhere]"
131
105
  tag.div(**lc_attributes(defaults, attributes)) do
132
- safe_join([ lc_form_alert_icon(tone: tone), tag.p(message, class: "m-0 min-w-0 text-pretty [overflow-wrap:anywhere]") ])
106
+ body = [
107
+ (tag.p(title, class: "#{text} font-semibold", data: { slot: "alert-title" }) if title.present?),
108
+ (tag.p(message, class: lc_tw(text, ("font-normal" if title.present?)), data: { slot: "alert-description" }) if message.present?),
109
+ extra.presence
110
+ ].compact
111
+ safe_join([ lc_alert_icon(tone: tone), tag.div(safe_join(body), class: "flex min-w-0 flex-col gap-tight") ])
133
112
  end
134
113
  end
135
114
 
@@ -144,9 +123,8 @@ module Lostcoords::Ui
144
123
  end
145
124
 
146
125
  def lc_button_attributes(variant, size, defaults, attributes)
147
- variant = lc_option!(:variant, variant, BUTTON_STYLES.keys)
148
- size = lc_option!(:size, size, %i[sm base lg])
149
- lc_attributes(defaults.merge(class: lc_button_class(variant: BUTTON_STYLES.fetch(variant), size: size)), attributes)
126
+ data = { slot: "button", variant: lc_option!(:variant, variant, ButtonHelper::BUTTON_VARIANTS.keys), size: lc_option!(:size, size, ButtonHelper::BUTTON_SIZES.keys) }
127
+ lc_attributes(defaults.merge(class: lc_button_class(variant: variant, size: size), data: data), attributes)
150
128
  end
151
129
  end
152
130
  end
@@ -1,8 +1,8 @@
1
1
  module Lostcoords::Ui
2
2
  module ReadingHelper
3
3
  PROSE_BASE =
4
- "prose ui-prose min-w-0 max-w-(--ui-reading-measure,65ch) " \
5
- "text-(length:--ui-reading-size,1rem) leading-(--ui-reading-leading,1.75)"
4
+ "prose lc-prose min-w-0 max-w-(--lc-reading-measure,65ch) " \
5
+ "text-(length:--lc-reading-size,1rem) leading-(--lc-reading-leading,1.75)"
6
6
 
7
7
  def lc_prose_class(extra: nil)
8
8
  lc_tw(PROSE_BASE, extra)
@@ -1,7 +1,10 @@
1
1
  module Lostcoords::Ui
2
2
  module RenderAttributesHelper
3
3
  private
4
- def lc_attributes(defaults, attributes)
4
+ # required: attributes the component needs to work, merged last so a caller can't override them.
5
+ def lc_attributes(defaults, attributes, required = nil)
6
+ return lc_attributes(lc_attributes(defaults, attributes), required) if required
7
+
5
8
  defaults = lc_normalize_attributes(defaults)
6
9
  attributes = lc_normalize_attributes(attributes)
7
10
  merged = defaults.merge(attributes)
@@ -36,6 +39,19 @@ module Lostcoords::Ui
36
39
  normalized
37
40
  end
38
41
 
42
+ def lc_label!(value, name)
43
+ raise ArgumentError, "#{name} must not be blank" if value.blank?
44
+ end
45
+
46
+ # The hint and error paragraphs under a field or choice group. With an id, controls can point
47
+ # aria-describedby at them.
48
+ def lc_field_messages(id, hint, errors)
49
+ [
50
+ (tag.p(hint, id: ("#{id}-hint" if id), class: "m-0 text-small text-muted-foreground", data: { slot: "field-description" }) if hint.present?),
51
+ (tag.p(safe_join(errors, tag.br), id: ("#{id}-error" if id), class: "m-0 text-small text-destructive", role: "alert", data: { slot: "field-error" }) if errors.any?)
52
+ ].compact
53
+ end
54
+
39
55
  def lc_option!(name, value, allowed)
40
56
  normalized = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
41
57
  return normalized if allowed.include?(normalized)
@@ -1,54 +1,16 @@
1
1
  module Lostcoords::Ui
2
2
  module SheetHelper
3
- SHEET_ROW_BASE =
4
- "grid grid-cols-[1.25rem_minmax(0,1fr)] @md:grid-cols-[8rem_minmax(0,1fr)] items-center " \
5
- "gap-x-3 gap-y-2 min-h-14 px-4 py-2.5 bg-paper-2"
6
-
7
- SHEET_ROW_LABEL_BASE =
8
- "flex items-center gap-2 font-body text-sm font-semibold text-ink-3 " \
9
- "[&_svg]:size-5 @md:[&_svg]:size-4.5 [&_svg]:shrink-0 @max-md:[&>span]:sr-only"
10
-
11
- SHEET_CHIP_BASE = "max-w-full min-w-0 h-11 min-h-11 py-0 px-3.5 gap-2 text-sm bg-paper border-0 aria-pressed:font-semibold aria-[current]:not-aria-[current=false]:font-semibold"
12
-
13
- SHEET_CHIP_ACTIVE = "bg-sky-soft font-semibold"
14
-
15
- SHEET_MEMBER_CHIP = "ps-1.5 [&_.avatar]:shrink-0 [--avatar-size:28px]"
16
-
17
- SHEET_DATETIME_INPUT_BASE =
18
- "min-h-9 px-2 rounded-xl border-0 bg-transparent font-body text-base text-ink tabular-nums cursor-pointer " \
19
- "outline-none [field-sizing:content] transition-colors hover:bg-paper focus-visible:bg-paper " \
20
- "[&::-webkit-calendar-picker-indicator]:hidden"
21
-
22
- SHEET_TEXT_INPUT_BASE =
23
- "w-full min-w-0 min-h-9 p-0 border-0 bg-transparent font-body text-base text-ink placeholder:text-ink-3 aria-invalid:placeholder:text-berry/70 outline-none"
24
-
25
- # Makes a <dialog> an iOS sheet on phones (sheet_controller), keeping the dialog's own
3
+ # Makes a <dialog> an iOS sheet on phones (lc-sheet controller), keeping the dialog's own
26
4
  # controllers. detents: "fit", "medium large" or "large".
5
+ SHEET_DETENTS = [ "fit", "medium large", "large" ].freeze
6
+
27
7
  def lc_sheet_data(data = {}, detents: "fit")
8
+ detents = lc_option!(:detents, detents, SHEET_DETENTS.map(&:to_sym)).to_s
9
+ data = data.to_h.symbolize_keys
28
10
  data.merge(
29
- controller: [ data[:controller], "sheet" ].compact.join(" "),
30
- sheet_detents_value: detents
11
+ controller: [ data[:controller], "lc-sheet" ].compact.join(" "),
12
+ lc_sheet_detents_value: detents
31
13
  )
32
14
  end
33
-
34
- def lc_sheet_row_class(extra: nil)
35
- lc_tw(SHEET_ROW_BASE, extra)
36
- end
37
-
38
- def lc_sheet_row_label_class(extra: nil)
39
- lc_tw(SHEET_ROW_LABEL_BASE, extra)
40
- end
41
-
42
- def lc_sheet_chip_class(active: false, member: false, extra: nil)
43
- lc_pill_class(size: :sm, active: active, extra: lc_tw(SHEET_CHIP_BASE, (SHEET_CHIP_ACTIVE if active), (SHEET_MEMBER_CHIP if member), extra))
44
- end
45
-
46
- def lc_sheet_datetime_input_class(extra: nil)
47
- lc_tw(SHEET_DATETIME_INPUT_BASE, extra)
48
- end
49
-
50
- def lc_sheet_text_input_class(extra: nil)
51
- lc_tw(SHEET_TEXT_INPUT_BASE, extra)
52
- end
53
15
  end
54
16
  end
@@ -1,159 +1,60 @@
1
1
  module Lostcoords::Ui
2
2
  module StructuresHelper
3
3
  INPUT_TYPES = %i[text search email password tel url number date time datetime-local month week].freeze
4
- SHEET_DETENTS = [ "fit", "medium large", "large" ].freeze
5
4
 
6
- def lc_input(name: nil, value: nil, type: :text, size: :base, **attributes)
5
+ def lc_input(name: nil, value: nil, type: :text, size: :md, **attributes)
7
6
  type = lc_option!(:type, type, INPUT_TYPES)
8
7
  size = lc_option!(:size, size, TailwindHelper::CONTROL_SIZES.keys)
9
- tag.input(**lc_attributes({ type: type, name: name, value: value, class: lc_input_class(size: size) }, attributes))
8
+ tag.input(**lc_attributes({ type: type, name: name, value: value, class: lc_input_class(size: size), data: { slot: "input" } }, attributes))
10
9
  end
11
10
 
12
11
  def lc_textarea(content = nil, name: nil, **attributes, &block)
13
- tag.textarea(block ? capture(&block) : content, **lc_attributes({ name: name, class: lc_textarea_class }, attributes))
12
+ tag.textarea(block ? capture(&block) : content, **lc_attributes({ name: name, class: lc_textarea_class, data: { slot: "textarea" } }, attributes))
14
13
  end
15
14
 
16
- def lc_select(options = nil, name: nil, selected: nil, size: :base, **attributes, &block)
15
+ def lc_select(options = nil, name: nil, selected: nil, size: :md, **attributes, &block)
17
16
  size = lc_option!(:size, size, TailwindHelper::CONTROL_SIZES.keys)
18
17
  content = block ? capture(&block) : options_for_select(options || [], selected)
19
- tag.select(content, **lc_attributes({ name: name, class: lc_select_class(size: size) }, attributes))
18
+ tag.select(content, **lc_attributes({ name: name, class: lc_select_class(size: size), data: { slot: "select" } }, attributes))
20
19
  end
21
20
 
21
+ # Yields the control's attributes; splat them into the control with `**field`. error: takes a
22
+ # message or a list of messages, such as `record.errors.full_messages_for(:name)`.
22
23
  def lc_field(label:, id:, hint: nil, error: nil, **attributes, &block)
23
24
  lc_label!(label, :label)
24
25
  lc_label!(id, :id)
25
- descriptions = [ ("#{id}-hint" if hint.present?), ("#{id}-error" if error.present?) ].compact
26
- control = { id: id, aria: { describedby: descriptions.presence&.join(" "), invalid: (true if error.present?) } }
27
- tag.div(**lc_attributes({ class: lc_field_class }, attributes)) do
26
+ errors = Array(error).compact_blank
27
+ descriptions = [ ("#{id}-hint" if hint.present?), ("#{id}-error" if errors.any?) ].compact
28
+ control = { id: id, aria: { describedby: descriptions.presence&.join(" "), invalid: (true if errors.any?) } }
29
+ tag.div(**lc_attributes({ class: lc_field_class, data: { slot: "field", invalid: (true if errors.any?) } }, attributes)) do
28
30
  safe_join([
29
- tag.label(label, for: id, class: lc_field_label_class),
30
- (capture(control, &block) if block),
31
- (tag.p(hint, id: "#{id}-hint", class: "text-sm text-ink-3") if hint.present?),
32
- (tag.p(error, id: "#{id}-error", class: "text-sm text-berry", role: "alert") if error.present?)
31
+ tag.label(label, for: id, class: lc_field_label_class, data: { slot: "field-label" }),
32
+ (capture(Parts::Field.new(self, id, control), &block) if block),
33
+ *lc_field_messages(id, hint, errors)
33
34
  ].compact)
34
35
  end
35
36
  end
36
37
 
37
- def lc_form_actions(**attributes, &block)
38
- tag.div(**lc_attributes({ class: lc_form_actions_class }, attributes), &block)
39
- end
40
-
41
- def lc_segmented(label:, name: nil, value: nil, **attributes, &block)
38
+ def lc_toggle_group(label:, name: nil, value: nil, **attributes, &block)
42
39
  lc_label!(label, :label)
43
- defaults = { class: lc_segmented_class, role: "group", aria: { label: label }, data: { controller: "ui-selection", action: "click->ui-selection#select keydown->ui-selection#navigate", ui_selection_value_value: value } }
44
- tag.div(**lc_attributes(lc_attributes(defaults, attributes), { role: "group", aria: { label: label } })) do
45
- safe_join([ (hidden_field_tag(name, value, data: { ui_selection_target: "input" }) if name), (capture(&block) if block) ].compact)
40
+ defaults = { class: lc_toggle_group_class, role: "group", aria: { label: label }, data: { slot: "toggle-group", controller: "lc-selection", action: "click->lc-selection#select keydown->lc-selection#navigate", lc_selection_value_value: value } }
41
+ tag.div(**lc_attributes(defaults, attributes, { role: "group", aria: { label: label } })) do
42
+ safe_join([ (hidden_field_tag(name, value, data: { lc_selection_target: "input" }) if name), (capture(&block) if block) ].compact)
46
43
  end
47
44
  end
48
45
 
49
- def lc_segmented_tab(label = nil, value: nil, selected: false, **attributes, &block)
46
+ def lc_toggle_group_item(label = nil, value: nil, selected: false, **attributes, &block)
50
47
  value ||= label
51
48
  lc_label!(value, :value)
52
- defaults = { class: lc_segmented_tab_class, type: "button", aria: { pressed: selected }, data: { ui_selection_target: "option", value: value } }
53
- required = { type: "button", data: { ui_selection_target: "option", value: value } }
54
- tag.button(block ? capture(&block) : label, **lc_attributes(lc_attributes(defaults, attributes), required))
55
- end
56
-
57
- def lc_pill(label = nil, active: false, ghost: false, size: :base, href: nil, **attributes, &block)
58
- size = lc_option!(:size, size, %i[sm base lg])
59
- content = block ? capture(&block) : label
60
- defaults = { class: lc_pill_class(ghost: ghost, size: size) }
61
- if href
62
- raise ArgumentError, "Links cannot be disabled; use a pill button instead" if attributes[:disabled] || attributes["disabled"]
63
-
64
- tag.a(content, **lc_attributes(defaults.merge(href: href, aria: { current: ("page" if active) }), attributes))
65
- else
66
- defaults.merge!(type: "button", aria: { pressed: active }, data: { controller: "ui-toggle", action: "click->ui-toggle#toggle" })
67
- tag.button(content, **lc_attributes(defaults, attributes).merge(type: "button"))
68
- end
69
- end
70
-
71
- def lc_pill_row(**attributes, &block)
72
- tag.div(**lc_attributes({ class: lc_pill_row_class }, attributes), &block)
73
- end
74
-
75
- def lc_dialog(id:, title:, trigger: "Open dialog", kind: :modal, **attributes, &block)
76
- kind = lc_option!(:kind, kind, DialogHelper::DIALOGS.keys)
77
- lc_render_dialog(id, title, trigger, { class: lc_dialog_class(kind), data: lc_sheet_data }, attributes, &block)
78
- end
79
-
80
- def lc_sheet(id:, title:, trigger: "Open sheet", detents: "fit", **attributes, &block)
81
- detents = detents.to_s
82
- raise ArgumentError, "detents must be one of #{SHEET_DETENTS.join(', ')}" unless SHEET_DETENTS.include?(detents)
83
-
84
- defaults = { class: lc_dialog_class, data: lc_sheet_data({}, detents: detents) }
85
- lc_render_dialog(id, title, trigger, defaults, attributes, &block)
86
- end
87
-
88
- def lc_menu(id:, label: "Actions", align: :end, **attributes, &block)
89
- align = lc_option!(:align, align, MenuHelper::MENU_POPOVER_ALIGN.keys)
90
- lc_render_panel(id, label, nil, "row-menu", "menu", lc_menu_popover_class(align: align), attributes, &block)
91
- end
92
-
93
- def lc_popover(id:, label: "Open popover", title: nil, align: :end, **attributes, &block)
94
- title = title.presence
95
- align = lc_option!(:align, align, MenuHelper::MENU_POPOVER_ALIGN.keys)
96
- classes = lc_popover_class(align: align, extra: "data-open:flex flex-col gap-4 p-5 z-nav-popover min-w-64 max-w-[calc(100vw-2rem)]")
97
- lc_render_panel(id, label, title, "ui-popover", "dialog", classes, attributes, &block)
98
- end
99
-
100
- def lc_menu_option(label = nil, href: nil, tone: :default, **attributes, &block)
101
- tone = lc_option!(:tone, tone, MenuHelper::MENU_OPTION_TONES.keys)
102
- content = tag.span(block ? capture(&block) : label, class: "col-span-3")
103
- defaults = { class: lc_menu_option_class(tone: tone), role: "menuitem", data: { action: "click->row-menu#close" } }
104
- if href
105
- raise ArgumentError, "Links cannot be disabled; use a menu button instead" if attributes[:disabled] || attributes["disabled"]
106
-
107
- tag.a(content, **lc_attributes(defaults.merge(href: href), attributes))
108
- else
109
- tag.button(content, **lc_attributes(defaults.merge(type: "button"), attributes))
110
- end
111
- end
112
-
113
- def lc_toast(message = nil, tone: :notice, timeout: 4000, **attributes, &block)
114
- tone = lc_option!(:tone, tone, FormHelper::FORM_ALERT_ICONS.keys)
115
- raise ArgumentError, "timeout must be a nonnegative integer" unless timeout.is_a?(Integer) && timeout >= 0
116
-
117
- defaults = { class: lc_glass_class(shape: :hud, extra: "flex items-center gap-2 px-4 py-3 text-sm font-semibold transition-opacity data-leaving:opacity-0"), role: tone == :error ? "alert" : "status", data: { controller: "toast", action: "click->toast#dismiss", toast_timeout_value: timeout } }
118
- tag.div(**lc_attributes(defaults, attributes)) do
119
- safe_join([ lc_form_alert_icon(tone: tone), tag.span(block ? capture(&block) : message) ])
120
- end
121
- end
122
-
123
- def lc_disclosure(title:, open: false, **attributes, &block)
124
- lc_label!(title, :title)
125
- defaults = { class: lc_card_class(extra: "disclosure-animated"), open: open, data: { controller: "disclosure", action: "click->disclosure#toggle" } }
126
- render "lostcoords/ui/components/lc_disclosure", title: title, attributes: lc_attributes(defaults, attributes), content: (capture(&block) if block)
49
+ defaults = { class: lc_toggle_group_item_class, type: "button", aria: { pressed: selected }, data: { slot: "toggle-group-item", lc_selection_target: "option", value: value } }
50
+ required = { type: "button", data: { lc_selection_target: "option", value: value } }
51
+ tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes, required))
127
52
  end
128
53
 
129
- def lc_emoji_picker(form:, field:, label:, catalog_url:, value: nil, quick: [], recent: [], recent_scope: nil, auto_submit: false, **attributes)
130
- lc_label!(label, :label)
131
- lc_label!(catalog_url, :catalog_url)
132
- render "lostcoords/ui/components/emoji_picker", form: form, field: field, label: label, catalog_url: catalog_url, value: value, quick: quick, recent: recent, recent_scope: recent_scope, auto_submit: auto_submit, attributes: attributes
133
- end
134
-
135
- private
136
-
137
- def lc_label!(value, name)
138
- raise ArgumentError, "#{name} must not be blank" if value.blank?
139
- end
140
-
141
- def lc_render_dialog(id, title, trigger, defaults, attributes, &block)
142
- lc_label!(id, :id)
143
- lc_label!(title, :title)
144
- lc_label!(trigger, :trigger)
145
- required = { id: id, aria: { labelledby: "#{id}-title" }, data: (defaults[:data] || {}).merge(modal_target: "dialog") }
146
- merged = lc_attributes(lc_attributes(defaults, attributes), required)
147
- render "lostcoords/ui/components/lc_dialog", id: id, title: title, trigger: trigger, attributes: merged, content: (capture(&block) if block)
148
- end
149
-
150
- def lc_render_panel(id, label, title, controller, role, classes, attributes, &block)
151
- lc_label!(id, :id)
152
- lc_label!(label, :label)
153
- required = { id: id, role: role, aria: { labelledby: "#{id}-#{title ? 'title' : 'trigger'}" }, data: { "#{controller}_target" => "popover" } }
154
- required[:tabindex] = -1 if role == "dialog"
155
- merged = lc_attributes(lc_attributes({ class: classes }, attributes), required)
156
- render "lostcoords/ui/components/ui_floating_panel", id: id, label: label, title: title, controller: controller, role: role, attributes: merged, content: (capture(&block) if block)
54
+ def lc_toggle(label = nil, active: false, ghost: false, size: :md, **attributes, &block)
55
+ raise ArgumentError, "Use lc_button_link_to for navigation" if attributes.key?(:href) || attributes.key?("href")
56
+ defaults = { class: lc_toggle_class(ghost: ghost, size: size), type: "button", aria: { pressed: active }, data: { slot: "toggle", controller: "lc-toggle", action: "click->lc-toggle#toggle" } }
57
+ tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes).merge(type: "button"))
157
58
  end
158
59
  end
159
60
  end
@@ -0,0 +1,11 @@
1
+ module Lostcoords::Ui
2
+ module TableHelper
3
+ def lc_table_class(extra: nil)
4
+ lc_tw("lc-table w-full text-start text-sm", extra)
5
+ end
6
+
7
+ def lc_table(**attributes, &block)
8
+ tag.table(**lc_attributes({ class: lc_table_class }, attributes), &block)
9
+ end
10
+ end
11
+ end
@@ -1,13 +1,22 @@
1
1
  module Lostcoords::Ui
2
2
  module TailwindHelper
3
+ # Buttons, inputs, selects, toggles and icon buttons share these heights.
3
4
  CONTROL_SIZES = {
4
- sm: "min-h-11 py-2",
5
- base: "min-h-12 py-2.5",
6
- lg: "min-h-14 py-3.5"
5
+ sm: "h-control-sm",
6
+ md: "h-control-md",
7
+ lg: "h-control-lg"
8
+ }.freeze
9
+
10
+ # The theme's own scales, so class merging knows text-label is a size and gap-related a gap.
11
+ MERGE_THEME = {
12
+ "text" => %w[display title heading subheading body small label caption],
13
+ "spacing" => %w[tight related heading content inset section gutter control-sm control-md control-lg],
14
+ "container" => %w[page reading form],
15
+ "radius" => %w[pill]
7
16
  }.freeze
8
17
 
9
18
  def lc_tw(*classes)
10
- @tailwind_merger ||= TailwindMerge::Merger.new
19
+ @tailwind_merger ||= TailwindMerge::Merger.new(config: { theme: MERGE_THEME })
11
20
  @tailwind_merger.merge(classes.flatten.compact.join(" "))
12
21
  end
13
22
  end
@@ -0,0 +1,22 @@
1
+ module Lostcoords::Ui
2
+ module ToastHelper
3
+ # Render toasts inside lc_toast_region, the live region that announces them. A destructive toast
4
+ # is also an alert, so it interrupts.
5
+ def lc_toast(message = nil, tone: :success, timeout: 4000, **attributes, &block)
6
+ tone = lc_option!(:tone, tone, FeedbackHelper::ALERT_ICONS.keys)
7
+ raise ArgumentError, "timeout must be a nonnegative integer" unless timeout.is_a?(Integer) && timeout >= 0
8
+
9
+ defaults = { class: lc_glass_class(shape: :hud, extra: "flex items-center gap-related px-content py-heading text-label font-semibold transition-opacity data-leaving:opacity-0"), role: ("alert" if tone == :destructive), data: { slot: "toast", tone: tone, controller: "lc-toast", action: "click->lc-toast#dismiss", lc_toast_timeout_value: timeout } }
10
+ tag.div(**lc_attributes(defaults, attributes)) do
11
+ safe_join([ lc_alert_icon(tone: tone), tag.span(block ? capture(&block) : message) ])
12
+ end
13
+ end
14
+
15
+ # Fixed corner that holds toasts. Render it once in the layout and append toasts to it, from flash
16
+ # or a Turbo Stream; it shows in the top layer, above dialogs and sticky chrome.
17
+ def lc_toast_region(**attributes, &block)
18
+ defaults = { class: "lc-toast-region", role: "status", aria: { live: "polite" }, data: { slot: "toast-region", controller: "lc-toast-region" } }
19
+ tag.div(**lc_attributes(defaults, attributes)) { capture(&block) if block }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ module Lostcoords::Ui
2
+ module TooltipHelper
3
+ TOOLTIP_SIDES = %i[top bottom].freeze
4
+
5
+ # Yields attributes for the trigger; splat them with `**tip` so the trigger is described by the tooltip.
6
+ # The tooltip shows on hover and keyboard focus, and Escape hides it. It is a hint: keep essential information visible.
7
+ def lc_tooltip(text, id:, side: :top, **attributes, &block)
8
+ lc_label!(text, :text)
9
+ lc_label!(id, :id)
10
+ side = lc_option!(:side, side, TOOLTIP_SIDES)
11
+ trigger = capture({ aria: { describedby: id } }, &block) if block
12
+ tag.span(**lc_attributes({ class: "lc-tooltip relative inline-flex", data: { slot: "tooltip", side: side, controller: "lc-tooltip", action: "mouseenter->lc-tooltip#show focusin->lc-tooltip#show mouseleave->lc-tooltip#hide focusout->lc-tooltip#hide" } }, attributes)) do
13
+ safe_join([ trigger, tag.span(text, id: id, role: "tooltip", class: "lc-tooltip__content", data: { slot: "tooltip-content", lc_tooltip_target: "content" }) ].compact)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,28 +1,58 @@
1
1
  module Lostcoords::Ui
2
2
  module TypographyHelper
3
- HEADING_SIZES = { page: "ui-heading-page", section: "ui-heading-section", card: "ui-heading-card" }.freeze
4
- TEXT_SIZES = { body: "ui-text-body", small: "ui-text-small", caption: "ui-text-caption" }.freeze
5
- TEXT_TONES = { default: "text-ink", muted: "text-ink-2", subtle: "text-ink-3" }.freeze
3
+ HEADING_SIZES = { display: "text-display", title: "text-title", heading: "text-heading", subheading: "text-subheading" }.freeze
4
+ TEXT_SIZES = { body: "text-body", small: "text-small", label: "text-label", caption: "text-caption" }.freeze
5
+ TEXT_TONES = { default: "text-foreground", muted: "text-muted-foreground" }.freeze
6
+ KBD_BASE = "lc-kbd inline-flex items-center rounded-md bg-tile px-1.5 font-sans text-caption leading-5 whitespace-nowrap text-muted-foreground"
7
+ KBD_NAMES = { "mod" => "Ctrl", "ctrl" => "Ctrl", "alt" => "Alt", "shift" => "Shift", "meta" => "Meta", "escape" => "Esc", "enter" => "↵",
8
+ "arrowup" => "↑", "arrowdown" => "↓", "arrowleft" => "←", "arrowright" => "→" }.freeze
9
+ KBD_MODIFIERS = %w[mod ctrl alt shift meta].freeze
6
10
 
7
- def lc_heading_class(size: :section, extra: nil)
11
+ def lc_heading_class(size: :heading, trim: false, extra: nil)
8
12
  size = lc_option!(:size, size, HEADING_SIZES.keys)
9
- lc_tw("ui-heading m-0 text-ink", HEADING_SIZES.fetch(size), extra)
13
+ lc_tw("lc-heading m-0 text-foreground", HEADING_SIZES.fetch(size), ("lc-trim" if trim), extra)
10
14
  end
11
15
 
12
- def lc_text_class(size: :body, tone: :default, extra: nil)
16
+ def lc_text_class(size: :body, tone: :default, trim: false, extra: nil)
13
17
  size = lc_option!(:size, size, TEXT_SIZES.keys)
14
18
  tone = lc_option!(:tone, tone, TEXT_TONES.keys)
15
- lc_tw("ui-text m-0", TEXT_SIZES.fetch(size), TEXT_TONES.fetch(tone), extra)
19
+ lc_tw("lc-text m-0", TEXT_SIZES.fetch(size), TEXT_TONES.fetch(tone), ("lc-trim" if trim), extra)
16
20
  end
17
21
 
18
- def lc_heading(content = nil, level: 2, size: :section, **attributes, &block)
22
+ def lc_kbd_class(extra: nil)
23
+ lc_tw(KBD_BASE, extra)
24
+ end
25
+
26
+ def lc_heading(content = nil, level: 2, size: :heading, trim: false, **attributes, &block)
19
27
  raise ArgumentError, "Invalid level: #{level.inspect}. Expected an integer from 1 to 6" unless (1..6).include?(level) && level.is_a?(Integer)
20
28
 
21
- tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size) }, attributes), &block)
29
+ tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size, trim: trim) }, attributes), &block)
30
+ end
31
+
32
+ def lc_text(content = nil, size: :body, tone: :default, trim: false, **attributes, &block)
33
+ tag.p(content, **lc_attributes({ class: lc_text_class(size: size, tone: tone, trim: trim) }, attributes), &block)
34
+ end
35
+
36
+ # A key hint, such as lc_kbd("mod+k"). "mod" renders Ctrl and reads ⌘ on Apple platforms once JS runs.
37
+ def lc_kbd(keys, **attributes)
38
+ lc_label!(keys, :keys)
39
+ text = keys.split.map do |combo|
40
+ *modifiers, key = combo.downcase.split(/\+(?!\z)/)
41
+ names = modifiers.map { |modifier| KBD_MODIFIERS.include?(modifier) ? KBD_NAMES.fetch(modifier) : raise(ArgumentError, "Unknown modifier #{modifier.inspect} in #{keys.inspect}. Expected one of: #{KBD_MODIFIERS.join(', ')}") }
42
+ # A symbol such as ? already implies Shift; a letter or digit keeps it.
43
+ names.delete("Shift") if key.match?(/\A[^[:alnum:]]\z/)
44
+ [ *names, KBD_NAMES.fetch(key) { key.length == 1 ? key.upcase : key.capitalize } ].join(" ")
45
+ end.join(" / ")
46
+ tag.kbd(text, **lc_attributes({ class: lc_kbd_class, data: { controller: "lc-kbd", lc_kbd_keys_value: keys } }, attributes))
22
47
  end
23
48
 
24
- def lc_text(content = nil, size: :body, tone: :default, **attributes, &block)
25
- tag.p(content, **lc_attributes({ class: lc_text_class(size: size, tone: tone) }, attributes), &block)
49
+ # Data for the element a page-wide shortcut focuses (fields) or clicks (anything else):
50
+ # lc_button "Search", data: lc_shortcut_data("mod+k"). Shortcuts without mod, ctrl or alt
51
+ # don't fire while typing in a field; inside an open modal, only its own shortcuts do.
52
+ def lc_shortcut_data(keys, data = {})
53
+ lc_label!(keys, :keys)
54
+ data = data.to_h.symbolize_keys
55
+ data.merge(controller: [ data[:controller], "lc-shortcut" ].compact.join(" "), lc_shortcut_keys_value: keys)
26
56
  end
27
57
  end
28
58
  end