lostcoords-ui 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -89
  3. data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
  4. data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +20 -22
  6. data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
  7. data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
  8. data/app/assets/tailwind/lostcoords/ui/components.css +25 -0
  9. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  10. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  11. data/app/assets/tailwind/lostcoords/ui/disclosure.css +5 -5
  12. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +43 -43
  13. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  14. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  15. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  16. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  17. data/app/assets/tailwind/lostcoords/ui/reading.css +24 -24
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +60 -60
  19. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  20. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  21. data/app/assets/tailwind/lostcoords/ui/theme.css +194 -225
  22. data/app/assets/tailwind/lostcoords/ui/tooltip.css +50 -0
  23. data/app/assets/tailwind/lostcoords/ui/typography.css +37 -61
  24. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  25. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  26. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  27. data/app/helpers/lostcoords/ui/button_helper.rb +36 -33
  28. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  29. data/app/helpers/lostcoords/ui/choice_helper.rb +83 -0
  30. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  31. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  32. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  33. data/app/helpers/lostcoords/ui/dialog_helper.rb +6 -13
  34. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  35. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  36. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  37. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  38. data/app/helpers/lostcoords/ui/menu_helper.rb +8 -8
  39. data/app/helpers/lostcoords/ui/navigation_helper.rb +131 -0
  40. data/app/helpers/lostcoords/ui/overlay_helper.rb +17 -0
  41. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  42. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  43. data/app/helpers/lostcoords/ui/sheet_helper.rb +3 -45
  44. data/app/helpers/lostcoords/ui/structures_helper.rb +55 -64
  45. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  46. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  47. data/app/helpers/lostcoords/ui/typography_helper.rb +11 -11
  48. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  49. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +3 -3
  50. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  51. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  52. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +4 -4
  53. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +3 -3
  54. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  55. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +1 -1
  56. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +15 -15
  57. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +39 -0
  58. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  59. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +14 -0
  60. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +5 -5
  61. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  62. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  63. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +7 -7
  64. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +2 -2
  65. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  66. data/app/javascript/lostcoords/ui/index.js +51 -0
  67. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +30 -30
  68. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  69. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  70. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  71. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  72. data/config/helper_renames.json +23 -25
  73. data/config/importmap.rb +10 -2
  74. data/config/locales/en.yml +23 -0
  75. data/config/locales/es.yml +23 -0
  76. data/config/locales/ja.yml +23 -0
  77. data/lib/lostcoords/ui/action_view.rb +5 -3
  78. data/lib/lostcoords/ui/builders.rb +1 -1
  79. data/lib/lostcoords/ui/form_builder.rb +113 -0
  80. data/lib/lostcoords/ui/parts.rb +154 -0
  81. data/lib/lostcoords/ui/tokens.rb +24 -1
  82. data/lib/lostcoords/ui/version.rb +1 -1
  83. metadata +33 -17
  84. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  85. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  86. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  87. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  88. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  89. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  90. /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
@@ -1,27 +1,20 @@
1
1
  module Lostcoords::Ui
2
2
  module DialogHelper
3
- DIALOG_SURFACE =
4
- "border border-(--glass-edge) rounded-3xl bg-paper text-ink shadow-[var(--glass-rim),var(--shadow-lg)]"
5
-
6
3
  # Below sm these open as iOS sheets (sheet.css), which paint their own surface.
7
4
  SHEET_DIALOG_SURFACE =
8
- "sm:border sm:border-(--glass-edge) sm:rounded-3xl sm:bg-paper text-ink sm:shadow-[var(--glass-rim),var(--shadow-lg)]"
5
+ "sm:border sm:border-(--lc-glass-edge) sm:rounded-3xl sm:bg-popover text-popover-foreground sm:shadow-[var(--lc-glass-rim),var(--lc-shadow-lg)]"
9
6
 
10
- ANCHORED = "data-anchored:[inset:var(--dialog-top)_var(--dialog-right)_auto_auto] data-anchored:m-0"
7
+ ANCHORED = "data-anchored:[inset:var(--lc-dialog-top)_var(--lc-dialog-right)_auto_auto] data-anchored:m-0"
11
8
 
