lostcoords-ui 0.4.1 → 0.5.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -89
  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 +20 -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/components.css +25 -0
  9. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  10. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  11. data/app/assets/tailwind/lostcoords/ui/disclosure.css +5 -5
  12. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +43 -43
  13. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  14. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  15. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  16. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  17. data/app/assets/tailwind/lostcoords/ui/reading.css +24 -24
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +60 -60
  19. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  20. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  21. data/app/assets/tailwind/lostcoords/ui/theme.css +194 -225
  22. data/app/assets/tailwind/lostcoords/ui/tooltip.css +50 -0
  23. data/app/assets/tailwind/lostcoords/ui/typography.css +37 -61
  24. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  25. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  26. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  27. data/app/helpers/lostcoords/ui/button_helper.rb +36 -33
  28. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  29. data/app/helpers/lostcoords/ui/choice_helper.rb +83 -0
  30. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  31. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  32. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  33. data/app/helpers/lostcoords/ui/dialog_helper.rb +6 -13
  34. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  35. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  36. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  37. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  38. data/app/helpers/lostcoords/ui/menu_helper.rb +8 -8
  39. data/app/helpers/lostcoords/ui/navigation_helper.rb +131 -0
  40. data/app/helpers/lostcoords/ui/overlay_helper.rb +17 -0
  41. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  42. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  43. data/app/helpers/lostcoords/ui/sheet_helper.rb +3 -45
  44. data/app/helpers/lostcoords/ui/structures_helper.rb +55 -64
  45. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  46. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  47. data/app/helpers/lostcoords/ui/typography_helper.rb +11 -11
  48. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  49. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +3 -3
  50. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  51. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  52. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +4 -4
  53. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +3 -3
  54. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  55. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +1 -1
  56. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +15 -15
  57. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +39 -0
  58. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  59. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +14 -0
  60. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +5 -5
  61. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  62. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  63. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +7 -7
  64. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +2 -2
  65. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  66. data/app/javascript/lostcoords/ui/index.js +51 -0
  67. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +30 -30
  68. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  69. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  70. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  71. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  72. data/config/helper_renames.json +23 -25
  73. data/config/importmap.rb +10 -2
  74. data/config/locales/en.yml +23 -0
  75. data/config/locales/es.yml +23 -0
  76. data/config/locales/ja.yml +23 -0
  77. data/lib/lostcoords/ui/action_view.rb +5 -3
  78. data/lib/lostcoords/ui/builders.rb +1 -1
  79. data/lib/lostcoords/ui/form_builder.rb +113 -0
  80. data/lib/lostcoords/ui/parts.rb +154 -0
  81. data/lib/lostcoords/ui/tokens.rb +24 -1
  82. data/lib/lostcoords/ui/version.rb +1 -1
  83. metadata +33 -17
  84. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  85. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  86. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  87. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  88. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  89. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  90. /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,54 +1,12 @@
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".
27
5
  def lc_sheet_data(data = {}, detents: "fit")
28
6
  data.merge(
29
- controller: [ data[:controller], "sheet" ].compact.join(" "),
30
- sheet_detents_value: detents
7
+ controller: [ data[:controller], "lc-sheet" ].compact.join(" "),
8
+ lc_sheet_detents_value: detents
31
9
  )
32
10
  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
11
  end
54
12
  end
@@ -3,104 +3,86 @@ module Lostcoords::Ui
3
3
  INPUT_TYPES = %i[text search email password tel url number date time datetime-local month week].freeze
4
4
  SHEET_DETENTS = [ "fit", "medium large", "large" ].freeze
5
5
 
6
- def lc_input(name: nil, value: nil, type: :text, size: :base, **attributes)
6
+ def lc_input(name: nil, value: nil, type: :text, size: :md, **attributes)
7
7
  type = lc_option!(:type, type, INPUT_TYPES)
8
8
  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))
