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.
- checksums.yaml +4 -4
- data/README.md +364 -96
- data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
- data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
- data/app/assets/tailwind/lostcoords/ui/card.css +23 -22
- data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
- data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
- data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +6 -6
- data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
- data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
- data/app/assets/tailwind/lostcoords/ui/components.css +12 -0
- data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
- data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +86 -88
- data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
- data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
- data/app/assets/tailwind/lostcoords/ui/prose.css +66 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +62 -63
- data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
- data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/theme.css +209 -225
- data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
- data/app/assets/tailwind/lostcoords/ui/tooltip.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/typography.css +31 -61
- data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
- data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
- data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
- data/app/helpers/lostcoords/ui/button_helper.rb +44 -35
- data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
- data/app/helpers/lostcoords/ui/choice_helper.rb +87 -0
- data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
- data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +15 -17
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
- data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
- data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
- data/app/helpers/lostcoords/ui/menu_helper.rb +45 -10
- data/app/helpers/lostcoords/ui/navigation_helper.rb +144 -0
- data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
- data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
- data/app/helpers/lostcoords/ui/sheet_helper.rb +7 -45
- data/app/helpers/lostcoords/ui/structures_helper.rb +26 -125
- data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
- data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
- data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/tooltip_helper.rb +17 -0
- data/app/helpers/lostcoords/ui/typography_helper.rb +41 -11
- data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
- data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +38 -10
- data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
- data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +25 -3
- data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +7 -15
- data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +17 -17
- data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +42 -0
- data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
- data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +54 -0
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +77 -25
- data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
- data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +30 -7
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +7 -8
- data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
- data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
- data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
- data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
- data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
- data/app/javascript/lostcoords/ui/index.js +57 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +46 -46
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
- data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
- data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
- data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
- data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
- data/config/importmap.rb +10 -2
- data/config/locales/en.yml +23 -0
- data/config/locales/es.yml +23 -0
- data/config/locales/ja.yml +23 -0
- data/lib/lostcoords/ui/action_view.rb +6 -3
- data/lib/lostcoords/ui/builders.rb +2 -2
- data/lib/lostcoords/ui/form_builder.rb +126 -0
- data/lib/lostcoords/ui/parts.rb +154 -0
- data/lib/lostcoords/ui/stylesheets.rb +25 -0
- data/lib/lostcoords/ui/tokens.rb +24 -1
- data/lib/lostcoords/ui/version.rb +1 -1
- data/lib/tasks/lostcoords_ui.rake +11 -0
- metadata +54 -21
- data/app/assets/tailwind/lostcoords/ui/reading.css +0 -60
- data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
- data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
- data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
- data/config/helper_renames.json +0 -113
- data/script/migrate_helpers.rb +0 -136
- /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
- /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
|
-
|
|
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: :
|
|
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: :
|
|
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: :
|
|
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
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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)
|
|
74
|
+
tag.div(content, **lc_attributes(defaults, attributes))
|
|
97
75
|
else
|
|
98
|
-
defaults.merge!(href: href, class: lc_tw(defaults[:class], "
|
|
99
|
-
tag.a(**lc_attributes(defaults, attributes)
|
|
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: :
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
5
|
-
"text-(length:--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: :
|
|
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: :
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
(
|
|
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
|
|
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:
|
|
44
|
-
tag.div(**lc_attributes(
|
|
45
|
-
safe_join([ (hidden_field_tag(name, value, data: {
|
|
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
|
|
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:
|
|
53
|
-
required = { type: "button", data: {
|
|
54
|
-
tag.button(block ? capture(&block) : label, **lc_attributes(
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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: "
|
|
5
|
-
|
|
6
|
-
lg: "
|
|
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 = {
|
|
4
|
-
TEXT_SIZES = { body: "
|
|
5
|
-
TEXT_TONES = { default: "text-
|
|
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: :
|
|
11
|
+
def lc_heading_class(size: :heading, trim: false, extra: nil)
|
|
8
12
|
size = lc_option!(:size, size, HEADING_SIZES.keys)
|
|
9
|
-
lc_tw("
|
|
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("
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
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
|