12
9
  DIALOGS = {
13
- modal: "app-dialog #{SHEET_DIALOG_SURFACE} sm:w-[min(92vw,31rem)]",
10
+ modal: "lc-dialog #{SHEET_DIALOG_SURFACE} sm:w-[min(92vw,31rem)]",
14
11
  context:
15
- "app-dialog context-dialog #{SHEET_DIALOG_SURFACE} sm:w-[min(22rem,calc(100vw-2rem))] " \
16
- "sm:max-h-[calc(100dvh-2rem)] sm:origin-(--dialog-origin,center) [--dialog-from:scale(0.96)] #{ANCHORED}"
12
+ "lc-dialog lc-dialog-context #{SHEET_DIALOG_SURFACE} sm:w-[min(22rem,calc(100vw-2rem))] " \
13
+ "sm:max-h-[calc(100dvh-2rem)] sm:origin-(--lc-dialog-origin,center) [--lc-dialog-from:scale(0.96)] #{ANCHORED}"
17
14
  }.freeze
18
15
 
19
16
  def lc_dialog_class(kind = :modal, extra: nil)
20
- lc_tw("squircle", lc_dialog_kinds.fetch(kind), extra)
21
- end
22
-
23
- def lc_dialog_surface_class(extra: nil)
24
- lc_tw(DIALOG_SURFACE, extra)
17
+ lc_tw("lc-squircle", lc_dialog_kinds.fetch(kind), extra)
25
18
  end
26
19
 
27
20
  private
@@ -5,18 +5,37 @@ module Lostcoords::Ui
5
5
 
6
6
  SPINNER_SIZES = {
7
7
  sm: "size-4 border-2",
8
- base: "size-6 border-2",
8
+ md: "size-6 border-2",
9
9
  lg: "size-8 border-[3px]"
10
10
  }.freeze
11
11
 
12
- SKELETON_BASE = "block rounded-xl bg-paper-2 animate-pulse"
12
+ SKELETON_BASE = "block rounded-xl bg-muted animate-pulse"
13
13
 
14
14
  EMPTY_STATE_BASE =
15
- "flex flex-col items-center text-center " \
16
- "py-6 px-4 sm:py-12 sm:px-6"
15
+ "flex flex-col items-center gap-content text-center " \
16
+ "py-section px-content"
17
17
 
18
- def lc_spinner_class(size: :base, extra: nil)
19
- lc_tw(SPINNER_BASE, SPINNER_SIZES.fetch(size.to_sym, SPINNER_SIZES[:base]), extra)
18
+ ALERT_BASE = "flex items-start gap-related py-heading px-content rounded-2xl lc-squircle bg-muted text-foreground text-label"
19
+ ALERT_ICONS = {
20
+ neutral: [ "information-circle", "text-muted-foreground" ],
21
+ primary: [ "information-circle", "text-primary" ],
22
+ success: [ "check-circle", "text-success" ],
23
+ warning: [ "exclamation-triangle", "text-warning" ],
24
+ destructive: [ "exclamation-circle", "text-destructive" ]
25
+ }.freeze
26
+
27
+ def lc_alert_class(extra: nil)
28
+ lc_tw(ALERT_BASE, extra)
29
+ end
30
+
31
+ def lc_alert_icon(tone: :destructive)
32
+ tone = lc_option!(:tone, tone, ALERT_ICONS.keys)
33
+ name, color = ALERT_ICONS.fetch(tone)
34
+ lc_icon name, variant: :mini, options: { class: lc_tw("size-5 shrink-0", color), "aria-hidden": "true" }
35
+ end
36
+
37
+ def lc_spinner_class(size: :md, extra: nil)
38
+ lc_tw(SPINNER_BASE, SPINNER_SIZES.fetch(lc_option!(:size, size, SPINNER_SIZES.keys)), extra)
20
39
  end
21
40
 
22
41
  def lc_skeleton_class(extra: nil)
@@ -1,72 +1,62 @@
1
1
  module Lostcoords::Ui