9
+ tag.input(**lc_attributes({ type: type, name: name, value: value, class: lc_input_class(size: size), data: { slot: "input" } }, attributes))
10
10
  end
11
11
 
12
12
  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))
13
+ tag.textarea(block ? capture(&block) : content, **lc_attributes({ name: name, class: lc_textarea_class, data: { slot: "textarea" } }, attributes))
14
14
  end
15
15
 
16
- def lc_select(options = nil, name: nil, selected: nil, size: :base, **attributes, &block)
16
+ def lc_select(options = nil, name: nil, selected: nil, size: :md, **attributes, &block)
17
17
  size = lc_option!(:size, size, TailwindHelper::CONTROL_SIZES.keys)
18
18
  content = block ? capture(&block) : options_for_select(options || [], selected)
19
- tag.select(content, **lc_attributes({ name: name, class: lc_select_class(size: size) }, attributes))
19
+ tag.select(content, **lc_attributes({ name: name, class: lc_select_class(size: size), data: { slot: "select" } }, attributes))
20
20
  end
21
21
 
22
+ # Yields the control's attributes; splat them into the control with `**field`. error: takes a
23
+ # message or a list of messages, such as `record.errors.full_messages_for(:name)`.
22
24
  def lc_field(label:, id:, hint: nil, error: nil, **attributes, &block)
23
25
  lc_label!(label, :label)
24
26
  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
27
+ errors = Array(error).compact_blank
28
+ descriptions = [ ("#{id}-hint" if hint.present?), ("#{id}-error" if errors.any?) ].compact
29
+ control = { id: id, aria: { describedby: descriptions.presence&.join(" "), invalid: (true if errors.any?) } }
30
+ tag.div(**lc_attributes({ class: lc_field_class, data: { slot: "field", invalid: (true if errors.any?) } }, attributes)) do
28
31
  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?)
32
+ tag.label(label, for: id, class: lc_field_label_class, data: { slot: "field-label" }),
33
+ (capture(Parts::Field.new(self, id, control), &block) if block),
34
+ *lc_field_messages(id, hint, errors)
33
35
  ].compact)
34
36
  end
35
37
  end
36
38
 
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)
39
+ def lc_toggle_group(label:, name: nil, value: nil, **attributes, &block)
42
40
  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 } }
41
+ 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 } }
44
42
  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)
43
+ safe_join([ (hidden_field_tag(name, value, data: { lc_selection_target: "input" }) if name), (capture(&block) if block) ].compact)
46
44
  end
47
45
  end
48
46
 
49
- def lc_segmented_tab(label = nil, value: nil, selected: false, **attributes, &block)
47
+ def lc_toggle_group_item(label = nil, value: nil, selected: false, **attributes, &block)
50
48
  value ||= label
51
49
  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 } }
50
+ defaults = { class: lc_toggle_group_item_class, type: "button", aria: { pressed: selected }, data: { slot: "toggle-group-item", lc_selection_target: "option", value: value } }
51
+ required = { type: "button", data: { lc_selection_target: "option", value: value } }
54
52
  tag.button(block ? capture(&block) : label, **lc_attributes(lc_attributes(defaults, attributes), required))
55
53
  end
56
54
 
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
55
+ def lc_toggle(label = nil, active: false, ghost: false, size: :md, **attributes, &block)
56
+ raise ArgumentError, "Use lc_button_link_to for navigation" if attributes.key?(:href) || attributes.key?("href")
57
+ 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" } }
58
+ tag.button(block ? capture(&block) : label, **lc_attributes(defaults, attributes).merge(type: "button"))
69
59
  end
70
60
 
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)
61
+ # Yields a builder with footer and close parts.
62
+ def lc_dialog(id:, title:, description: nil, trigger: lc_t("lostcoords.ui.dialog.trigger"), kind: :modal, detents: "fit", **attributes, &block)
76
63
  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
64
  detents = detents.to_s
82
65
  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)
