lostcoords-ui 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +352 -0
  4. data/app/assets/tailwind/lostcoords/ui/avatar.css +11 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +26 -0
  6. data/app/assets/tailwind/lostcoords/ui/copy-button.css +33 -0
  7. data/app/assets/tailwind/lostcoords/ui/dialog.css +60 -0
  8. data/app/assets/tailwind/lostcoords/ui/disclosure.css +43 -0
  9. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +462 -0
  10. data/app/assets/tailwind/lostcoords/ui/field.css +6 -0
  11. data/app/assets/tailwind/lostcoords/ui/glass.css +37 -0
  12. data/app/assets/tailwind/lostcoords/ui/layout.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/popover.css +75 -0
  14. data/app/assets/tailwind/lostcoords/ui/reading.css +60 -0
  15. data/app/assets/tailwind/lostcoords/ui/sheet.css +170 -0
  16. data/app/assets/tailwind/lostcoords/ui/squircle.css +32 -0
  17. data/app/assets/tailwind/lostcoords/ui/theme.css +255 -0
  18. data/app/assets/tailwind/lostcoords/ui/typography.css +81 -0
  19. data/app/assets/tailwind/lostcoords/ui/ui.css +21 -0
  20. data/app/assets/tailwind/lostcoords/ui/utilities.css +45 -0
  21. data/app/assets/tailwind/lostcoords_ui/engine.css +1 -0
  22. data/app/helpers/lostcoords/ui/badge_helper.rb +162 -0
  23. data/app/helpers/lostcoords/ui/button_helper.rb +70 -0
  24. data/app/helpers/lostcoords/ui/card_helper.rb +60 -0
  25. data/app/helpers/lostcoords/ui/copy_button_helper.rb +40 -0
  26. data/app/helpers/lostcoords/ui/dialog_helper.rb +33 -0
  27. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +245 -0
  28. data/app/helpers/lostcoords/ui/feedback_helper.rb +30 -0
  29. data/app/helpers/lostcoords/ui/form_helper.rb +93 -0
  30. data/app/helpers/lostcoords/ui/glass_helper.rb +25 -0
  31. data/app/helpers/lostcoords/ui/icon_helper.rb +15 -0
  32. data/app/helpers/lostcoords/ui/layout_helper.rb +60 -0
  33. data/app/helpers/lostcoords/ui/menu_helper.rb +41 -0
  34. data/app/helpers/lostcoords/ui/primitives_helper.rb +152 -0
  35. data/app/helpers/lostcoords/ui/reading_helper.rb +15 -0
  36. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +46 -0
  37. data/app/helpers/lostcoords/ui/sheet_helper.rb +54 -0
  38. data/app/helpers/lostcoords/ui/structures_helper.rb +159 -0
  39. data/app/helpers/lostcoords/ui/tailwind_helper.rb +14 -0
  40. data/app/helpers/lostcoords/ui/translation_helper.rb +13 -0
  41. data/app/helpers/lostcoords/ui/typography_helper.rb +28 -0
  42. data/app/javascript/lostcoords/ui/controllers/confirm_dialog_controller.js +39 -0
  43. data/app/javascript/lostcoords/ui/controllers/disclosure_controller.js +163 -0
  44. data/app/javascript/lostcoords/ui/controllers/emoji_picker_controller.js +366 -0
  45. data/app/javascript/lostcoords/ui/controllers/modal_controller.js +27 -0
  46. data/app/javascript/lostcoords/ui/controllers/row_menu_controller.js +83 -0
  47. data/app/javascript/lostcoords/ui/controllers/sheet_controller.js +422 -0
  48. data/app/javascript/lostcoords/ui/controllers/toast_controller.js +69 -0
  49. data/app/javascript/lostcoords/ui/controllers/ui_copy_controller.js +56 -0
  50. data/app/javascript/lostcoords/ui/controllers/ui_popover_controller.js +17 -0
  51. data/app/javascript/lostcoords/ui/controllers/ui_selection_controller.js +69 -0
  52. data/app/javascript/lostcoords/ui/controllers/ui_toggle_controller.js +13 -0
  53. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +455 -0
  54. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +29 -0
  55. data/app/javascript/lostcoords/ui/helpers/dialog.js +31 -0
  56. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +155 -0
  57. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +108 -0
  58. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +27 -0
  59. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +13 -0
  60. data/app/javascript/lostcoords/ui/helpers/top_layer.js +102 -0
  61. data/app/javascript/lostcoords/ui/helpers/viewport.js +13 -0
  62. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +176 -0
  63. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +29 -0
  64. data/app/views/lostcoords/ui/components/_form_alert.html.erb +4 -0
  65. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +13 -0
  66. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +5 -0
  67. data/app/views/lostcoords/ui/components/_skeleton.html.erb +4 -0
  68. data/app/views/lostcoords/ui/components/_spinner.html.erb +10 -0
  69. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +8 -0
  70. data/config/emoji_search_aliases.yml +146 -0
  71. data/config/emoji_search_names.yml +1917 -0
  72. data/config/emoji_search_names_ja.yml +6902 -0
  73. data/config/helper_renames.json +113 -0
  74. data/config/importmap.rb +3 -0
  75. data/config/locales/en.yml +33 -0
  76. data/config/locales/es.yml +33 -0
  77. data/config/locales/ja.yml +33 -0
  78. data/lib/lostcoords/ui/action_view.rb +10 -0
  79. data/lib/lostcoords/ui/builders.rb +42 -0
  80. data/lib/lostcoords/ui/components.rb +13 -0
  81. data/lib/lostcoords/ui/engine.rb +22 -0
  82. data/lib/lostcoords/ui/helpers.rb +11 -0
  83. data/lib/lostcoords/ui/icons.rb +24 -0
  84. data/lib/lostcoords/ui/rails.rb +3 -0
  85. data/lib/lostcoords/ui/resources.rb +17 -0
  86. data/lib/lostcoords/ui/tokens.rb +14 -0
  87. data/lib/lostcoords/ui/version.rb +5 -0
  88. data/lib/lostcoords/ui.rb +2 -0
  89. data/lib/tasks/lostcoords_ui.rake +15 -0
  90. data/script/migrate_helpers.rb +136 -0
  91. data/vendor/heroicons/LICENSE +21 -0
  92. data/vendor/heroicons/icons.json +1303 -0
  93. metadata +203 -0