2
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 " \
3
+ TOGGLE_GROUP_BASE =
4
+ "inline-flex items-center gap-1 rounded-2xl bg-muted p-1 lc-squircle"
5
+
6
+ TOGGLE_GROUP_ITEM_BASE =
7
+ "lc-press inline-flex items-center justify-center gap-1.5 whitespace-nowrap " \
8
+ "h-control-sm rounded-xl border-0 bg-transparent " \
9
+ "px-3 font-sans text-label cursor-pointer no-underline " \
10
+ "text-muted-foreground hover:text-foreground " \
11
+ "aria-selected:bg-background aria-selected:text-foreground " \
12
+ "aria-pressed:bg-background aria-pressed:text-foreground " \
13
+ "aria-[current]:not-aria-[current=false]:bg-background aria-[current]:not-aria-[current=false]:text-foreground " \
14
+ "transition-[background-color,color,box-shadow,transform] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
15
+ "disabled:opacity-40 disabled:pointer-events-none " \
16
16
  "[&_svg]:size-[1.1em] [&_svg]:shrink-0"
17
17
 
18
- SEGMENTED_TAB_ACTIVE = "bg-paper text-ink hover:text-ink"
18
+ TOGGLE_GROUP_ITEM_ACTIVE = "bg-background text-foreground hover:text-foreground"
19
19
 
20
20
  INPUT_BASE =
21
21
  "w-full max-w-full min-w-0 " \
22
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)]"
23
+ "font-sans text-base bg-(--lc-field-bg,var(--input)) text-foreground placeholder:text-muted-foreground " \
24
+ "border border-transparent rounded-xl lc-squircle outline-none " \
25
+ "transition-[border-color,box-shadow] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
26
+ "focus:shadow-[0_0_0_calc(var(--spacing)*0.5)_var(--ring,color-mix(in_oklab,var(--primary)_50%,transparent))] " \
27
+ "aria-invalid:border-destructive aria-invalid:shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_16%,transparent)]"
28
28
 
29
- FIELD_BASE = "ui-field grid min-w-0 gap-2 has-aria-invalid:[&>label]:text-berry"
29
+ FIELD_BASE = "lc-field grid min-w-0 gap-related has-aria-invalid:[&>label]:text-destructive"
30
30
 
31
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"
32
+ "block font-sans text-label font-semibold text-muted-foreground " \
33
+ "transition-colors duration-(--lc-motion-quick) ease-(--lc-ease-out)"
41
34
 
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)
35
+ # form_with with Lostcoords::Ui::FormBuilder, so form.text_field, form.field and form.submit
36
+ # render library controls.
37
+ def lc_form_with(**options, &block)
38
+ form_with(**options, builder: Lostcoords::Ui::FormBuilder, &block)
49
39
  end
50
40
 
51
- def lc_segmented_tab_class(active: false, extra: nil)
52
- lc_tw(SEGMENTED_TAB_BASE, (SEGMENTED_TAB_ACTIVE if active), extra)
41
+ def lc_toggle_group_class(extra: nil)
42
+ lc_tw(TOGGLE_GROUP_BASE, extra)
53
43
  end
54
44
 
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)
45
+ def lc_toggle_group_item_class(active: false, extra: nil)
46
+ lc_tw(TOGGLE_GROUP_ITEM_BASE, (TOGGLE_GROUP_ITEM_ACTIVE if active), extra)
57
47
  end
58
48
 
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))
49
+ def lc_input_class(size: :md, extra: nil)
50
+ lc_tw(INPUT_BASE, TailwindHelper::CONTROL_SIZES.fetch(lc_option!(:size, size, TailwindHelper::CONTROL_SIZES.keys)), extra)
62
51
  end
63
52
 
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)
53
+
54
+ def lc_select_class(size: :md, extra: nil)
55
+ lc_tw(lc_input_class(size: size), "lc-select rounded-pill ps-4 pe-10 cursor-pointer hover:bg-(--lc-input-hover)", extra)
66
56
  end
67
57
 
68
58
  def lc_textarea_class(extra: nil)