66
+ defaults = { class: lc_dialog_class(kind), aria: { describedby: ("#{id}-description" if description.present?) }, data: lc_sheet_data({ slot: "dialog" }, detents: detents) }
67
+ lc_render_dialog(id, title, description, trigger, defaults, attributes, &block)
86
68
  end
87
69
 
88
- def lc_menu(id:, label: "Actions", align: :end, **attributes, &block)
70
+ def lc_menu(id:, label: lc_t("lostcoords.ui.menu.label"), align: :end, **attributes, &block)
89
71
  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)
72
+ lc_render_panel(id, label, nil, "lc-menu", "menu", lc_menu_popover_class(align: align), lc_attributes({ data: { slot: "menu" } }, attributes), &block)
91
73
  end
92
74
 
93
- def lc_popover(id:, label: "Open popover", title: nil, align: :end, **attributes, &block)
75
+ def lc_popover(id:, label: lc_t("lostcoords.ui.popover.trigger"), title: nil, align: :end, trigger_variant: :secondary, **attributes, &block)
94
76
  title = title.presence
95
77
  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)
78
+ classes = lc_popover_class(align: align, extra: "data-open:flex flex-col gap-content p-inset z-(--lc-z-popover) min-w-64 max-w-[calc(100vw-2rem)]")
79
+ lc_render_panel(id, label, title, "lc-popover", "dialog", classes, lc_attributes({ data: { slot: "popover" } }, attributes), trigger_variant: trigger_variant, &block)
98
80
  end
99
81
 
100
82
  def lc_menu_option(label = nil, href: nil, tone: :default, **attributes, &block)
101
83
  tone = lc_option!(:tone, tone, MenuHelper::MENU_OPTION_TONES.keys)
102
84
  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" } }
85
+ defaults = { class: lc_menu_option_class(tone: tone), role: "menuitem", data: { slot: "menu-item", action: "click->lc-menu#close" } }
104
86
  if href
105
87
  raise ArgumentError, "Links cannot be disabled; use a menu button instead" if attributes[:disabled] || attributes["disabled"]
106
88
 
@@ -110,20 +92,20 @@ module Lostcoords::Ui
110
92
  end
111
93
  end
112
94
 
113
- def lc_toast(message = nil, tone: :notice, timeout: 4000, **attributes, &block)
114
- tone = lc_option!(:tone, tone, FormHelper::FORM_ALERT_ICONS.keys)
95
+ def lc_toast(message = nil, tone: :success, timeout: 4000, **attributes, &block)
96
+ tone = lc_option!(:tone, tone, FeedbackHelper::ALERT_ICONS.keys)
115
97
  raise ArgumentError, "timeout must be a nonnegative integer" unless timeout.is_a?(Integer) && timeout >= 0
116
98
 
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 } }
99
+ 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: tone == :destructive ? "alert" : "status", data: { slot: "toast", tone: tone, controller: "lc-toast", action: "click->lc-toast#dismiss", lc_toast_timeout_value: timeout } }
118
100
  tag.div(**lc_attributes(defaults, attributes)) do
119
- safe_join([ lc_form_alert_icon(tone: tone), tag.span(block ? capture(&block) : message) ])
101
+ safe_join([ lc_alert_icon(tone: tone), tag.span(block ? capture(&block) : message) ])
120
102
  end
121
103
  end
122
104
 
123
- def lc_disclosure(title:, open: false, **attributes, &block)
105
+ def lc_collapsible(title:, open: false, **attributes, &block)
124
106
  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)
107
+ defaults = { class: lc_card_class(extra: "lc-collapsible"), open: open, data: { slot: "collapsible", controller: "lc-collapsible", action: "click->lc-collapsible#toggle" } }
108
+ render "lostcoords/ui/components/lc_collapsible", title: title, attributes: lc_attributes(defaults, attributes), content: (capture(&block) if block)
127
109
  end
128
110
 
