lostcoords-ui 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +352 -0
- data/app/assets/tailwind/lostcoords/ui/avatar.css +11 -0
- data/app/assets/tailwind/lostcoords/ui/card.css +26 -0
- data/app/assets/tailwind/lostcoords/ui/copy-button.css +33 -0
- data/app/assets/tailwind/lostcoords/ui/dialog.css +60 -0
- data/app/assets/tailwind/lostcoords/ui/disclosure.css +43 -0
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +462 -0
- data/app/assets/tailwind/lostcoords/ui/field.css +6 -0
- data/app/assets/tailwind/lostcoords/ui/glass.css +37 -0
- data/app/assets/tailwind/lostcoords/ui/layout.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/popover.css +75 -0
- data/app/assets/tailwind/lostcoords/ui/reading.css +60 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +170 -0
- data/app/assets/tailwind/lostcoords/ui/squircle.css +32 -0
- data/app/assets/tailwind/lostcoords/ui/theme.css +255 -0
- data/app/assets/tailwind/lostcoords/ui/typography.css +81 -0
- data/app/assets/tailwind/lostcoords/ui/ui.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/utilities.css +45 -0
- data/app/assets/tailwind/lostcoords_ui/engine.css +1 -0
- data/app/helpers/lostcoords/ui/badge_helper.rb +162 -0
- data/app/helpers/lostcoords/ui/button_helper.rb +70 -0
- data/app/helpers/lostcoords/ui/card_helper.rb +60 -0
- data/app/helpers/lostcoords/ui/copy_button_helper.rb +40 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +33 -0
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +245 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +30 -0
- data/app/helpers/lostcoords/ui/form_helper.rb +93 -0
- data/app/helpers/lostcoords/ui/glass_helper.rb +25 -0
- data/app/helpers/lostcoords/ui/icon_helper.rb +15 -0
- data/app/helpers/lostcoords/ui/layout_helper.rb +60 -0
- data/app/helpers/lostcoords/ui/menu_helper.rb +41 -0
- data/app/helpers/lostcoords/ui/primitives_helper.rb +152 -0
- data/app/helpers/lostcoords/ui/reading_helper.rb +15 -0
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +46 -0
- data/app/helpers/lostcoords/ui/sheet_helper.rb +54 -0
- data/app/helpers/lostcoords/ui/structures_helper.rb +159 -0
- data/app/helpers/lostcoords/ui/tailwind_helper.rb +14 -0
- data/app/helpers/lostcoords/ui/translation_helper.rb +13 -0
- data/app/helpers/lostcoords/ui/typography_helper.rb +28 -0
- data/app/javascript/lostcoords/ui/controllers/confirm_dialog_controller.js +39 -0
- data/app/javascript/lostcoords/ui/controllers/disclosure_controller.js +163 -0
- data/app/javascript/lostcoords/ui/controllers/emoji_picker_controller.js +366 -0
- data/app/javascript/lostcoords/ui/controllers/modal_controller.js +27 -0
- data/app/javascript/lostcoords/ui/controllers/row_menu_controller.js +83 -0
- data/app/javascript/lostcoords/ui/controllers/sheet_controller.js +422 -0
- data/app/javascript/lostcoords/ui/controllers/toast_controller.js +69 -0
- data/app/javascript/lostcoords/ui/controllers/ui_copy_controller.js +56 -0
- data/app/javascript/lostcoords/ui/controllers/ui_popover_controller.js +17 -0
- data/app/javascript/lostcoords/ui/controllers/ui_selection_controller.js +69 -0
- data/app/javascript/lostcoords/ui/controllers/ui_toggle_controller.js +13 -0
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +455 -0
- data/app/javascript/lostcoords/ui/helpers/context_dialog.js +29 -0
- data/app/javascript/lostcoords/ui/helpers/dialog.js +31 -0
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +155 -0
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +108 -0
- data/app/javascript/lostcoords/ui/helpers/menu_keys.js +27 -0
- data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +13 -0
- data/app/javascript/lostcoords/ui/helpers/top_layer.js +102 -0
- data/app/javascript/lostcoords/ui/helpers/viewport.js +13 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +176 -0
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +29 -0
- data/app/views/lostcoords/ui/components/_form_alert.html.erb +4 -0
- data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +13 -0
- data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +5 -0
- data/app/views/lostcoords/ui/components/_skeleton.html.erb +4 -0
- data/app/views/lostcoords/ui/components/_spinner.html.erb +10 -0
- data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +8 -0
- data/config/emoji_search_aliases.yml +146 -0
- data/config/emoji_search_names.yml +1917 -0
- data/config/emoji_search_names_ja.yml +6902 -0
- data/config/helper_renames.json +113 -0
- data/config/importmap.rb +3 -0
- data/config/locales/en.yml +33 -0
- data/config/locales/es.yml +33 -0
- data/config/locales/ja.yml +33 -0
- data/lib/lostcoords/ui/action_view.rb +10 -0
- data/lib/lostcoords/ui/builders.rb +42 -0
- data/lib/lostcoords/ui/components.rb +13 -0
- data/lib/lostcoords/ui/engine.rb +22 -0
- data/lib/lostcoords/ui/helpers.rb +11 -0
- data/lib/lostcoords/ui/icons.rb +24 -0
- data/lib/lostcoords/ui/rails.rb +3 -0
- data/lib/lostcoords/ui/resources.rb +17 -0
- data/lib/lostcoords/ui/tokens.rb +14 -0
- data/lib/lostcoords/ui/version.rb +5 -0
- data/lib/lostcoords/ui.rb +2 -0
- data/lib/tasks/lostcoords_ui.rake +15 -0
- data/script/migrate_helpers.rb +136 -0
- data/vendor/heroicons/LICENSE +21 -0
- data/vendor/heroicons/icons.json +1303 -0
- metadata +203 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module FormHelper
|
|
3
|
+
SEGMENTED_BASE =
|
|
4
|
+
"inline-flex items-center gap-1 rounded-2xl bg-paper-2 p-1"
|
|
5
|
+
|
|
6
|
+
SEGMENTED_TAB_BASE =
|
|
7
|
+
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap " \
|
|
8
|
+
"min-h-11 rounded-xl border-0 bg-transparent " \
|
|
9
|
+
"px-4 py-2 font-body text-sm font-medium cursor-pointer no-underline " \
|
|
10
|
+
"text-ink-2 hover:text-ink " \
|
|
11
|
+
"aria-selected:bg-paper aria-selected:text-ink " \
|
|
12
|
+
"aria-pressed:bg-paper aria-pressed:text-ink " \
|
|
13
|
+
"aria-[current]:not-aria-[current=false]:bg-paper aria-[current]:not-aria-[current=false]:text-ink " \
|
|
14
|
+
"transition-[background-color,color,box-shadow,transform] duration-[150ms] ease-out " \
|
|
15
|
+
"active:scale-[0.96] disabled:opacity-40 disabled:pointer-events-none " \
|
|
16
|
+
"[&_svg]:size-[1.1em] [&_svg]:shrink-0"
|
|
17
|
+
|
|
18
|
+
SEGMENTED_TAB_ACTIVE = "bg-paper text-ink hover:text-ink"
|
|
19
|
+
|
|
20
|
+
INPUT_BASE =
|
|
21
|
+
"w-full max-w-full min-w-0 " \
|
|
22
|
+
"px-3 " \
|
|
23
|
+
"font-body text-base bg-(--field-bg,var(--paper-2)) text-ink placeholder:text-ink-3 " \
|
|
24
|
+
"border border-transparent rounded-2xl outline-none " \
|
|
25
|
+
"transition-[border-color,box-shadow] duration-150 ease-out " \
|
|
26
|
+
"focus:shadow-(--focus-halo) " \
|
|
27
|
+
"aria-invalid:border-berry aria-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--berry)_16%,transparent)]"
|
|
28
|
+
|
|
29
|
+
FIELD_BASE = "ui-field grid min-w-0 gap-2 has-aria-invalid:[&>label]:text-berry"
|
|
30
|
+
|
|
31
|
+
FIELD_LABEL_BASE =
|
|
32
|
+
"block font-body text-sm font-semibold text-ink-3 " \
|
|
33
|
+
"transition-colors duration-[120ms] ease-out"
|
|
34
|
+
|
|
35
|
+
FORM_ACTIONS_BASE = "flex flex-wrap items-center justify-end gap-2"
|
|
36
|
+
|
|
37
|
+
# The toast's surface (shared/_toast), set in the flow: the tone lives in the icon, not the fill.
|
|
38
|
+
FORM_ALERT_BASE =
|
|
39
|
+
"flex items-start gap-2 mb-4 py-3 px-4 rounded-2xl bg-(--glass-hud-solid) text-(--glass-hud-ink) text-sm font-semibold " \
|
|
40
|
+
"motion-safe:animate-alert-in"
|
|
41
|
+
|
|
42
|
+
FORM_ALERT_ICONS = {
|
|
43
|
+
error: [ "exclamation-circle", "text-berry" ],
|
|
44
|
+
notice: [ "check-circle", "text-leaf" ]
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def lc_segmented_class(extra: nil)
|
|
48
|
+
lc_tw(SEGMENTED_BASE, extra)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def lc_segmented_tab_class(active: false, extra: nil)
|
|
52
|
+
lc_tw(SEGMENTED_TAB_BASE, (SEGMENTED_TAB_ACTIVE if active), extra)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def lc_input_class(size: :base, extra: nil)
|
|
56
|
+
lc_tw(INPUT_BASE, TailwindHelper::CONTROL_SIZES.fetch(size.to_sym, TailwindHelper::CONTROL_SIZES[:base]), extra)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def lc_dropdown_trigger_class(size: :base, extra: nil)
|
|
60
|
+
lc_button_class(variant: :secondary, size: size, block: true,
|
|
61
|
+
extra: lc_tw("justify-start text-start ps-5 pe-4 gap-3 [&_.caret]:ms-auto [&_.caret]:shrink-0", extra))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def lc_select_class(size: :base, extra: nil)
|
|
65
|
+
lc_tw(lc_input_class(size: size), "select-control rounded-full ps-5 pe-11 cursor-pointer hover:bg-paper-3", extra)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def lc_textarea_class(extra: nil)
|
|
69
|
+
lc_tw(INPUT_BASE, "min-h-28 py-2.5 resize-y", extra)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def lc_field_class(extra: nil)
|
|
73
|
+
lc_tw(FIELD_BASE, extra)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def lc_field_label_class(extra: nil)
|
|
77
|
+
lc_tw(FIELD_LABEL_BASE, extra)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def lc_form_actions_class(extra: nil)
|
|
81
|
+
lc_tw(FORM_ACTIONS_BASE, extra)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def lc_form_alert_class(extra: nil)
|
|
85
|
+
lc_tw(FORM_ALERT_BASE, extra)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def lc_form_alert_icon(tone: :error)
|
|
89
|
+
name, color = FORM_ALERT_ICONS.fetch(tone.to_sym, FORM_ALERT_ICONS[:error])
|
|
90
|
+
lc_icon name, variant: :mini, options: { class: lc_tw("size-5 shrink-0", color), "aria-hidden": "true" }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module GlassHelper
|
|
3
|
+
# Menus lift further, and on phones go solid: they cover rows that look like their own
|
|
4
|
+
# options, and some open where nothing blurs (the dock). Dark lifts to paper-3 to clear cards.
|
|
5
|
+
GLASS_MENU = "[--glass-shadow:var(--glass-rim),var(--shadow-lg)] max-md:[--glass-fill:var(--paper)] dark:max-md:[--glass-fill:var(--paper-3)]"
|
|
6
|
+
|
|
7
|
+
GLASS_SHAPES = {
|
|
8
|
+
bar: "rounded-full border-(--glass-bar-edge)",
|
|
9
|
+
panel: "rounded-3xl",
|
|
10
|
+
disc: "rounded-full",
|
|
11
|
+
menu: "rounded-3xl #{GLASS_MENU}",
|
|
12
|
+
# A picker is a menu on desktop and a bottom sheet on phones, so its own CSS owns the corners.
|
|
13
|
+
picker: GLASS_MENU,
|
|
14
|
+
hud: "rounded-[1.375rem] border-transparent text-(--glass-hud-ink) [--glass-fill:var(--glass-hud-fill)] [--glass-shadow:var(--glass-hud-shadow)] [--glass-solid:var(--glass-hud-solid)]"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
GLASS_THICK =
|
|
18
|
+
"max-md:[--glass-fill:color-mix(in_oklab,var(--paper)_90%,transparent)] " \
|
|
19
|
+
"dark:max-md:[--glass-fill:color-mix(in_oklab,var(--paper-2)_92%,transparent)]"
|
|
20
|
+
|
|
21
|
+
def lc_glass_class(shape: :bar, thick: false, extra: nil)
|
|
22
|
+
lc_tw("glass", GLASS_SHAPES.fetch(shape.to_sym, GLASS_SHAPES[:bar]), (GLASS_THICK if thick), extra)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module IconHelper
|
|
3
|
+
def lc_icon(name, variant: :outline, options: {}, path_options: {})
|
|
4
|
+
svg = Icons.fetch(name, variant)
|
|
5
|
+
path_options.each do |key, value|
|
|
6
|
+
attribute = key.to_s.dasherize
|
|
7
|
+
svg.css("path").each { |path| path[attribute] = value.to_s if path.key?(attribute) }
|
|
8
|
+
end
|
|
9
|
+
defaults = svg.attribute_nodes.to_h { |attribute| [ attribute.name.to_sym, attribute.value ] }
|
|
10
|
+
defaults.merge!(xmlns: "http://www.w3.org/2000/svg", class: "lc-icon")
|
|
11
|
+
options = options.transform_keys { |key| key.to_s.dasherize.to_sym }
|
|
12
|
+
tag.svg(svg.children.to_xml.html_safe, **lc_attributes(defaults, options))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module LayoutHelper
|
|
3
|
+
LAYOUT_GAPS = {
|
|
4
|
+
tight: "gap-[var(--ui-space-tight,0.25rem)]",
|
|
5
|
+
related: "gap-[var(--ui-space-related,0.5rem)]",
|
|
6
|
+
heading: "gap-[var(--ui-space-heading,0.75rem)]",
|
|
7
|
+
content: "gap-[var(--ui-space-content,1rem)]",
|
|
8
|
+
inset: "gap-[var(--ui-space-inset,1.25rem)]",
|
|
9
|
+
section: "gap-[var(--ui-space-section,2rem)]"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
CONTAINER_SIZES = {
|
|
13
|
+
page: "ui-container-page max-w-[var(--ui-page-max,75rem)]",
|
|
14
|
+
reading: "max-w-[var(--ui-reading-measure,65ch)]",
|
|
15
|
+
form: "max-w-[var(--ui-form-max,32rem)]"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def lc_stack_class(gap: :content, extra: nil)
|
|
19
|
+
gap = lc_option!(:gap, gap, LAYOUT_GAPS.keys)
|
|
20
|
+
lc_tw("flex min-w-0 flex-col", LAYOUT_GAPS.fetch(gap), extra)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def lc_inline_class(gap: :related, extra: nil)
|
|
24
|
+
gap = lc_option!(:gap, gap, LAYOUT_GAPS.keys)
|
|
25
|
+
lc_tw("flex min-w-0 flex-wrap items-center", LAYOUT_GAPS.fetch(gap), extra)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lc_container_class(size: :page, extra: nil)
|
|
29
|
+
size = lc_option!(:size, size, CONTAINER_SIZES.keys)
|
|
30
|
+
lc_tw("mx-auto min-w-0 w-full", CONTAINER_SIZES.fetch(size), extra)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def lc_section_header_class(extra: nil)
|
|
34
|
+
lc_inline_class(extra: lc_tw("min-h-[var(--ui-section-header-height,2.75rem)] justify-between", extra))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def lc_stack(gap: :content, **attributes, &block)
|
|
38
|
+
tag.div(**lc_attributes({ class: lc_stack_class(gap: gap) }, attributes), &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def lc_inline(gap: :related, **attributes, &block)
|
|
42
|
+
tag.div(**lc_attributes({ class: lc_inline_class(gap: gap) }, attributes), &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def lc_container(size: :page, **attributes, &block)
|
|
46
|
+
tag.div(**lc_attributes({ class: lc_container_class(size: size) }, attributes), &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def lc_section(title:, level: 2, actions: nil, **attributes, &block)
|
|
50
|
+
raise ArgumentError, "title must not be blank" if title.blank?
|
|
51
|
+
|
|
52
|
+
tag.section(**lc_attributes({ class: lc_stack_class(gap: :heading) }, attributes)) do
|
|
53
|
+
heading = lc_heading(title, level: level, size: :section)
|
|
54
|
+
action_content = actions.respond_to?(:call) ? capture(&actions) : actions
|
|
55
|
+
header = tag.header(class: lc_section_header_class) { safe_join([ heading, action_content ].compact) }
|
|
56
|
+
safe_join([ header, lc_stack(&block) ])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module MenuHelper
|
|
3
|
+
# FloatingPanel sets [data-open]; the caller picks the display.
|
|
4
|
+
POPOVER_BASE =
|
|
5
|
+
"row-menu__popover squircle popover-shell hidden absolute top-[calc(100%+--spacing(1))] end-0 overscroll-contain [&_form]:m-0"
|
|
6
|
+
|
|
7
|
+
MENU_POPOVER_BASE =
|
|
8
|
+
"data-open:flex flex-col gap-0.5 z-nav-popover p-2 " \
|
|
9
|
+
"min-w-56 max-w-[calc(100vw-clamp(1rem,3vw,1.5rem)*2)] max-h-[min(24rem,70vh)] overflow-y-auto"
|
|
10
|
+
|
|
11
|
+
MENU_POPOVER_ALIGN = {
|
|
12
|
+
start: "start-0 end-auto",
|
|
13
|
+
end: "",
|
|
14
|
+
center: "start-1/2 end-auto -translate-x-1/2"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
MENU_OPTION_BASE =
|
|
18
|
+
"row-menu__item press [--press-scale:0.98] group/option grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 w-full " \
|
|
19
|
+
"min-h-11 px-3 py-2 rounded-xl border-none bg-transparent text-start font-body text-[0.9375rem] font-medium text-ink " \
|
|
20
|
+
"no-underline cursor-pointer transition-[background-color] duration-120 [--avatar-size:--spacing(7)] " \
|
|
21
|
+
"hover:bg-paper-2 focus-visible:bg-paper-2 focus-visible:outline-none " \
|
|
22
|
+
"disabled:text-ink-3 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
|
23
|
+
|
|
24
|
+
MENU_OPTION_TONES = {
|
|
25
|
+
default: "",
|
|
26
|
+
danger: "text-danger hover:bg-danger-soft focus-visible:bg-danger-soft"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def lc_popover_class(align: :end, extra: nil)
|
|
30
|
+
lc_glass_class(shape: :menu, extra: lc_tw(POPOVER_BASE, MENU_POPOVER_ALIGN.fetch(align), extra))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def lc_menu_popover_class(align: :end, extra: nil)
|
|
34
|
+
lc_popover_class(align: align, extra: lc_tw(MENU_POPOVER_BASE, extra))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def lc_menu_option_class(tone: :default, extra: nil)
|
|
38
|
+
lc_tw(MENU_OPTION_BASE, MENU_OPTION_TONES.fetch(tone), extra)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
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)
|
|
11
|
+
tag.button(lc_button_content(content, icon, icon_position, &block), **lc_button_attributes(variant, size, { type: "button" }, attributes))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def lc_button_link_to(content = nil, url = nil, variant: :primary, size: :base, icon: nil, icon_position: :start, **attributes, &block)
|
|
15
|
+
attributes = lc_button_attributes(variant, size, {}, attributes)
|
|
16
|
+
raise ArgumentError, "Links cannot be disabled; use lc_button for a disabled control" if attributes[:disabled]
|
|
17
|
+
|
|
18
|
+
if block
|
|
19
|
+
url ||= content
|
|
20
|
+
content = capture(&block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
link_to(lc_button_content(content, icon, icon_position), url, attributes)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def lc_button_to(content = nil, url = nil, variant: :primary, size: :base, icon: nil, icon_position: :start, **attributes, &block)
|
|
27
|
+
if block
|
|
28
|
+
url ||= content
|
|
29
|
+
content = capture(&block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
button_to(lc_button_content(content, icon, icon_position), url, lc_button_attributes(variant, size, {}, attributes))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lc_icon_button(label, icon:, size: :base, shape: :circle, **attributes)
|
|
36
|
+
size = lc_option!(:size, size, ButtonHelper::ICON_BUTTON_SIZES.keys)
|
|
37
|
+
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 } }
|
|
39
|
+
attributes = lc_attributes(defaults, attributes)
|
|
40
|
+
accessible_label = attributes.fetch(:"aria-label", attributes.dig(:aria, :label))
|
|
41
|
+
raise ArgumentError, "lc_icon_button requires a nonblank accessible label" if label.blank? || accessible_label.blank?
|
|
42
|
+
|
|
43
|
+
tag.button(**attributes) do
|
|
44
|
+
lc_icon(icon, options: { "aria-hidden": true })
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def lc_badge(content = nil, tone: :neutral, **attributes, &block)
|
|
49
|
+
tone = lc_option!(:tone, tone, BadgeHelper::BADGE_TONES.keys)
|
|
50
|
+
tag.span(content, **lc_attributes({ class: lc_badge_class(tone: tone) }, attributes), &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
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
|
|
65
|
+
lc_icon(icon, options: { class: "size-[1em]", "aria-hidden": true })
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
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)
|
|
78
|
+
size = lc_option!(:size, size, BadgeHelper::AVATAR_SIZES.keys)
|
|
79
|
+
defaults = { class: lc_avatar_class(size: size, ink: ink, linked: linked) }
|
|
80
|
+
if src
|
|
81
|
+
tag.img(**lc_attributes(defaults.merge(src: src, alt: alt.to_s, class: lc_tw(defaults[:class], "object-cover")), attributes))
|
|
82
|
+
else
|
|
83
|
+
tag.span(content, **lc_attributes(defaults, attributes), &block)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def lc_avatar_stack(compact: false, **attributes, &block)
|
|
88
|
+
tag.span(**lc_attributes({ class: lc_avatar_stack_class(compact: compact) }, attributes)) do
|
|
89
|
+
tag.span(class: lc_avatar_stack_row_class, &block)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def lc_card(href: nil, interactive: false, dense: false, **attributes, &block)
|
|
94
|
+
defaults = { class: lc_card_class(interactive: interactive || !href.nil?, dense: dense) }
|
|
95
|
+
if href.nil?
|
|
96
|
+
tag.div(**lc_attributes(defaults, attributes), &block)
|
|
97
|
+
else
|
|
98
|
+
defaults.merge!(href: href, class: lc_tw(defaults[:class], "block no-underline text-inherit"))
|
|
99
|
+
tag.a(**lc_attributes(defaults, attributes), &block)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
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
|
+
def lc_empty_state(**attributes, &block)
|
|
113
|
+
tag.div(**lc_attributes({ class: lc_empty_state_class }, attributes), &block)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def lc_spinner(size: :base, label: "Loading…", **attributes)
|
|
117
|
+
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?) } }
|
|
119
|
+
tag.span(**lc_attributes(defaults, attributes)) do
|
|
120
|
+
tag.span(label, class: "sr-only") if label.present?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def lc_skeleton(**attributes)
|
|
125
|
+
tag.span(**lc_attributes({ class: lc_tw(lc_skeleton_class, "h-4 w-full"), aria: { hidden: true } }, attributes))
|
|
126
|
+
end
|
|
127
|
+
|
|
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 } }
|
|
131
|
+
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]") ])
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
def lc_button_content(content, icon, position, &block)
|
|
138
|
+
position = lc_option!(:icon_position, position, %i[start end])
|
|
139
|
+
content = capture(&block) if block
|
|
140
|
+
return content unless icon
|
|
141
|
+
|
|
142
|
+
glyph = lc_icon(icon, options: { "aria-hidden": true, "data-icon": "inline-#{position}" })
|
|
143
|
+
safe_join(position == :start ? [ glyph, content ] : [ content, glyph ])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
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)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module ReadingHelper
|
|
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)"
|
|
6
|
+
|
|
7
|
+
def lc_prose_class(extra: nil)
|
|
8
|
+
lc_tw(PROSE_BASE, extra)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def lc_prose(**attributes, &block)
|
|
12
|
+
tag.div(**lc_attributes({ class: lc_prose_class }, attributes), &block)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module RenderAttributesHelper
|
|
3
|
+
private
|
|
4
|
+
def lc_attributes(defaults, attributes)
|
|
5
|
+
defaults = lc_normalize_attributes(defaults)
|
|
6
|
+
attributes = lc_normalize_attributes(attributes)
|
|
7
|
+
merged = defaults.merge(attributes)
|
|
8
|
+
merged[:class] = lc_tw(CGI.unescape_html(class_names(defaults[:class], attributes[:class]))) if defaults.key?(:class) || attributes.key?(:class)
|
|
9
|
+
%i[data aria].each do |key|
|
|
10
|
+
next unless defaults[key].is_a?(Hash) && attributes[key].is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
merged[key] = defaults[key].merge(attributes[key])
|
|
13
|
+
end
|
|
14
|
+
%i[controller action].each do |key|
|
|
15
|
+
next unless defaults.dig(:data, key)
|
|
16
|
+
|
|
17
|
+
merged[:data] ||= {}
|
|
18
|
+
merged[:data][key] = [ defaults[:data][key], attributes.dig(:data, key) ].join(" ").split.uniq.join(" ")
|
|
19
|
+
end
|
|
20
|
+
merged
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def lc_normalize_attributes(attributes)
|
|
24
|
+
normalized = attributes.deep_symbolize_keys
|
|
25
|
+
%i[data aria].each do |prefix|
|
|
26
|
+
values = normalized[prefix]
|
|
27
|
+
values = values.transform_keys { |key| key.to_s.tr("-", "_").to_sym } if values.is_a?(Hash)
|
|
28
|
+
normalized.keys.each do |key|
|
|
29
|
+
next unless key.to_s.start_with?("#{prefix}-")
|
|
30
|
+
|
|
31
|
+
values ||= {}
|
|
32
|
+
values[key.to_s.delete_prefix("#{prefix}-").tr("-", "_").to_sym] = normalized.delete(key)
|
|
33
|
+
end
|
|
34
|
+
normalized[prefix] = values if values
|
|
35
|
+
end
|
|
36
|
+
normalized
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def lc_option!(name, value, allowed)
|
|
40
|
+
normalized = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
|
|
41
|
+
return normalized if allowed.include?(normalized)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "Invalid #{name}: #{value.inspect}. Expected one of: #{allowed.map(&:inspect).join(', ')}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
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
|
|
26
|
+
# controllers. detents: "fit", "medium large" or "large".
|
|
27
|
+
def lc_sheet_data(data = {}, detents: "fit")
|
|
28
|
+
data.merge(
|
|
29
|
+
controller: [ data[:controller], "sheet" ].compact.join(" "),
|
|
30
|
+
sheet_detents_value: detents
|
|
31
|
+
)
|
|
32
|
+
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
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module StructuresHelper
|
|
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
|
+
|
|
6
|
+
def lc_input(name: nil, value: nil, type: :text, size: :base, **attributes)
|
|
7
|
+
type = lc_option!(:type, type, INPUT_TYPES)
|
|
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))
|
|
10
|
+
end
|
|
11
|
+
|
|
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))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def lc_select(options = nil, name: nil, selected: nil, size: :base, **attributes, &block)
|
|
17
|
+
size = lc_option!(:size, size, TailwindHelper::CONTROL_SIZES.keys)
|
|
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))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lc_field(label:, id:, hint: nil, error: nil, **attributes, &block)
|
|
23
|
+
lc_label!(label, :label)
|
|
24
|
+
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
|
|
28
|
+
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?)
|
|
33
|
+
].compact)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
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)
|
|
42
|
+
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)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def lc_segmented_tab(label = nil, value: nil, selected: false, **attributes, &block)
|
|
50
|
+
value ||= label
|
|
51
|
+
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)
|
|
127
|
+
end
|
|
128
|
+
|
|
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)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|