69
- lc_tw(INPUT_BASE, "min-h-28 py-2.5 resize-y", extra)
59
+ lc_tw(INPUT_BASE, "min-h-28 py-2 resize-y", extra)
70
60
  end
71
61
 
72
62
  def lc_field_class(extra: nil)
@@ -76,18 +66,5 @@ module Lostcoords::Ui
76
66
  def lc_field_label_class(extra: nil)
77
67
  lc_tw(FIELD_LABEL_BASE, extra)
78
68
  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
69
  end
93
70
  end
@@ -1,25 +1,25 @@
1
1
  module Lostcoords::Ui
2
2
  module GlassHelper
3
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)]"
4
+ # options, and some open where nothing blurs (the dock). Dark lifts to surface-hover to clear cards.
5
+ GLASS_MENU = "[--lc-glass-shadow:var(--lc-glass-rim),var(--lc-shadow-lg)] max-md:[--lc-glass-fill:var(--background)] dark:max-md:[--lc-glass-fill:var(--accent)]"
6
6
 
7
7
  GLASS_SHAPES = {
8
- bar: "rounded-full border-(--glass-bar-edge)",
8
+ bar: "rounded-full border-(--lc-glass-bar-edge)",
9
9
  panel: "rounded-3xl",
10
10
  disc: "rounded-full",
11
11
  menu: "rounded-3xl #{GLASS_MENU}",
12
12
  # A picker is a menu on desktop and a bottom sheet on phones, so its own CSS owns the corners.
13
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)]"
14
+ hud: "rounded-[1.375rem] border-transparent text-(--lc-glass-hud-ink) [--lc-glass-fill:var(--lc-glass-hud-fill)] [--lc-glass-shadow:var(--lc-glass-hud-shadow)] [--lc-glass-solid:var(--lc-glass-hud-solid)]"
15
15
  }.freeze
16
16
 
17
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)]"
18
+ "max-md:[--lc-glass-fill:color-mix(in_oklab,var(--background)_90%,transparent)] " \
19
+ "dark:max-md:[--lc-glass-fill:color-mix(in_oklab,var(--muted)_92%,transparent)]"
20
20
 
21
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)
22
+ lc_tw("lc-glass", GLASS_SHAPES.fetch(shape.to_sym, GLASS_SHAPES[:bar]), (GLASS_THICK if thick), extra)
23
23
  end
24
24
  end
25
25
  end
@@ -1,18 +1,19 @@
1
1
  module Lostcoords::Ui
2
2
  module LayoutHelper
3
+ # Space between things belongs to these helpers; components never set outer margin.
3
4
  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)]"
5
+ tight: "gap-tight",
6
+ related: "gap-related",
7
+ heading: "gap-heading",
8
+ content: "gap-content",
9
+ inset: "gap-inset",
10
+ section: "gap-section"
10
11
  }.freeze
11
12
 
12
13
  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)]"
14
+ page: "lc-container-page max-w-page",
15
+ reading: "max-w-reading",
16
+ form: "max-w-form"
16
17
  }.freeze
17
18
 
18
19
  def lc_stack_class(gap: :content, extra: nil)
@@ -30,8 +31,9 @@ module Lostcoords::Ui
30
31
  lc_tw("mx-auto min-w-0 w-full", CONTAINER_SIZES.fetch(size), extra)
31
32
  end
32
33
 
34
+ # The header keeps a control's height, so a section with actions and one without line up.
33
35
  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))
36
+ lc_inline_class(extra: lc_tw("min-h-control-md justify-between", extra))
35
37
  end
36
38
 
37
39
  def lc_stack(gap: :content, **attributes, &block)
@@ -50,7 +52,7 @@ module Lostcoords::Ui
50
52
  raise ArgumentError, "title must not be blank" if title.blank?
51
53
 
52
54
  tag.section(**lc_attributes({ class: lc_stack_class(gap: :heading) }, attributes)) do
53
- heading = lc_heading(title, level: level, size: :section)
55
+ heading = lc_heading(title, level: level, size: :heading)
54
56
  action_content = actions.respond_to?(:call) ? capture(&actions) : actions
