lostcoords-ui 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +364 -96
- data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
- data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
- data/app/assets/tailwind/lostcoords/ui/card.css +23 -22
- data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
- data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
- data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +6 -6
- data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
- data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
- data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
- data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
- data/app/assets/tailwind/lostcoords/ui/components.css +12 -0
- data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
- data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
- data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
- data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +86 -88
- data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
- data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
- data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
- data/app/assets/tailwind/lostcoords/ui/prose.css +66 -0
- data/app/assets/tailwind/lostcoords/ui/sheet.css +62 -63
- data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
- data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/theme.css +209 -225
- data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
- data/app/assets/tailwind/lostcoords/ui/tooltip.css +51 -0
- data/app/assets/tailwind/lostcoords/ui/typography.css +31 -61
- data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
- data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
- data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
- data/app/helpers/lostcoords/ui/button_helper.rb +44 -35
- data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
- data/app/helpers/lostcoords/ui/choice_helper.rb +87 -0
- data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
- data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
- data/app/helpers/lostcoords/ui/dialog_helper.rb +15 -17
- data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
- data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
- data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
- data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
- data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
- data/app/helpers/lostcoords/ui/menu_helper.rb +45 -10
- data/app/helpers/lostcoords/ui/navigation_helper.rb +144 -0
- data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
- data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
- data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
- data/app/helpers/lostcoords/ui/sheet_helper.rb +7 -45
- data/app/helpers/lostcoords/ui/structures_helper.rb +26 -125
- data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
- data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
- data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
- data/app/helpers/lostcoords/ui/tooltip_helper.rb +17 -0
- data/app/helpers/lostcoords/ui/typography_helper.rb +41 -11
- data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
- data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
- data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +38 -10
- data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
- data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +25 -3
- data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
- data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +7 -15
- data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +17 -17
- data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +42 -0
- data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
- data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
- data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +54 -0
- data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +77 -25
- data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
- data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
- data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +30 -7
- data/app/javascript/lostcoords/ui/helpers/floating_panel.js +7 -8
- data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
- data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
- data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
- data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
- data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
- data/app/javascript/lostcoords/ui/index.js +57 -0
- data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +46 -46
- data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
- data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
- data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
- data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
- data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
- data/config/importmap.rb +10 -2
- data/config/locales/en.yml +23 -0
- data/config/locales/es.yml +23 -0
- data/config/locales/ja.yml +23 -0
- data/lib/lostcoords/ui/action_view.rb +6 -3
- data/lib/lostcoords/ui/builders.rb +2 -2
- data/lib/lostcoords/ui/form_builder.rb +126 -0
- data/lib/lostcoords/ui/parts.rb +154 -0
- data/lib/lostcoords/ui/stylesheets.rb +25 -0
- data/lib/lostcoords/ui/tokens.rb +24 -1
- data/lib/lostcoords/ui/version.rb +1 -1
- data/lib/tasks/lostcoords_ui.rake +11 -0
- metadata +54 -21
- data/app/assets/tailwind/lostcoords/ui/reading.css +0 -60
- data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
- data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
- data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
- data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
- data/config/helper_renames.json +0 -113
- data/script/migrate_helpers.rb +0 -136
- /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
- /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Lostcoords::Ui
|
|
2
2
|
module CopyButtonHelper
|
|
3
|
-
def lc_copy_button(label = nil, value: nil, source: nil, copied_label: nil, error_label: nil, reset_after: 2000, icon_only: false, variant: :secondary, size: :
|
|
3
|
+
def lc_copy_button(label = nil, value: nil, source: nil, copied_label: nil, error_label: nil, reset_after: 2000, icon_only: false, variant: :secondary, size: :md, **attributes)
|
|
4
4
|
raise ArgumentError, "Provide either value: or source:" unless value.nil? != source.nil?
|
|
5
5
|
raise ArgumentError, "source: must be a nonblank element ID" if !source.nil? && source.blank?
|
|
6
6
|
raise ArgumentError, "reset_after: must be a positive number of milliseconds" unless reset_after.is_a?(Numeric) && reset_after.finite? && reset_after.positive?
|
|
@@ -8,15 +8,15 @@ module Lostcoords::Ui
|
|
|
8
8
|
label ||= lc_t("lostcoords.ui.copy_button.label")
|
|
9
9
|
copied_label ||= lc_t("lostcoords.ui.copy_button.copied")
|
|
10
10
|
error_label ||= lc_t("lostcoords.ui.copy_button.retry")
|
|
11
|
-
size = lc_option!(:size, size, %i[sm
|
|
11
|
+
size = lc_option!(:size, size, %i[sm md lg])
|
|
12
12
|
defaults = {
|
|
13
|
-
class: lc_tw("inline-grid", ({ sm: "size-
|
|
14
|
-
aria: { label: label }, data: { action: "
|
|
13
|
+
class: lc_tw("inline-grid", ({ sm: "size-control-sm p-0", md: "size-control-md p-0", lg: "size-control-lg p-0" }.fetch(size) if icon_only)),
|
|
14
|
+
aria: { label: label }, data: { action: "lc-copy#copy", lc_copy_target: "button" }
|
|
15
15
|
}
|
|
16
16
|
button_attributes = lc_attributes(defaults, attributes).merge(type: "button")
|
|
17
17
|
raise ArgumentError, "A copy button requires a nonblank accessible label" if button_attributes.dig(:aria, :label).blank?
|
|
18
18
|
|
|
19
|
-
tag.span(class: "
|
|
19
|
+
tag.span(class: "lc-copy", data: { controller: "lc-copy", state: "idle", lc_copy_value_value: value, lc_copy_source_value: source, lc_copy_reset_after_value: reset_after }) do
|
|
20
20
|
safe_join([
|
|
21
21
|
lc_button(variant: variant, size: size, **button_attributes) do
|
|
22
22
|
safe_join([
|
|
@@ -25,14 +25,14 @@ module Lostcoords::Ui
|
|
|
25
25
|
lc_copy_content(:error, "arrow-path", error_label, icon_only)
|
|
26
26
|
])
|
|
27
27
|
end,
|
|
28
|
-
tag.span(class: "sr-only", role: "status", aria: { atomic: true }, data: {
|
|
28
|
+
tag.span(class: "sr-only", role: "status", aria: { atomic: true }, data: { lc_copy_target: "status", copied: copied_label, error: lc_t("lostcoords.ui.copy_button.error") })
|
|
29
29
|
])
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
private
|
|
34
34
|
def lc_copy_content(state, icon, label, icon_only)
|
|
35
|
-
tag.span(class: "
|
|
35
|
+
tag.span(class: "lc-copy-content", aria: { hidden: true }, data: { copy_state: state, icon: ("inline-start" unless icon_only) }) do
|
|
36
36
|
safe_join([ lc_icon(icon, options: { "aria-hidden": true }), (tag.span(label) unless icon_only) ].compact)
|
|
37
37
|
end
|
|
38
38
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Lostcoords::Ui
|
|
2
|
+
module DescriptionListHelper
|
|
3
|
+
# Yields a builder; `list.item "Owner", "Ada"` or `list.item("Owner") { … }` adds a row.
|
|
4
|
+
# Rows stack on phones and sit in two columns from the sm breakpoint.
|
|
5
|
+
def lc_description_list(**attributes, &block)
|
|
6
|
+
defaults = { class: "m-0 min-w-0 divide-y divide-border [&>div:first-child]:pt-0 [&>div:last-child]:pb-0", data: { slot: "description-list" } }
|
|
7
|
+
tag.dl(**lc_attributes(defaults, attributes)) do
|
|
8
|
+
capture(Parts::DescriptionList.new(self), &block) if block
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -1,33 +1,31 @@
|
|
|
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",
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def lc_dialog_surface_class(extra: nil)
|
|
24
|
-
lc_tw(DIALOG_SURFACE, extra)
|
|
17
|
+
lc_tw("lc-squircle", DIALOGS.fetch(lc_option!(:kind, kind, DIALOGS.keys)), extra)
|
|
25
18
|
end
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
# Yields a builder with footer and close parts.
|
|
21
|
+
def lc_dialog(id:, title:, description: nil, trigger: lc_t("lostcoords.ui.dialog.trigger"), kind: :modal, detents: "fit", **attributes, &block)
|
|
22
|
+
lc_label!(id, :id)
|
|
23
|
+
lc_label!(title, :title)
|
|
24
|
+
lc_label!(trigger, :trigger)
|
|
25
|
+
defaults = { class: lc_dialog_class(kind), aria: { describedby: ("#{id}-description" if description.present?) }, data: lc_sheet_data({ slot: "dialog" }, detents: detents) }
|
|
26
|
+
required = { id: id, aria: { labelledby: "#{id}-title" }, data: { lc_dialog_target: "dialog" } }
|
|
27
|
+
render "lostcoords/ui/components/lc_dialog", id: id, title: title, description: description, trigger: trigger,
|
|
28
|
+
attributes: lc_attributes(defaults, attributes, required), content: (capture(Parts::Dialog.new(self, id), &block) if block)
|
|
31
29
|
end
|
|
32
30
|
end
|
|
33
31
|
end
|
|
@@ -6,6 +6,12 @@ module Lostcoords::Ui
|
|
|
6
6
|
module EmojiPickerHelper
|
|
7
7
|
include TranslationHelper
|
|
8
8
|
|
|
9
|
+
def lc_emoji_picker(form:, field:, label:, catalog_url:, value: nil, quick: [], recent: [], recent_scope: nil, auto_submit: false, **attributes)
|
|
10
|
+
lc_label!(label, :label)
|
|
11
|
+
lc_label!(catalog_url, :catalog_url)
|
|
12
|
+
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
|
|
13
|
+
end
|
|
14
|
+
|
|
9
15
|
SEARCH_NAMES_PATH = Lostcoords::Ui.root.join("config/emoji_search_names.yml")
|
|
10
16
|
SEARCH_NAMES_JA_PATH = Lostcoords::Ui.root.join("config/emoji_search_names_ja.yml")
|
|
11
17
|
SEARCH_ALIASES_PATH = Lostcoords::Ui.root.join("config/emoji_search_aliases.yml")
|
|
@@ -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-tile 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-tile 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-track 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
|
|
22
|
+
lc_tw("lc-glass", GLASS_SHAPES.fetch(lc_option!(:shape, shape, GLASS_SHAPES.keys)), (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,19 +15,19 @@ 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)
|
|
30
|
-
lc_glass_class(shape: :menu, extra: lc_tw(POPOVER_BASE, MENU_POPOVER_ALIGN.fetch(align), extra))
|
|
30
|
+
lc_glass_class(shape: :menu, extra: lc_tw(POPOVER_BASE, MENU_POPOVER_ALIGN.fetch(lc_option!(:align, align, MENU_POPOVER_ALIGN.keys)), extra))
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def lc_menu_popover_class(align: :end, extra: nil)
|
|
@@ -35,7 +35,42 @@ module Lostcoords::Ui
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def lc_menu_option_class(tone: :default, extra: nil)
|
|
38
|
-
lc_tw(MENU_OPTION_BASE, MENU_OPTION_TONES.fetch(tone), extra)
|
|
38
|
+
lc_tw(MENU_OPTION_BASE, MENU_OPTION_TONES.fetch(lc_option!(:tone, tone, MENU_OPTION_TONES.keys)), extra)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def lc_menu(id:, label: lc_t("lostcoords.ui.menu.label"), align: :end, **attributes, &block)
|
|
42
|
+
align = lc_option!(:align, align, MENU_POPOVER_ALIGN.keys)
|
|
43
|
+
lc_render_panel(id, label, nil, "lc-menu", "menu", lc_menu_popover_class(align: align), lc_attributes({ data: { slot: "menu" } }, attributes), &block)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def lc_popover(id:, label: lc_t("lostcoords.ui.popover.trigger"), title: nil, align: :end, trigger_variant: :secondary, **attributes, &block)
|
|
47
|
+
title = title.presence
|
|
48
|
+
align = lc_option!(:align, align, MENU_POPOVER_ALIGN.keys)
|
|
49
|
+
classes = lc_popover_class(align: align, extra: "data-open:flex flex-col gap-content p-inset z-(--lc-z-popover) min-w-64 max-w-[calc(100vw-2rem)]")
|
|
50
|
+
lc_render_panel(id, label, title, "lc-popover", "dialog", classes, lc_attributes({ data: { slot: "popover" } }, attributes), trigger_variant: trigger_variant, &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def lc_menu_option(label = nil, href: nil, tone: :default, **attributes, &block)
|
|
54
|
+
tone = lc_option!(:tone, tone, MENU_OPTION_TONES.keys)
|
|
55
|
+
content = tag.span(block ? capture(&block) : label, class: "col-span-3")
|
|
56
|
+
defaults = { class: lc_menu_option_class(tone: tone), role: "menuitem", data: { slot: "menu-item", action: "click->lc-menu#close" } }
|
|
57
|
+
if href
|
|
58
|
+
raise ArgumentError, "Links cannot be disabled; use a menu button instead" if attributes[:disabled] || attributes["disabled"]
|
|
59
|
+
|
|
60
|
+
tag.a(content, **lc_attributes(defaults.merge(href: href), attributes))
|
|
61
|
+
else
|
|
62
|
+
tag.button(content, **lc_attributes(defaults.merge(type: "button"), attributes))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
def lc_render_panel(id, label, title, controller, role, classes, attributes, trigger_variant: :secondary, &block)
|
|
68
|
+
lc_label!(id, :id)
|
|
69
|
+
lc_label!(label, :label)
|
|
70
|
+
required = { id: id, role: role, aria: { labelledby: "#{id}-#{title ? 'title' : 'trigger'}" }, data: { "#{controller}_target" => "popover" } }
|
|
71
|
+
required[:tabindex] = -1 if role == "dialog"
|
|
72
|
+
merged = lc_attributes({ class: classes }, attributes, required)
|
|
73
|
+
render "lostcoords/ui/components/lc_floating_panel", id: id, label: label, title: title, controller: controller, role: role, trigger_variant: trigger_variant, attributes: merged, content: (capture(&block) if block)
|
|
39
74
|
end
|
|
40
75
|
end
|
|
41
76
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
TABS_ORIENTATIONS = {
|
|
103
|
+
horizontal: { root: "flex min-w-0 flex-col gap-content", list: "max-w-full overflow-x-auto lc-scroll-fade-x" },
|
|
104
|
+
vertical: { root: "flex min-w-0 flex-row items-start gap-content", list: "shrink-0 flex-col items-stretch [&>[role=tab]]:justify-start" }
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
# Yields a builder; each `tabs.panel "Label" do … end` adds a tab and its panel.
|
|
108
|
+
# orientation: :vertical stacks the tabs beside the panels and moves them with Arrow Up and Down.
|
|
109
|
+
# activation: :manual moves focus with the arrows and selects with Enter or Space.
|
|
110
|
+
def lc_tabs(id:, label:, selected: nil, orientation: :horizontal, activation: :automatic, **attributes, &block)
|
|
111
|
+
lc_label!(id, :id)
|
|
112
|
+
lc_label!(label, :label)
|
|
113
|
+
orientation = lc_option!(:orientation, orientation, TABS_ORIENTATIONS.keys)
|
|
114
|
+
activation = lc_option!(:activation, activation, %i[automatic manual])
|
|
115
|
+
layout = TABS_ORIENTATIONS.fetch(orientation)
|
|
116
|
+
tabs = Parts::Tabs.new(self)
|
|
117
|
+
capture(tabs, &block) if block
|
|
118
|
+
raise ArgumentError, "lc_tabs needs at least one panel" if tabs.panels.empty?
|
|
119
|
+
|
|
120
|
+
selected = (selected || tabs.panels.first.value).to_s
|
|
121
|
+
raise ArgumentError, "selected tab #{selected.inspect} has no panel" unless tabs.panels.any? { |panel| panel.value == selected }
|
|
122
|
+
|
|
123
|
+
key = ->(panel) { "#{id}-#{panel.value.gsub(/[^\w-]+/, '-')}" }
|
|
124
|
+
ids = tabs.panels.map(&key)
|
|
125
|
+
raise ArgumentError, "tab values #{tabs.panels.map(&:value).inspect} produce duplicate ids" unless ids.uniq.size == ids.size
|
|
126
|
+
list = tag.div(role: "tablist", aria: { label: label, orientation: (orientation if orientation == :vertical) }, class: lc_toggle_group_class(extra: layout[:list]), data: { slot: "tabs-list" }) do
|
|
127
|
+
safe_join(tabs.panels.map do |panel|
|
|
128
|
+
active = panel.value == selected
|
|
129
|
+
tag.button(panel.label, type: "button", role: "tab", id: "#{key.(panel)}-tab", tabindex: (active ? 0 : -1),
|
|
130
|
+
aria: { selected: active, controls: "#{key.(panel)}-panel" }, class: lc_toggle_group_item_class,
|
|
131
|
+
data: { slot: "tabs-trigger", value: panel.value, lc_tabs_target: "tab", action: "click->lc-tabs#select keydown->lc-tabs#navigate" })
|
|
132
|
+
end)
|
|
133
|
+
end
|
|
134
|
+
panels = tabs.panels.map do |panel|
|
|
135
|
+
tag.div(panel.content, **lc_attributes({ role: "tabpanel", id: "#{key.(panel)}-panel", tabindex: 0, hidden: (panel.value != selected),
|
|
136
|
+
aria: { labelledby: "#{key.(panel)}-tab" }, class: lc_tw("min-w-0 rounded-lg outline-offset-4", ("flex-1" if orientation == :vertical)), data: { slot: "tabs-panel", lc_tabs_target: "panel" } }, panel.attributes))
|
|
137
|
+
end
|
|
138
|
+
data = { slot: "tabs", controller: "lc-tabs", lc_tabs_activation_value: (activation.to_s if activation == :manual) }.compact
|
|
139
|
+
tag.div(**lc_attributes({ id: id, class: layout[:root], data: data }, attributes)) do
|
|
140
|
+
safe_join([ list, *panels ])
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|