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.
- checksums.yaml +4 -4
- data/README.md +303 -89
- data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
- data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
- data/app/assets/tailwind/lostcoords/ui/card.css +20 -22
- data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
- data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
- data/app/assets/tailwind/lostcoords/ui/components.css +25 -0
- data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
- data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
- data/app/assets/tailwind/lostcoords/ui/disclosure.css +5 -5
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +43 -43
- data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
- data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
- data/app/assets/tailwind/lostcoords/ui/reading.css +24 -24
- data/app/assets/tailwind/lostcoords/ui/sheet.css +60 -60
- data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
- data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/theme.css +194 -225
- data/app/assets/tailwind/lostcoords/ui/tooltip.css +50 -0
- data/app/assets/tailwind/lostcoords/ui/typography.css +37 -61
- data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
- data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
- data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
- data/app/helpers/lostcoords/ui/button_helper.rb +36 -33
- data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
- data/app/helpers/lostcoords/ui/choice_helper.rb +83 -0
- data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +6 -13
- data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
- data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
- data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
- data/app/helpers/lostcoords/ui/menu_helper.rb +8 -8
- data/app/helpers/lostcoords/ui/navigation_helper.rb +131 -0
- data/app/helpers/lostcoords/ui/overlay_helper.rb +17 -0
- data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
- data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/sheet_helper.rb +3 -45
- data/app/helpers/lostcoords/ui/structures_helper.rb +55 -64
- data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
- data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
- data/app/helpers/lostcoords/ui/typography_helper.rb +11 -11
- data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
- data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +3 -3
- data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +3 -3
- data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +15 -15
- data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +39 -0
- data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
- data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +14 -0
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +5 -5
- data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
- data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +7 -7
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +2 -2
- data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
- data/app/javascript/lostcoords/ui/index.js +51 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +30 -30
- data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
- data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
- data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
- data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
- data/config/helper_renames.json +23 -25
- data/config/importmap.rb +10 -2
- data/config/locales/en.yml +23 -0
- data/config/locales/es.yml +23 -0
- data/config/locales/ja.yml +23 -0
- data/lib/lostcoords/ui/action_view.rb +5 -3
- data/lib/lostcoords/ui/builders.rb +1 -1
- data/lib/lostcoords/ui/form_builder.rb +113 -0
- data/lib/lostcoords/ui/parts.rb +154 -0
- data/lib/lostcoords/ui/tokens.rb +24 -1
- data/lib/lostcoords/ui/version.rb +1 -1
- metadata +33 -17
- data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
- data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
- data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
- /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
- /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
|
@@ -1,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-
|
|
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: "
|
|
10
|
+
modal: "lc-dialog #{SHEET_DIALOG_SURFACE} sm:w-[min(92vw,31rem)]",
|
|
14
11
|
context:
|
|
15
|
-
"
|
|
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
|
-
|
|
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-
|
|
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-
|
|
15
|
+
"flex flex-col items-center gap-content text-center " \
|
|
16
|
+
"py-section px-content"
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
4
|
-
"inline-flex items-center gap-1 rounded-2xl bg-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap " \
|
|
8
|
-
"
|
|
9
|
-
"px-
|
|
10
|
-
"text-
|
|
11
|
-
"aria-selected:bg-
|
|
12
|
-
"aria-pressed:bg-
|
|
13
|
-
"aria-[current]:not-aria-[current=false]:bg-
|
|
14
|
-
"transition-[background-color,color,box-shadow,transform] duration-
|
|
15
|
-
"
|
|
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
|
-
|
|
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-
|
|
24
|
-
"border border-transparent rounded-
|
|
25
|
-
"transition-[border-color,box-shadow] duration-
|
|
26
|
-
"focus:shadow-(--
|
|
27
|
-
"aria-invalid:border-
|
|
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 = "
|
|
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-
|
|
33
|
-
"transition-colors duration-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
52
|
-
lc_tw(
|
|
41
|
+
def lc_toggle_group_class(extra: nil)
|
|
42
|
+
lc_tw(TOGGLE_GROUP_BASE, extra)
|
|
53
43
|
end
|
|
54
44
|
|
|
55
|
-
def
|
|
56
|
-
lc_tw(
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
5
|
-
GLASS_MENU = "[--glass-shadow:var(--glass-rim),var(--shadow-lg)] max-md:[--glass-fill:var(--
|
|
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(--
|
|
19
|
-
"dark:max-md:[--glass-fill:color-mix(in_oklab,var(--
|
|
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-
|
|
5
|
-
related: "gap-
|
|
6
|
-
heading: "gap-
|
|
7
|
-
content: "gap-
|
|
8
|
-
inset: "gap-
|
|
9
|
-
section: "gap-
|
|
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: "
|
|
14
|
-
reading: "max-w-
|
|
15
|
-
form: "max-w-
|
|
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-
|
|
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: :
|
|
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
|
-
"
|
|
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-
|
|
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
|
-
"
|
|
19
|
-
"min-h-
|
|
20
|
-
"no-underline cursor-pointer transition-[background-color] duration-120 [--avatar-size:--spacing(7)] " \
|
|
21
|
-
"hover:bg-
|
|
22
|
-
"disabled:text-
|
|
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
|
-
|
|
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
|