55
57
  header = tag.header(class: lc_section_header_class) { safe_join([ heading, action_content ].compact) }
56
58
  safe_join([ header, lc_stack(&block) ])
@@ -2,10 +2,10 @@ module Lostcoords::Ui
2
2
  module MenuHelper
3
3
  # FloatingPanel sets [data-open]; the caller picks the display.
4
4
  POPOVER_BASE =
5
- "row-menu__popover squircle popover-shell hidden absolute top-[calc(100%+--spacing(1))] end-0 overscroll-contain [&_form]:m-0"
5
+ "lc-menu__popover lc-squircle lc-popover-shell hidden absolute top-[calc(100%+--spacing(1))] end-0 overscroll-contain [&_form]:m-0"
6
6
 
7
7
  MENU_POPOVER_BASE =
8
- "data-open:flex flex-col gap-0.5 z-nav-popover p-2 " \
8
+ "data-open:flex flex-col gap-px z-(--lc-z-popover) p-related " \
9
9
  "min-w-56 max-w-[calc(100vw-clamp(1rem,3vw,1.5rem)*2)] max-h-[min(24rem,70vh)] overflow-y-auto"
10
10
 
11
11
  MENU_POPOVER_ALIGN = {
@@ -15,15 +15,15 @@ module Lostcoords::Ui
15
15
  }.freeze
16
16
 
17
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"
18
+ "lc-menu__item lc-press [--lc-press-scale:0.98] group/option grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 w-full " \
19
+ "min-h-control-md px-3 py-1.5 rounded-2xl lc-squircle border-none bg-transparent text-start font-sans text-label text-foreground " \
20
+ "no-underline cursor-pointer transition-[background-color] duration-120 [--lc-avatar-size:--spacing(7)] " \
21
+ "hover:bg-accent focus-visible:bg-accent focus-visible:outline-none " \
22
+ "disabled:text-muted-foreground disabled:cursor-not-allowed disabled:hover:bg-transparent"
23
23
 
24
24
  MENU_OPTION_TONES = {
25
25
  default: "",
26
- danger: "text-danger hover:bg-danger-soft focus-visible:bg-danger-soft"
26
+ destructive: "text-destructive hover:bg-destructive-soft focus-visible:bg-destructive-soft"
27
27
  }.freeze
28
28
 
29
29
  def lc_popover_class(align: :end, extra: nil)