@@ -0,0 +1,162 @@
1
+ module Lostcoords::Ui
2
+ module BadgeHelper
3
+ ICON_BADGE_BASE =
4
+ "icon-badge squircle grid place-items-center shrink-0 " \
5
+ "size-[var(--icon-badge-size,var(--icon-badge-size-default,--spacing(10)))] " \
6
+ "[border:var(--icon-badge-border,1px_solid_transparent)] " \
7
+ "rounded-[var(--icon-badge-radius,var(--radius-2xl))] " \
8
+ "bg-[var(--icon-badge-bg,var(--paper-2))] " \
9
+ "text-[length:var(--icon-badge-font-size,var(--icon-badge-font-size-default,1.125rem))] leading-none"
10
+
11
+ # Sizes set the default, so a context that pins --icon-badge-size still wins.
12
+ ICON_BADGE_SIZES = {
13
+ base: "",
14
+ lg: "[--icon-badge-size-default:--spacing(11)] [--icon-badge-font-size-default:1.25rem]",
15
+ xl: "[--icon-badge-size-default:--spacing(14)] [--icon-badge-font-size-default:1.5rem]"
16
+ }.freeze
17
+
18
+ ICON_MEDALLION_BASE = "grid place-items-center shrink-0 rounded-full"
19
+
20
+ ICON_MEDALLION_SIZES = {
21
+ sm: "size-6.5",
22
+ lg: "size-12"
23
+ }.freeze
24
+
25
+ ICON_MEDALLION_TONES = {
26
+ accent: "bg-sky-soft text-accent",
27
+ neutral: "bg-paper-2",
28
+ leaf: "bg-[color-mix(in_oklab,var(--leaf-soft)_72%,var(--paper))] text-leaf"
29
+ }.freeze
30
+
31
+ AVATAR_BASE =
32
+ "avatar grid place-items-center shrink-0 rounded-full font-medium tracking-[var(--avatar-tracking,0.05em)] " \
33
+ "size-[var(--avatar-size,var(--avatar-size-default,--spacing(8)))] " \
34
+ "text-[length:var(--avatar-font-size,var(--avatar-font-size-default,0.75rem))] " \
35
+ "text-[color:var(--avatar-color,var(--ink-on-accent))] " \
36
+ "border-[length:var(--avatar-border,0px)] border-[color:var(--avatar-cutout,var(--paper))]"
37
+
38
+ # Sizes set the default, so a context that pins --avatar-size (a stack, a dialog) still wins.
39
+ AVATAR_SIZES = {
40
+ base: "",
41
+ lg: "",
42
+ sm: "[--avatar-size-default:--spacing(6.5)] [--avatar-font-size-default:0.625rem]",
43
+ xl: "[--avatar-size-default:--spacing(24)] [--avatar-font-size-default:1.25rem]"
44
+ }.freeze
45
+
46
+ AVATAR_INK = "[--avatar-color:var(--ink)]"
47
+
48
+ AVATAR_LINKED = "avatar-ring"
49
+
50
+ # Overlapping avatars with a "+N" chip. The stack sets inherited variables rather than styling
51
+ # its avatars, so a context's own `[&_.avatar]:` override still wins. The border cuts each
52
+ # avatar out of the one beneath, so it takes the fill it sits on (--avatar-cutout).
53
+ AVATAR_STACK_BASE =
54
+ "avatar-stack relative inline-flex items-center min-w-0 [&_.avatar]:outline-none " \
55
+ "[--avatar-size:--spacing(8)] [--avatar-border:--spacing(0.5)] [--avatar-ring-gap:1] " \
56
+ "[--avatar-stack-overlap:--spacing(1.5)]"
57
+
58
+ AVATAR_STACK_COMPACT =
59
+ "[--avatar-size:--spacing(4.5)] [--avatar-font-size:0.4375rem] [--avatar-border:0px] [--avatar-ring-gap:2] " \
60
+ "@sm:[--avatar-size:--spacing(5.5)] @sm:[--avatar-font-size:0.5rem] " \
61
+ "[--avatar-stack-overlap:--spacing(1.25)] [--avatar-stack-padding:0px]"
62
+
63
+ # dynamic_avatar_stack_controller marks the leading visible avatar with data-first as it narrows the stack.
64
+ AVATAR_STACK_ROW =
65
+ "avatar-stack__row flex shrink-0 p-[var(--avatar-stack-padding,--spacing(1))] " \
66
+ "[&>.avatar]:-ms-(--avatar-stack-overlap) [&>.avatar:first-child]:ms-0 [&>.avatar[data-first]]:ms-0"
67
+
68
+ EYEBROW_BASE =
69
+ "font-mono uppercase tracking-widest text-xs font-medium text-ink-3"
70
+
71
+ BADGE_BASE =
72
+ "inline-flex items-center gap-1 rounded-xl px-2.5 py-1 " \
73
+ "font-body text-xs font-medium whitespace-nowrap"
74
+
75
+ BADGE_TONES = {
76
+ neutral: "bg-paper-2 text-ink-2",
77
+ success: "bg-leaf-soft text-leaf",
78
+ warning: "bg-gold-soft text-gold",
79
+ danger: "bg-berry-soft text-berry",
80
+ info: "bg-sky-soft text-sky-deep"
81
+ }.freeze
82
+
83
+ STATUS_PILL_BASE =
84
+ "inline-flex items-center rounded-full px-[0.6em] py-[0.15em] " \
85
+ "font-body text-sm font-medium"
86
+
87
+ STATUS_PILL_TONES = {
88
+ accent: "bg-sky-soft text-accent",
89
+ danger: "bg-berry-soft text-berry"
90
+ }.freeze
91
+
92
+ PILL_BASE =
93
+ "inline-flex items-center justify-center gap-2 rounded-full " \
94
+ "border border-transparent bg-paper-2 " \
95
+ "font-body text-ink-2 whitespace-nowrap cursor-pointer no-underline " \
96
+ "transition-[background-color,border-color,color,transform] duration-[150ms] ease-out " \
97
+ "hover:text-ink active:scale-[0.96] " \
98
+ "aria-pressed:[background:var(--sky-soft)] aria-pressed:border-accent aria-pressed:text-accent aria-pressed:shadow-none " \
99
+ "aria-[current]:not-aria-[current=false]:[background:var(--sky-soft)] aria-[current]:not-aria-[current=false]:border-accent " \
100
+ "aria-[current]:not-aria-[current=false]:text-accent aria-[current]:not-aria-[current=false]:shadow-none " \
101
+ "has-checked:[background:var(--sky-soft)] has-checked:border-accent has-checked:text-accent has-checked:shadow-none " \
102
+ "[&_svg]:size-[1.1em] [&_svg]:shrink-0"
103
+
104
+ PILL_ACTIVE = "bg-sky-soft border-accent text-accent hover:text-accent shadow-none"
105
+
106
+ PILL_GHOST = "border-dashed border-ink-3/40 [background:none] shadow-none text-ink-3"
107
+
108
+ PILL_ROW_BASE =
109
+ "flex flex-wrap items-center gap-y-2 gap-x-2"
110
+
111
+ def lc_icon_badge_class(size: :base, extra: nil)
112
+ lc_tw(ICON_BADGE_BASE, ICON_BADGE_SIZES.fetch(size.to_sym), extra)
113
+ end
114
+
115
+ def lc_icon_medallion_class(size: :sm, tone: :accent, extra: nil)
116
+ lc_tw(
117
+ ICON_MEDALLION_BASE,
118
+ ICON_MEDALLION_SIZES.fetch(size.to_sym, ICON_MEDALLION_SIZES[:sm]),
119
+ ICON_MEDALLION_TONES.fetch(tone.to_sym, ICON_MEDALLION_TONES[:accent]),
120
+ extra
121
+ )
122
+ end
123
+
124
+ def lc_avatar_class(size: :base, ink: false, linked: false, extra: nil)
125
+ lc_tw(AVATAR_BASE, AVATAR_SIZES.fetch(size.to_sym), (AVATAR_INK if ink), (AVATAR_LINKED if linked), extra)
126
+ end
127
+
128
+ def lc_avatar_stack_class(compact: false, extra: nil)
129
+ lc_tw(AVATAR_STACK_BASE, (AVATAR_STACK_COMPACT if compact), extra)
130
+ end
131
+
132
+ def lc_avatar_stack_row_class(extra: nil)
133
+ lc_tw(AVATAR_STACK_ROW, extra)
134
+ end
135
+
136
+ def lc_eyebrow_class(extra: nil)
137
+ lc_tw(EYEBROW_BASE, extra)
138
+ end
139
+
140
+ def lc_badge_class(tone: :neutral, extra: nil)
141
+ lc_tw(BADGE_BASE, BADGE_TONES.fetch(tone.to_sym, BADGE_TONES[:neutral]), extra)
142
+ end
143
+
144
+ def lc_status_pill_class(tone: :accent, extra: nil)
145
+ lc_tw(STATUS_PILL_BASE, STATUS_PILL_TONES.fetch(tone.to_sym, STATUS_PILL_TONES[:accent]), extra)
146
+ end
147
+
148
+ def lc_pill_class(active: false, ghost: false, size: :base, extra: nil)
149
+ lc_tw(
150
+ PILL_BASE,
151
+ ButtonHelper::BUTTON_SIZES.fetch(size.to_sym, ButtonHelper::BUTTON_SIZES[:base]),
152
+ (PILL_GHOST if ghost),
153
+ (PILL_ACTIVE if active),
154
+ extra
155
+ )
156
+ end
157
+
158
+ def lc_pill_row_class(extra: nil)
159
+ lc_tw(PILL_ROW_BASE, extra)
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,70 @@
1
+ module Lostcoords::Ui
2
+ module ButtonHelper
3
+ BUTTON_BASE =
4
+ "inline-flex items-center justify-center gap-2 rounded-full font-body font-medium " \
5
+ "whitespace-nowrap no-underline cursor-pointer " \
6
+ "transition-[background-color,border-color,color,box-shadow,transform,translate,scale,filter] duration-(--ui-motion-control) ease-out " \
7
+ "motion-safe:hover:-translate-y-px motion-safe:active:translate-y-px active:shadow-none disabled:opacity-40 disabled:pointer-events-none " \
8
+ "[&_svg]:block [&_svg:not([class*='size-'])]:size-[1.1em] [&_svg]:shrink-0"
9
+
10
+ BUTTON_SIZES = {
11
+ sm: "#{TailwindHelper::CONTROL_SIZES[:sm]} px-4 has-[>[data-icon=inline-start]]:ps-3 has-[>[data-icon=inline-end]]:pe-3 text-sm",
12
+ base: "#{TailwindHelper::CONTROL_SIZES[:base]} px-5 has-[>[data-icon=inline-start]]:ps-4 has-[>[data-icon=inline-end]]:pe-4 text-base",
13
+ lg: "#{TailwindHelper::CONTROL_SIZES[:lg]} px-7 has-[>[data-icon=inline-start]]:ps-6 has-[>[data-icon=inline-end]]:pe-6 text-base",
14
+ nav: "#{TailwindHelper::CONTROL_SIZES[:sm]} px-4 has-[>[data-icon=inline-start]]:ps-3 has-[>[data-icon=inline-end]]:pe-3 text-base"
15
+ }.freeze
16
+
17
+ BUTTON_VARIANTS = {
18
+ default: "border border-transparent bg-paper text-ink active:bg-paper-2",
19
+ accent: "border border-transparent bg-sky text-ink-on-accent hover:bg-sky-deep active:bg-sky-deep",
20
+ secondary: "border border-transparent bg-paper-2 text-ink hover:bg-paper-3 active:bg-paper-3",
21
+ glass: "pill-3d text-ink hover:brightness-[0.98] active:brightness-[0.96]",
22
+ ghost: "border border-transparent bg-transparent text-ink-2 hover:bg-(--ghost-hover-bg,var(--paper-2)) hover:text-ink active:bg-(--ghost-hover-bg,var(--paper-2))",
23
+ danger: "border border-transparent bg-transparent text-berry hover:bg-berry/8 active:bg-berry/8"
24
+ }.freeze
25
+
26
+ ICON_BUTTON_BASE =
27
+ "inline-grid place-items-center shrink-0 border-0 bg-transparent " \
28
+ "text-ink-3 cursor-pointer no-underline " \
29
+ "transition-[background-color,color,transform,scale] duration-(--ui-motion-quick) ease-out " \
30
+ "hover:bg-paper-2 hover:text-ink motion-safe:active:scale-(--ui-press-scale) " \
31
+ "disabled:opacity-40 disabled:pointer-events-none " \
32
+ "[&_svg]:block [&_svg]:shrink-0"
33
+
34
+ ICON_BUTTON_SHAPES = {
35
+ square: "rounded-xl",
36
+ circle: "rounded-full"
37
+ }.freeze
38
+
39
+ ICON_BUTTON_SIZES = {
40
+ sm: "size-11 [&_svg]:size-4",
41
+ base: "size-12 [&_svg]:size-5",
42
+ lg: "size-14 [&_svg]:size-6"
43
+ }.freeze
44
+
45
+ def lc_button_class(variant: :default, size: :base, block: false, extra: nil)
46
+ lc_tw(
47
+ BUTTON_BASE,
48
+ BUTTON_SIZES.fetch(size.to_sym, BUTTON_SIZES[:base]),
49
+ lc_button_variants.fetch(variant.to_sym, lc_button_variants[:default]),
50
+ ("w-full" if block),
51
+ extra
52
+ )
53
+ end
54
+
55
+ def lc_icon_button_class(shape: :square, size: :base, extra: nil)
56
+ lc_tw(
57
+ ICON_BUTTON_BASE,
58
+ ICON_BUTTON_SHAPES.fetch(shape.to_sym, ICON_BUTTON_SHAPES[:square]),
59
+ ICON_BUTTON_SIZES.fetch(size.to_sym, ICON_BUTTON_SIZES[:base]),
60
+ extra
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ def lc_button_variants
67
+ BUTTON_VARIANTS
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,60 @@
1
+ module Lostcoords::Ui
2
+ module CardHelper
3
+ CARD_BASE =
4
+ "squircle bg-paper-2 border border-transparent rounded-3xl"
5
+
6
+ CARD_PADDING = {
7
+ default: "p-5",
8
+ dense: "py-4 px-3.5 sm:p-5"
9
+ }.freeze
10
+
11
+ HOVER_LIFT =
12
+ "transition-[transform,box-shadow] duration-[180ms] ease-out " \
13
+ "hover:-translate-y-0.5 hover:shadow-(--shadow)"
14
+
15
+ CARD_SURFACE_CHROME =
16
+ "squircle bg-(--card-surface-bg,var(--paper-2)) " \
17
+ "[border:var(--card-surface-border,1px_solid_transparent)] " \
18
+ "rounded-(--card-surface-radius,var(--radius-3xl)) " \
19
+ "shadow-(--card-surface-shadow,none) [--field-bg:var(--paper)] " \
20
+ "[--avatar-cutout:var(--card-surface-bg,var(--paper-2))] " \
21
+ "transition-[background,border-color,box-shadow] duration-[180ms] ease-(--ease-out)"
22
+
23
+ FORM_CARD_BASE =
24
+ "form-card @container " \
25
+ "#{CARD_SURFACE_CHROME} " \
26
+ "has-[[data-tone=error]]:[border-color:color-mix(in_oklab,var(--berry)_30%,var(--hairline))]"
27
+
28
+ FORM_CARD_PADDINGS = {
29
+ default: "p-(--form-card-padding,1.25rem)",
30
+ compact: "p-(--form-card-padding,1rem_14px) sm:p-(--form-card-padding,20px)",
31
+ snug: "p-(--form-card-padding,1rem_14px) sm:p-(--form-card-padding,1rem_20px)",
32
+ empty: "p-(--form-card-padding,2rem_1.25rem)"
33
+ }.freeze
34
+
35
+ FORM_CARD_EMPTY_CONTENT = "text-center text-ink-3"
36
+
37
+ def lc_card_class(interactive: false, dense: false, extra: nil)
38
+ lc_tw(
39
+ CARD_BASE,
40
+ CARD_PADDING.fetch(dense ? :dense : :default),
41
+ (HOVER_LIFT if interactive),
42
+ ("hover:bg-paper-3" if interactive),
43
+ extra
44
+ )
45
+ end
46
+
47
+ def lc_card_surface_class(extra: nil)
48
+ lc_tw(CARD_SURFACE_CHROME, extra)
49
+ end
50
+
51
+ def lc_form_card_class(padding: :default, extra: nil)
52
+ lc_tw(
53
+ FORM_CARD_BASE,
54
+ FORM_CARD_PADDINGS.fetch(padding.to_sym, FORM_CARD_PADDINGS[:default]),
55
+ (FORM_CARD_EMPTY_CONTENT if padding.to_sym == :empty),
56
+ extra
57
+ )
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,40 @@
1
+ module Lostcoords::Ui
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: :base, **attributes)
4
+ raise ArgumentError, "Provide either value: or source:" unless value.nil? != source.nil?
5
+ raise ArgumentError, "source: must be a nonblank element ID" if !source.nil? && source.blank?
6
+ raise ArgumentError, "reset_after: must be a positive number of milliseconds" unless reset_after.is_a?(Numeric) && reset_after.finite? && reset_after.positive?
7
+
8
+ label ||= lc_t("lostcoords.ui.copy_button.label")
9
+ copied_label ||= lc_t("lostcoords.ui.copy_button.copied")
10
+ error_label ||= lc_t("lostcoords.ui.copy_button.retry")
11
+ size = lc_option!(:size, size, %i[sm base lg])
12
+ defaults = {
13
+ class: lc_tw("inline-grid", ({ sm: "size-11 p-0", base: "size-12 p-0", lg: "size-14 p-0" }.fetch(size) if icon_only)),
14
+ aria: { label: label }, data: { action: "ui-copy#copy", ui_copy_target: "button" }
15
+ }
16
+ button_attributes = lc_attributes(defaults, attributes).merge(type: "button")
17
+ raise ArgumentError, "A copy button requires a nonblank accessible label" if button_attributes.dig(:aria, :label).blank?
18
+
19
+ tag.span(class: "ui-copy", data: { controller: "ui-copy", state: "idle", ui_copy_value_value: value, ui_copy_source_value: source, ui_copy_reset_after_value: reset_after }) do
20
+ safe_join([
21
+ lc_button(variant: variant, size: size, **button_attributes) do
22
+ safe_join([
23
+ lc_copy_content(:idle, "document-duplicate", label, icon_only),
24
+ lc_copy_content(:copied, "check", copied_label, icon_only),
25
+ lc_copy_content(:error, "arrow-path", error_label, icon_only)
26
+ ])
27
+ end,
28
+ tag.span(class: "sr-only", role: "status", aria: { atomic: true }, data: { ui_copy_target: "status", copied: copied_label, error: lc_t("lostcoords.ui.copy_button.error") })
29
+ ])
30
+ end
31
+ end
32
+
33
+ private
34
+ def lc_copy_content(state, icon, label, icon_only)
35
+ tag.span(class: "ui-copy-content", aria: { hidden: true }, data: { copy_state: state, icon: ("inline-start" unless icon_only) }) do
36
+ safe_join([ lc_icon(icon, options: { "aria-hidden": true }), (tag.span(label) unless icon_only) ].compact)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ module Lostcoords::Ui
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
+ # Below sm these open as iOS sheets (sheet.css), which paint their own surface.
7
+ 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)]"
9
+
10
+ ANCHORED = "data-anchored:[inset:var(--dialog-top)_var(--dialog-right)_auto_auto] data-anchored:m-0"
11
+
12
+ DIALOGS = {
13
+ modal: "app-dialog #{SHEET_DIALOG_SURFACE} sm:w-[min(92vw,31rem)]",
14
+ 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}"
17
+ }.freeze
18
+
19
+ 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)
25
+ end
26
+
27
+ private
28
+
29
+ def lc_dialog_kinds
30
+ DIALOGS
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,245 @@
1
+ require "unicode/emoji"
2
+ require "yaml"
3
+ require_relative "translation_helper"
4
+
5
+ module Lostcoords::Ui
6
+ module EmojiPickerHelper
7
+ include TranslationHelper
8
+
9
+ SEARCH_NAMES_PATH = Lostcoords::Ui.root.join("config/emoji_search_names.yml")
10
+ SEARCH_NAMES_JA_PATH = Lostcoords::Ui.root.join("config/emoji_search_names_ja.yml")
11
+ SEARCH_ALIASES_PATH = Lostcoords::Ui.root.join("config/emoji_search_aliases.yml")
12
+
13
+ # Representative glyph for each category's tab in the picker navigation.
14
+ CATEGORY_ICONS = {
15
+ "smileys_emotion" => "😀",
16
+ "people_body" => "🧑",
17
+ "animals_nature" => "🐻",
18
+ "food_drink" => "🍔",
19
+ "travel_places" => "🚗",
20
+ "activities" => "⚽",
21
+ "objects" => "💡",
22
+ "symbols" => "🔣",
23
+ "flags" => "🏁"
24
+ }.freeze
25
+
26
+ KANA_DIGRAPHS = {
27
+ "きゃ" => "kya", "きゅ" => "kyu", "きょ" => "kyo",
28
+ "しゃ" => "sha", "しゅ" => "shu", "しょ" => "sho",
29
+ "ちゃ" => "cha", "ちゅ" => "chu", "ちょ" => "cho",
30
+ "にゃ" => "nya", "にゅ" => "nyu", "にょ" => "nyo",
31
+ "ひゃ" => "hya", "ひゅ" => "hyu", "ひょ" => "hyo",
32
+ "みゃ" => "mya", "みゅ" => "myu", "みょ" => "myo",
33
+ "りゃ" => "rya", "りゅ" => "ryu", "りょ" => "ryo",
34
+ "ぎゃ" => "gya", "ぎゅ" => "gyu", "ぎょ" => "gyo",
35
+ "じゃ" => "ja", "じゅ" => "ju", "じょ" => "jo",
36
+ "びゃ" => "bya", "びゅ" => "byu", "びょ" => "byo",
37
+ "ぴゃ" => "pya", "ぴゅ" => "pyu", "ぴょ" => "pyo",
38
+ "ふぁ" => "fa", "ふぃ" => "fi", "ふぇ" => "fe", "ふぉ" => "fo",
39
+ "てぃ" => "ti", "でぃ" => "di", "うぃ" => "wi", "うぇ" => "we", "うぉ" => "wo"
40
+ }.freeze
41
+ KANA_ROMAJI = {
42
+ "あ" => "a", "い" => "i", "う" => "u", "え" => "e", "お" => "o",
43
+ "か" => "ka", "き" => "ki", "く" => "ku", "け" => "ke", "こ" => "ko",
44
+ "さ" => "sa", "し" => "shi", "す" => "su", "せ" => "se", "そ" => "so",
45
+ "た" => "ta", "ち" => "chi", "つ" => "tsu", "て" => "te", "と" => "to",
46
+ "な" => "na", "に" => "ni", "ぬ" => "nu", "ね" => "ne", "の" => "no",
47
+ "は" => "ha", "ひ" => "hi", "ふ" => "fu", "へ" => "he", "ほ" => "ho",
48
+ "ま" => "ma", "み" => "mi", "む" => "mu", "め" => "me", "も" => "mo",
49
+ "や" => "ya", "ゆ" => "yu", "よ" => "yo",
50
+ "ら" => "ra", "り" => "ri", "る" => "ru", "れ" => "re", "ろ" => "ro",
51
+ "わ" => "wa", "を" => "wo", "ん" => "n",
52
+ "が" => "ga", "ぎ" => "gi", "ぐ" => "gu", "げ" => "ge", "ご" => "go",
53
+ "ざ" => "za", "じ" => "ji", "ず" => "zu", "ぜ" => "ze", "ぞ" => "zo",
54
+ "だ" => "da", "ぢ" => "ji", "づ" => "zu", "で" => "de", "ど" => "do",
55
+ "ば" => "ba", "び" => "bi", "ぶ" => "bu", "べ" => "be", "ぼ" => "bo",
56
+ "ぱ" => "pa", "ぴ" => "pi", "ぷ" => "pu", "ぺ" => "pe", "ぽ" => "po",
57
+ "ぁ" => "a", "ぃ" => "i", "ぅ" => "u", "ぇ" => "e", "ぉ" => "o",
58
+ "ゃ" => "ya", "ゅ" => "yu", "ょ" => "yo", "ゎ" => "wa"
59
+ }.freeze
60
+ CATEGORY_KEYS = {
61
+ "Smileys & Emotion" => "smileys_emotion",
62
+ "People & Body" => "people_body",
63
+ "Animals & Nature" => "animals_nature",
64
+ "Food & Drink" => "food_drink",
65
+ "Travel & Places" => "travel_places",
66
+ "Activities" => "activities",
67
+ "Objects" => "objects",
68
+ "Symbols" => "symbols",
69
+ "Flags" => "flags"
70
+ }.freeze
71
+
72
+ def lc_emoji_picker_groups
73
+ Lostcoords::Ui::EmojiPickerHelper.groups.map do |group|
74
+ group.merge(name: lc_t("lostcoords.ui.emoji_picker.categories.#{group[:key]}", default: group[:name]))
75
+ end
76
+ end
77
+
78
+ def lc_emoji_picker_count
79
+ lc_emoji_picker_groups.sum { |group| group[:emojis].size }
80
+ end
81
+
82
+ def lc_emoji_picker_version
83
+ "#{Unicode::Emoji::EMOJI_VERSION}-#{I18n.locale}-search-v3"
84
+ end
85
+
86
+ def lc_emoji_picker_search_text(emoji)
87
+ Lostcoords::Ui::EmojiPickerHelper.search_text_for(emoji)
88
+ end
89
+
90
+ def lc_emoji_picker_search_name(emoji)
91
+ Lostcoords::Ui::EmojiPickerHelper.search_name_for(emoji, locale: I18n.locale)
92
+ end
93
+
94
+ def lc_emoji_picker_search_labels(emoji)
95
+ Lostcoords::Ui::EmojiPickerHelper.search_labels_for(emoji).join("|")
96
+ end
97
+
98
+ def lc_emoji_picker_toneable?(emoji)
99
+ Lostcoords::Ui::EmojiPickerHelper.toneable?(emoji)
100
+ end
101
+
102
+ def self.groups
103
+ @groups ||= Unicode::Emoji::LIST.filter_map do |name, subgroups|
104
+ emojis = subgroups.values.flatten.uniq.reject { |emoji| skin_tone_variant?(emoji) }
105
+ next if emojis.empty?
106
+
107
+ key = category_key(name)
108
+ { key: key, name:, icon: CATEGORY_ICONS[key], emojis: emojis.freeze }
109
+ end.freeze
110
+ end
111
+
112
+ def self.search_text_for(emoji)
113
+ search_index.fetch(emoji) { emoji }
114
+ end
115
+
116
+ # Primary, human-facing name. Prefers the reader's language so the preview
117
+ # and accessible label read naturally; falls back across languages.
118
+ def self.search_name_for(emoji, locale: :en)
119
+ if locale.to_s.start_with?("ja")
120
+ japanese_search_names[emoji]&.first || search_names[emoji] || emoji
121
+ else
122
+ search_names[emoji] || japanese_search_names[emoji]&.first || emoji
123
+ end
124
+ end
125
+
126
+ def self.search_labels_for(emoji)
127
+ [
128
+ search_names[emoji],
129
+ *Array(japanese_search_names[emoji]),
130
+ *Array(curated_aliases[emoji])
131
+ ].compact_blank.uniq
132
+ end
133
+
134
+ def self.search_index
135
+ @search_index ||= begin
136
+ index = Hash.new { |hash, key| hash[key] = [] }
137
+
138
+ Unicode::Emoji::LIST.each do |name, subgroups|
139
+ category_key = category_key(name)
140
+ category_terms = [ name, category_key.tr("_", " ") ]
141
+
142
+ subgroups.each do |subgroup, emojis|
143
+ subgroup_terms = [ subgroup, subgroup.tr("_-", " ") ]
144
+
145
+ emojis.each do |emoji|
146
+ next if skin_tone_variant?(emoji)
147
+
148
+ labels = search_labels_for(emoji)
149
+ index[emoji].concat([
150
+ emoji,
151
+ *labels,
152
+ *romaji_terms(labels),
153
+ *category_terms,
154
+ *subgroup_terms
155
+ ])
156
+ end
157
+ end
158
+ end
159
+
160
+ index.transform_values { |terms| terms.compact_blank.uniq.join(" ").downcase }.freeze
161
+ end
162
+ end
163
+
164
+ def self.search_names
165
+ @search_names ||= YAML.load_file(SEARCH_NAMES_PATH).freeze
166
+ end
167
+
168
+ def self.japanese_search_names
169
+ @japanese_search_names ||= YAML.load_file(SEARCH_NAMES_JA_PATH).freeze
170
+ end
171
+
172
+ def self.curated_aliases
173
+ @curated_aliases ||= YAML.load_file(SEARCH_ALIASES_PATH).transform_values { |terms| Array(terms) }.freeze
174
+ end
175
+
176
+ # Romaji equivalents for any kana-bearing terms, so a Japanese name can be
177
+ # found by typing on a Latin keyboard (e.g. "neko" finds 🐱).
178
+ def self.romaji_terms(terms)
179
+ Array(terms).filter_map do |term|
180
+ next unless contains_kana?(term)
181
+
182
+ kana_to_romaji(term)
183
+ end.compact_blank
184
+ end
185
+
186
+ def self.contains_kana?(term)
187
+ term.to_s.match?(/[぀-ヿ]/)
188
+ end
189
+
190
+ def self.kana_to_romaji(term)
191
+ kana = term.to_s.each_char.map do |char|
192
+ codepoint = char.ord
193
+ codepoint.between?(0x30A1, 0x30F6) ? (codepoint - 0x60).chr(Encoding::UTF_8) : char
194
+ end.join
195
+
196
+ result = +""
197
+ index = 0
198
+ while index < kana.length
199
+ char = kana[index]
200
+ pair = kana[index, 2]
201
+
202
+ if char == "っ"
203
+ next_pair = KANA_DIGRAPHS[kana[index + 1, 2]]
204
+ next_char = next_pair || KANA_ROMAJI[kana[index + 1]]
205
+ result << next_char[0] if next_char.present?
206
+ index += 1
207
+ elsif char == "ー"
208
+ index += 1
209
+ elsif KANA_DIGRAPHS[pair]
210
+ result << KANA_DIGRAPHS[pair]
211
+ index += 2
212
+ elsif KANA_ROMAJI[char]
213
+ result << KANA_ROMAJI[char]
214
+ index += 1
215
+ else
216
+ result << char
217
+ index += 1
218
+ end
219
+ end
220
+
221
+ result
222
+ end
223
+
224
+ def self.skin_tone_variant?(emoji)
225
+ (emoji.codepoints & Unicode::Emoji::EMOJI_MODIFIERS).any?
226
+ end
227
+
228
+ # A "simple" modifier base: one base codepoint (optionally with a variation
229
+ # selector) drawn from Unicode's modifier-base set. We deliberately skip ZWJ
230
+ # sequences (families, couples) where applying a single skin tone is wrong.
231
+ def self.toneable?(emoji)
232
+ @toneable ||= {}
233
+ @toneable.fetch(emoji) do
234
+ @toneable[emoji] = begin
235
+ codepoints = emoji.codepoints.reject { |cp| cp == 0xFE0F }
236
+ codepoints.length == 1 && Unicode::Emoji::EMOJI_MODIFIER_BASES.include?(codepoints.first)
237
+ end
238
+ end
239
+ end
240
+
241
+ def self.category_key(name)
242
+ CATEGORY_KEYS.fetch(name) { name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "") }
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,30 @@
1
+ module Lostcoords::Ui
2
+ module FeedbackHelper
3
+ SPINNER_BASE =
4
+ "inline-block rounded-full border-current border-t-transparent animate-spin"
5
+
6
+ SPINNER_SIZES = {
7
+ sm: "size-4 border-2",
8
+ base: "size-6 border-2",
9
+ lg: "size-8 border-[3px]"
10
+ }.freeze
11
+
12
+ SKELETON_BASE = "block rounded-xl bg-paper-2 animate-pulse"
13
+
14
+ EMPTY_STATE_BASE =
15
+ "flex flex-col items-center text-center " \
16
+ "py-6 px-4 sm:py-12 sm:px-6"
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)
20
+ end
21
+
22
+ def lc_skeleton_class(extra: nil)
23
+ lc_tw(SKELETON_BASE, extra)
24
+ end
25
+
26
+ def lc_empty_state_class(extra: nil)
27
+ lc_tw(EMPTY_STATE_BASE, extra)
28
+ end
29
+ end
30
+ end