129
111
  def lc_emoji_picker(form:, field:, label:, catalog_url:, value: nil, quick: [], recent: [], recent_scope: nil, auto_submit: false, **attributes)
@@ -133,27 +115,36 @@ module Lostcoords::Ui
133
115
  end
134
116
 
135
117
  private
118
+ # The hint and error paragraphs under a field or choice group. With an id, controls can point
119
+ # aria-describedby at them.
120
+ def lc_field_messages(id, hint, errors)
121
+ [
122
+ (tag.p(hint, id: ("#{id}-hint" if id), class: "m-0 text-small text-muted-foreground", data: { slot: "field-description" }) if hint.present?),
123
+ (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?)
124
+ ].compact
125
+ end
126
+
136
127
 
137
128
  def lc_label!(value, name)
138
129
  raise ArgumentError, "#{name} must not be blank" if value.blank?
139
130
  end
140
131
 
141
- def lc_render_dialog(id, title, trigger, defaults, attributes, &block)
132
+ def lc_render_dialog(id, title, description, trigger, defaults, attributes, &block)
142
133
  lc_label!(id, :id)
143
134
  lc_label!(title, :title)
144
135
  lc_label!(trigger, :trigger)
145
- required = { id: id, aria: { labelledby: "#{id}-title" }, data: (defaults[:data] || {}).merge(modal_target: "dialog") }
136
+ required = { id: id, aria: { labelledby: "#{id}-title" }, data: (defaults[:data] || {}).merge(lc_dialog_target: "dialog") }
146
137
  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)
138
+ render "lostcoords/ui/components/lc_dialog", id: id, title: title, description: description, trigger: trigger, attributes: merged, content: (capture(Parts::Dialog.new(self, id), &block) if block)
148
139
  end
149
140
 
150
- def lc_render_panel(id, label, title, controller, role, classes, attributes, &block)
141
+ def lc_render_panel(id, label, title, controller, role, classes, attributes, trigger_variant: :secondary, &block)
151
142
  lc_label!(id, :id)
152
143
  lc_label!(label, :label)
153
144
  required = { id: id, role: role, aria: { labelledby: "#{id}-#{title ? 'title' : 'trigger'}" }, data: { "#{controller}_target" => "popover" } }
154
145
  required[:tabindex] = -1 if role == "dialog"
155
146
  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)
147
+ render "lostcoords/ui/components/lc_floating_panel", id: id, label: label, title: title, controller: controller, role: role, trigger_variant: trigger_variant, attributes: merged, content: (capture(&block) if block)
157
148
  end
158
149
  end
159
150
  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
@@ -1,28 +1,28 @@
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
6
 
7
- def lc_heading_class(size: :section, extra: nil)
7
+ def lc_heading_class(size: :heading, trim: false, extra: nil)
8
8
  size = lc_option!(:size, size, HEADING_SIZES.keys)
9
- lc_tw("ui-heading m-0 text-ink", HEADING_SIZES.fetch(size), extra)
9
+ lc_tw("lc-heading m-0 text-foreground", HEADING_SIZES.fetch(size), ("lc-trim" if trim), extra)
10
10
  end
11
11
 
12
- def lc_text_class(size: :body, tone: :default, extra: nil)
12
+ def lc_text_class(size: :body, tone: :default, trim: false, extra: nil)
13
13
  size = lc_option!(:size, size, TEXT_SIZES.keys)
14
14
  tone = lc_option!(:tone, tone, TEXT_TONES.keys)
15
- lc_tw("ui-text m-0", TEXT_SIZES.fetch(size), TEXT_TONES.fetch(tone), extra)
15
+ lc_tw("lc-text m-0", TEXT_SIZES.fetch(size), TEXT_TONES.fetch(tone), ("lc-trim" if trim), extra)
16
16
  end
17
17
 