@@ -0,0 +1,131 @@
1
+ module Lostcoords::Ui
2
+ module NavigationHelper
3
+ BREADCRUMB_LINK = "rounded-sm text-muted-foreground no-underline underline-offset-4 hover:text-foreground hover:underline"
4
+
5
+ LINK_TONES = {
6
+ inherit: "text-inherit decoration-current/40 hover:decoration-current",
7
+ muted: "text-muted-foreground decoration-current/40 hover:text-foreground hover:decoration-current",
8
+ primary: "text-primary decoration-current/40 hover:decoration-current"
9
+ }.freeze
10
+
11
+ NAV_LINK = "flex min-w-0 items-center rounded-xl lc-squircle no-underline transition-colors duration-(--lc-motion-quick) " \
12
+ "text-muted-foreground hover:text-foreground aria-[current=page]:text-foreground"
13
+
14
+ # An inline text link. Prose inside lc_prose styles its links already.
15
+ def lc_link_class(tone: :inherit, extra: nil)
16
+ tone = lc_option!(:tone, tone, LINK_TONES.keys)
17
+ lc_tw("rounded-sm underline underline-offset-4 decoration-1 transition-colors duration-(--lc-motion-quick)", LINK_TONES.fetch(tone), extra)
18
+ end
19
+
20
+ def lc_link(content = nil, url = nil, tone: :inherit, **attributes, &block)
21
+ if block
22
+ url = content
23
+ content = capture(&block)
24
+ end
25
+ raise ArgumentError, "lc_link needs a URL" if url.blank?
26
+
27
+ link_to(content, url, **lc_attributes({ class: lc_link_class(tone: tone), data: { slot: "link" } }, attributes))
28
+ end
29
+
30
+ # compact: true is the quieter style for an "On this page" list.
31
+ def lc_nav_link_class(compact: false, extra: nil)
32
+ size = compact ? "py-tight text-small aria-[current=page]:font-medium" : "min-h-control-md px-3 py-1.5 text-label hover:bg-accent aria-[current=page]:bg-accent aria-[current=page]:font-semibold"
33
+ lc_tw(NAV_LINK, size, extra)
34
+ end
35
+
36
+ # Yields a builder with link and group parts; links mark the current page with aria-current.
37
+ def lc_nav_list(label:, compact: false, **attributes, &block)
38
+ lc_label!(label, :label)
39
+ tag.nav(**lc_attributes({ aria: { label: label }, data: { slot: "nav-list", compact: (true if compact) } }, attributes)) do
40
+ tag.ul(capture(Parts::NavList.new(self, compact), &block), class: "m-0 flex list-none flex-col gap-px p-0")
41
+ end
42
+ end
43
+
44
+ # Previous and next links between pages in a sequence. Each side takes [title, url] or nil.
45
+ # compact: true shows icon buttons, for a page header.
46
+ def lc_pager(previous: nil, next: nil, label: lc_t("lostcoords.ui.pager.label"), compact: false, **attributes)
47
+ pages = { previous: previous, next: binding.local_variable_get(:next) }
48
+ raise ArgumentError, "lc_pager needs a previous or next page" if pages.values.none?
49
+
50
+ tag.nav(**lc_attributes({ aria: { label: label }, class: lc_tw("flex items-stretch gap-related", ("justify-between" unless compact)), data: { slot: "pager" } }, attributes)) do
51
+ safe_join(pages.map do |direction, page|
52
+ next tag.span(class: "flex-1", aria: { hidden: true }) if page.nil? && !compact
53
+ next if page.nil?
54
+
55
+ title, url = page
56
+ word = lc_t("lostcoords.ui.pager.#{direction}")
57
+ icon = direction == :previous ? "arrow-left" : "arrow-right"
58
+ glyph = lc_icon(icon, variant: :mini, options: { class: "size-4 shrink-0 rtl:rotate-180", "aria-hidden": true })
59
+ if compact
60
+ link_to(glyph, url, class: lc_icon_button_class(size: :sm), aria: { label: "#{word}: #{title}" }, rel: (direction == :previous ? "prev" : "next"), data: { slot: "pager-#{direction}" })
61
+ else
62
+ link_to(url, rel: (direction == :previous ? "prev" : "next"), data: { slot: "pager-#{direction}" },
63
+ class: lc_tw("flex min-w-0 flex-1 basis-0 flex-col gap-tight rounded-2xl lc-squircle bg-card p-content no-underline text-inherit transition-colors hover:bg-accent sm:max-w-80", ("ms-auto items-end text-end" if direction == :next))) do
64
+ safe_join([
65
+ tag.span(safe_join(direction == :previous ? [ glyph, word ] : [ word, glyph ]), class: "inline-flex items-center gap-tight text-caption text-muted-foreground"),
66
+ tag.span(title, class: "max-w-full truncate text-label")
67
+ ], " ")
68
+ end
69
+ end
70
+ end.compact)
71
+ end
72
+ end
73
+
74
+ # The first focusable element on a page; it stays hidden until it receives keyboard focus.
75
+ def lc_skip_link(target = "#main", label: lc_t("lostcoords.ui.skip_link.label"), **attributes)
76
+ defaults = { class: "sr-only focus:not-sr-only focus:fixed focus:top-content focus:start-content focus:z-(--lc-z-toast) " \
77
+ "focus:rounded-xl focus:bg-popover focus:px-content focus:py-related focus:text-label focus:text-popover-foreground focus:shadow-(--lc-shadow-lg)",
78
+ data: { slot: "skip-link" } }
79
+ link_to(label, target, **lc_attributes(defaults, attributes))
80
+ end
81
+
82
+ # items: [[label, url], …, label]. The last item is the current page.
83
+ def lc_breadcrumb(items, label: lc_t("lostcoords.ui.breadcrumb.label"), **attributes)
84
+ items = Array(items).map { |item| Array(item) }
85
+ raise ArgumentError, "lc_breadcrumb needs at least one item" if items.empty?
86
+
87
+ tag.nav(**lc_attributes({ aria: { label: label }, data: { slot: "breadcrumb" } }, attributes)) do
88
+ tag.ol(class: "m-0 flex min-w-0 list-none flex-wrap items-center gap-tight p-0 text-small") do
89
+ safe_join(items.each_with_index.map do |(text, url), index|
90
+ current = index == items.size - 1
91
+ tag.li(class: "inline-flex min-w-0 items-center gap-tight", data: { slot: "breadcrumb-item" }) do
92
+ safe_join([
93
+ (current || url.nil? ? tag.span(text, class: "truncate font-medium text-foreground", aria: { current: ("page" if current) }) : link_to(text, url, class: BREADCRUMB_LINK)),
94
+ (lc_icon("chevron-right", variant: :mini, options: { class: "size-4 shrink-0 text-muted-foreground rtl:rotate-180", "aria-hidden": true }) unless current)
95
+ ].compact)
96
+ end
97
+ end)
98
+ end
99
+ end
100
+ end
101
+
102
+ # Yields a builder; each `tabs.panel "Label" do … end` adds a tab and its panel.
103
+ def lc_tabs(id:, label:, selected: nil, **attributes, &block)
104
+ lc_label!(id, :id)
105
+ lc_label!(label, :label)
106
+ tabs = Parts::Tabs.new(self)
107
+ capture(tabs, &block) if block
108
+ raise ArgumentError, "lc_tabs needs at least one panel" if tabs.panels.empty?
109
+
110
+ selected = (selected || tabs.panels.first.value).to_s
111
+ raise ArgumentError, "selected tab #{selected.inspect} has no panel" unless tabs.panels.any? { |panel| panel.value == selected }
112
+
113
+ key = ->(panel) { "#{id}-#{panel.value.gsub(/[^\w-]+/, '-')}" }
114
+ list = tag.div(role: "tablist", aria: { label: label }, class: lc_toggle_group_class(extra: "max-w-full overflow-x-auto lc-scroll-fade-x"), data: { slot: "tabs-list" }) do
115
+ safe_join(tabs.panels.map do |panel|
116
+ active = panel.value == selected
117
+ tag.button(panel.label, type: "button", role: "tab", id: "#{key.(panel)}-tab", tabindex: (active ? 0 : -1),
118
+ aria: { selected: active, controls: "#{key.(panel)}-panel" }, class: lc_toggle_group_item_class,
119
+ data: { slot: "tabs-trigger", value: panel.value, lc_tabs_target: "tab", action: "click->lc-tabs#select keydown->lc-tabs#navigate" })
120
+ end)
121
+ end
122
+ panels = tabs.panels.map do |panel|
123
+ tag.div(panel.content, **lc_attributes({ role: "tabpanel", id: "#{key.(panel)}-panel", tabindex: 0, hidden: (panel.value != selected),
124
+ aria: { labelledby: "#{key.(panel)}-tab" }, class: "min-w-0 rounded-lg outline-offset-4", data: { slot: "tabs-panel", lc_tabs_target: "panel" } }, panel.attributes))
125
+ end
126
+ tag.div(**lc_attributes({ id: id, class: "flex min-w-0 flex-col gap-content", data: { slot: "tabs", controller: "lc-tabs" } }, attributes)) do
127
+ safe_join([ list, *panels ])
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,17 @@
1
+ module Lostcoords::Ui
2
+ module OverlayHelper
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: "keydown@window->lc-tooltip#dismiss mouseleave->lc-tooltip#reset focusout->lc-tooltip#reset" } }, attributes)) do
13
+ safe_join([ trigger, tag.span(text, id: id, role: "tooltip", class: "lc-tooltip__content", data: { slot: "tooltip-content" }) ].compact)
14
+ end
15
+ end
16
+ end
17
+ end