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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +352 -0
  4. data/app/assets/tailwind/lostcoords/ui/avatar.css +11 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +26 -0
  6. data/app/assets/tailwind/lostcoords/ui/copy-button.css +33 -0
  7. data/app/assets/tailwind/lostcoords/ui/dialog.css +60 -0
  8. data/app/assets/tailwind/lostcoords/ui/disclosure.css +43 -0
  9. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +462 -0
  10. data/app/assets/tailwind/lostcoords/ui/field.css +6 -0
  11. data/app/assets/tailwind/lostcoords/ui/glass.css +37 -0
  12. data/app/assets/tailwind/lostcoords/ui/layout.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/popover.css +75 -0
  14. data/app/assets/tailwind/lostcoords/ui/reading.css +60 -0
  15. data/app/assets/tailwind/lostcoords/ui/sheet.css +170 -0
  16. data/app/assets/tailwind/lostcoords/ui/squircle.css +32 -0
  17. data/app/assets/tailwind/lostcoords/ui/theme.css +255 -0
  18. data/app/assets/tailwind/lostcoords/ui/typography.css +81 -0
  19. data/app/assets/tailwind/lostcoords/ui/ui.css +21 -0
  20. data/app/assets/tailwind/lostcoords/ui/utilities.css +45 -0
  21. data/app/assets/tailwind/lostcoords_ui/engine.css +1 -0
  22. data/app/helpers/lostcoords/ui/badge_helper.rb +162 -0
  23. data/app/helpers/lostcoords/ui/button_helper.rb +70 -0
  24. data/app/helpers/lostcoords/ui/card_helper.rb +60 -0
  25. data/app/helpers/lostcoords/ui/copy_button_helper.rb +40 -0
  26. data/app/helpers/lostcoords/ui/dialog_helper.rb +33 -0
  27. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +245 -0
  28. data/app/helpers/lostcoords/ui/feedback_helper.rb +30 -0
  29. data/app/helpers/lostcoords/ui/form_helper.rb +93 -0
  30. data/app/helpers/lostcoords/ui/glass_helper.rb +25 -0
  31. data/app/helpers/lostcoords/ui/icon_helper.rb +15 -0
  32. data/app/helpers/lostcoords/ui/layout_helper.rb +60 -0
  33. data/app/helpers/lostcoords/ui/menu_helper.rb +41 -0
  34. data/app/helpers/lostcoords/ui/primitives_helper.rb +152 -0
  35. data/app/helpers/lostcoords/ui/reading_helper.rb +15 -0
  36. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +46 -0
  37. data/app/helpers/lostcoords/ui/sheet_helper.rb +54 -0
  38. data/app/helpers/lostcoords/ui/structures_helper.rb +159 -0
  39. data/app/helpers/lostcoords/ui/tailwind_helper.rb +14 -0
  40. data/app/helpers/lostcoords/ui/translation_helper.rb +13 -0
  41. data/app/helpers/lostcoords/ui/typography_helper.rb +28 -0
  42. data/app/javascript/lostcoords/ui/controllers/confirm_dialog_controller.js +39 -0
  43. data/app/javascript/lostcoords/ui/controllers/disclosure_controller.js +163 -0
  44. data/app/javascript/lostcoords/ui/controllers/emoji_picker_controller.js +366 -0
  45. data/app/javascript/lostcoords/ui/controllers/modal_controller.js +27 -0
  46. data/app/javascript/lostcoords/ui/controllers/row_menu_controller.js +83 -0
  47. data/app/javascript/lostcoords/ui/controllers/sheet_controller.js +422 -0
  48. data/app/javascript/lostcoords/ui/controllers/toast_controller.js +69 -0
  49. data/app/javascript/lostcoords/ui/controllers/ui_copy_controller.js +56 -0
  50. data/app/javascript/lostcoords/ui/controllers/ui_popover_controller.js +17 -0
  51. data/app/javascript/lostcoords/ui/controllers/ui_selection_controller.js +69 -0
  52. data/app/javascript/lostcoords/ui/controllers/ui_toggle_controller.js +13 -0
  53. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +455 -0
  54. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +29 -0
  55. data/app/javascript/lostcoords/ui/helpers/dialog.js +31 -0
  56. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +155 -0
  57. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +108 -0
  58. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +27 -0
  59. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +13 -0
  60. data/app/javascript/lostcoords/ui/helpers/top_layer.js +102 -0
  61. data/app/javascript/lostcoords/ui/helpers/viewport.js +13 -0
  62. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +176 -0
  63. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +29 -0
  64. data/app/views/lostcoords/ui/components/_form_alert.html.erb +4 -0
  65. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +13 -0
  66. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +5 -0
  67. data/app/views/lostcoords/ui/components/_skeleton.html.erb +4 -0
  68. data/app/views/lostcoords/ui/components/_spinner.html.erb +10 -0
  69. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +8 -0
  70. data/config/emoji_search_aliases.yml +146 -0
  71. data/config/emoji_search_names.yml +1917 -0
  72. data/config/emoji_search_names_ja.yml +6902 -0
  73. data/config/helper_renames.json +113 -0
  74. data/config/importmap.rb +3 -0
  75. data/config/locales/en.yml +33 -0
  76. data/config/locales/es.yml +33 -0
  77. data/config/locales/ja.yml +33 -0
  78. data/lib/lostcoords/ui/action_view.rb +10 -0
  79. data/lib/lostcoords/ui/builders.rb +42 -0
  80. data/lib/lostcoords/ui/components.rb +13 -0
  81. data/lib/lostcoords/ui/engine.rb +22 -0
  82. data/lib/lostcoords/ui/helpers.rb +11 -0
  83. data/lib/lostcoords/ui/icons.rb +24 -0
  84. data/lib/lostcoords/ui/rails.rb +3 -0
  85. data/lib/lostcoords/ui/resources.rb +17 -0
  86. data/lib/lostcoords/ui/tokens.rb +14 -0
  87. data/lib/lostcoords/ui/version.rb +5 -0
  88. data/lib/lostcoords/ui.rb +2 -0
  89. data/lib/tasks/lostcoords_ui.rake +15 -0
  90. data/script/migrate_helpers.rb +136 -0
  91. data/vendor/heroicons/LICENSE +21 -0
  92. data/vendor/heroicons/icons.json +1303 -0
  93. 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