18
- def lc_heading(content = nil, level: 2, size: :section, **attributes, &block)
18
+ def lc_heading(content = nil, level: 2, size: :heading, trim: false, **attributes, &block)
19
19
  raise ArgumentError, "Invalid level: #{level.inspect}. Expected an integer from 1 to 6" unless (1..6).include?(level) && level.is_a?(Integer)
20
20
 
21
- tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size) }, attributes), &block)
21
+ tag.public_send("h#{level}", content, **lc_attributes({ class: lc_heading_class(size: size, trim: trim) }, attributes), &block)
22
22
  end
23
23
 
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)
24
+ def lc_text(content = nil, size: :body, tone: :default, trim: false, **attributes, &block)
25
+ tag.p(content, **lc_attributes({ class: lc_text_class(size: size, tone: tone, trim: trim) }, attributes), &block)
26
26
  end
27
27
  end
28
28
  end
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toggle"]
5
+
6
+ toggle() {
7
+ this.setExpanded(!this.element.hasAttribute("data-expanded"))
8
+ }
9
+
10
+ copyFailed() {
11
+ this.setExpanded(true)
12
+ }
13
+
14
+ setExpanded(expanded) {
15
+ this.element.toggleAttribute("data-expanded", expanded)
16
+ if (!this.hasToggleTarget) return
17
+
18
+ this.toggleTarget.setAttribute("aria-expanded", String(expanded))
19
+ this.toggleTarget.textContent = expanded
20
+ ? this.toggleTarget.dataset.collapseLabel
21
+ : this.toggleTarget.dataset.expandLabel
22
+ }
23
+ }
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { prefersReducedMotion } from "helpers/viewport"
2
+ import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
3
3
 
4
4
  // Animates a <details> both ways and keeps what it reveals on screen. Only
5
5
  // user toggles go through here: programmatic opens (validation) already scroll
@@ -40,7 +40,7 @@ export default class extends Controller {
40
40
  from,
41
41
  this.element.offsetHeight,
42
42
  duration,
43
- "--ease-out-expo",
43
+ "--lc-ease-out-expo",
44
44
  )
45
45
  animation.finished.then(
46
46
  () => animation.cancel(),
@@ -55,7 +55,7 @@ export default class extends Controller {
55
55
  this.element.toggleAttribute("data-closing", true)
56
56
  const duration = Math.max(...this.transitionDurations())
57
57
  if (!this.constructor.easesHeightInCss) {
58
- this.animateHeight(this.element.offsetHeight, this.closedHeight(), duration, "--ease-out")
58
+ this.animateHeight(this.element.offsetHeight, this.closedHeight(), duration, "--lc-ease-out")
59
59
  }
60
60
  this.closeTimeout = setTimeout(() => {
61
61
  this.element.open = false
@@ -1,6 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { showContextDialog } from "helpers/context_dialog"
3
- import { registerDialog } from "helpers/dialog"
2
+ import { showContextDialog } from "lostcoords/ui/helpers/context_dialog"
3
+ import { registerDialog } from "lostcoords/ui/helpers/dialog"
4
4
 
5
5
  export default class extends Controller {
6
6
  static targets = ["dialog"]
@@ -16,8 +16,8 @@ export default class extends Controller {
16
16
  if (!dialog || dialog.open) return
17
17
 
18
18
  this.activeDialog = dialog
19
- if (dialog.matches(".context-dialog")) {
20
- showContextDialog(dialog, this.element.querySelector("[data-row-menu-target='popover']"))
19
+ if (dialog.matches(".lc-dialog-context")) {
20
+ showContextDialog(dialog, this.element.querySelector("[data-lc-menu-target='popover']"))
21
21
  } else {
22
22
  dialog.showModal()
23
23
  }
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { registerDialog } from "helpers/dialog"
2
+ import { registerDialog } from "lostcoords/ui/helpers/dialog"
3
3
 
4
4
  // Opens a dialog whose body is a Turbo frame fetched on open. It reloads on every
5
5
  // open, so what it shows is never staler than the page that opened it.