lostcoords-ui 0.5.0 → 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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +102 -48
  3. data/app/assets/tailwind/lostcoords/ui/card.css +5 -2
  4. data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +1 -1
  5. data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
  6. data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
  7. data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
  8. data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
  9. data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
  10. data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
  11. data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
  12. data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
  14. data/app/assets/tailwind/lostcoords/ui/components.css +12 -25
  15. data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
  16. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +45 -47
  17. data/app/assets/tailwind/lostcoords/ui/{reading.css → prose.css} +6 -0
  18. data/app/assets/tailwind/lostcoords/ui/sheet.css +3 -4
  19. data/app/assets/tailwind/lostcoords/ui/theme.css +16 -1
  20. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  21. data/app/assets/tailwind/lostcoords/ui/tooltip.css +12 -11
  22. data/app/assets/tailwind/lostcoords/ui/typography.css +1 -7
  23. data/app/helpers/lostcoords/ui/badge_helper.rb +2 -2
  24. data/app/helpers/lostcoords/ui/button_helper.rb +12 -6
  25. data/app/helpers/lostcoords/ui/choice_helper.rb +11 -7
  26. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  27. data/app/helpers/lostcoords/ui/dialog_helper.rb +10 -5
  28. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  29. data/app/helpers/lostcoords/ui/feedback_helper.rb +2 -2
  30. data/app/helpers/lostcoords/ui/form_helper.rb +1 -1
  31. data/app/helpers/lostcoords/ui/glass_helper.rb +1 -1
  32. data/app/helpers/lostcoords/ui/menu_helper.rb +37 -2
  33. data/app/helpers/lostcoords/ui/navigation_helper.rb +17 -4
  34. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  35. data/app/helpers/lostcoords/ui/sheet_helper.rb +4 -0
  36. data/app/helpers/lostcoords/ui/structures_helper.rb +2 -92
  37. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  38. data/app/helpers/lostcoords/ui/{overlay_helper.rb → tooltip_helper.rb} +3 -3
  39. data/app/helpers/lostcoords/ui/typography_helper.rb +30 -0
  40. data/app/javascript/lostcoords/ui/controllers/lc_collapsible_controller.js +1 -1
  41. data/app/javascript/lostcoords/ui/controllers/lc_emoji_picker_controller.js +34 -6
  42. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  43. data/app/javascript/lostcoords/ui/controllers/lc_menu_controller.js +23 -1
  44. data/app/javascript/lostcoords/ui/controllers/lc_selection_controller.js +6 -14
  45. data/app/javascript/lostcoords/ui/controllers/lc_sheet_controller.js +2 -2
  46. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  47. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +18 -15
  48. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  49. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +46 -6
  50. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +72 -20
  51. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +23 -0
  52. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +6 -7
  53. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  54. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  55. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  56. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  57. data/app/javascript/lostcoords/ui/index.js +6 -0
  58. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +27 -27
  59. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  60. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +1 -1
  61. data/lib/lostcoords/ui/action_view.rb +1 -0
  62. data/lib/lostcoords/ui/builders.rb +2 -2
  63. data/lib/lostcoords/ui/form_builder.rb +23 -10
  64. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  65. data/lib/lostcoords/ui/version.rb +1 -1
  66. data/lib/tasks/lostcoords_ui.rake +11 -0
  67. metadata +23 -6
  68. data/config/helper_renames.json +0 -111
  69. data/script/migrate_helpers.rb +0 -136
@@ -1,18 +1,16 @@
1
1
  @layer components {
2
- .emoji-picker {
2
+ .lc-emoji-picker {
3
3
  display: flex;
4
4
  gap: 6px;
5
5
  flex-wrap: wrap;
6
6
  align-items: center;
7
7
 
8
- /* Sit above the mobile app nav (z-100 + 25) so the sheet covers it. */
9
- --lc-emoji-sheet-z: calc(100 + 30);
10
8
  --lc-emoji-cell: 2.25rem;
11
9
  }
12
10
 
13
11
  /* ---- Trigger + quick choices -------------------------------------- */
14
- .emoji-picker__choice,
15
- .emoji-picker__trigger {
12
+ .lc-emoji-picker__choice,
13
+ .lc-emoji-picker__trigger {
16
14
  display: inline-grid;
17
15
  place-items: center;
18
16
  inline-size: 44px;
@@ -38,12 +36,12 @@
38
36
  }
39
37
  }
40
38
 
41
- .emoji-picker__trigger {
39
+ .lc-emoji-picker__trigger {
42
40
  color: var(--muted-foreground);
43
41
  font-weight: var(--font-weight-normal, 400);
44
42
  }
45
43
 
46
- .emoji-picker__more {
44
+ .lc-emoji-picker__more {
47
45
  position: relative;
48
46
  display: inline-flex;
49
47
  }
@@ -58,7 +56,7 @@
58
56
  dims everything beneath it — including the app nav. The allow-discrete
59
57
  transitions keep it in the top layer while it eases out, so it fades
60
58
  both ways instead of snapping away on close. */
61
- .emoji-picker__popover::backdrop {
59
+ .lc-emoji-picker__popover::backdrop {
62
60
  background: var(--lc-scrim);
63
61
  opacity: 0;
64
62
  transition:
@@ -72,7 +70,7 @@
72
70
  /* Only the phone sheet dims the page; the desktop popover rides the top
73
71
  layer too (so nothing paints over it) but floats over an undimmed page. */
74
72
  @media (width < 40rem) {
75
- .emoji-picker__popover:popover-open::backdrop {
73
+ .lc-emoji-picker__popover:popover-open::backdrop {
76
74
  opacity: 1;
77
75
 
78
76
  @starting-style { opacity: 0; }
@@ -82,7 +80,7 @@
82
80
  /* Phones get a solid sheet; on desktop the host paints the popover as a
83
81
  floating surface (glass in wariwari), so the gem leaves it unpainted. */
84
82
  @media (width < 40rem) {
85
- .emoji-picker__popover {
83
+ .lc-emoji-picker__popover {
86
84
  border: 1px solid var(--lc-glass-edge, var(--border));
87
85
  border-block-end: 0;
88
86
  background: var(--background);
@@ -90,11 +88,11 @@
90
88
  }
91
89
  }
92
90
 
93
- .emoji-picker__popover {
91
+ .lc-emoji-picker__popover {
94
92
  position: fixed;
95
93
  inset: auto 0 0;
96
94
  margin: 0;
97
- z-index: var(--lc-emoji-sheet-z);
95
+ z-index: var(--lc-z-overlay);
98
96
  display: none;
99
97
  max-inline-size: none;
100
98
  color: var(--foreground);
@@ -125,7 +123,7 @@
125
123
 
126
124
  /* Fallback for browsers without the Popover API: class-driven display. */
127
125
  @supports not selector(:popover-open) {
128
- .emoji-picker__popover[data-open] {
126
+ .lc-emoji-picker__popover[data-open] {
129
127
  display: flex;
130
128
  transform: translateY(0);
131
129
  }
@@ -135,7 +133,7 @@
135
133
  is large; the host's sheet draws the pill.
136
134
  touch-action: none lets a vertical drag pull the sheet down rather than
137
135
  scroll. Drag-to-dismiss is wired by the sheet controller. */
138
- .emoji-picker__handle {
136
+ .lc-emoji-picker__handle {
139
137
  flex: 0 0 auto;
140
138
  display: grid;
141
139
  place-items: center;
@@ -145,10 +143,10 @@
145
143
  cursor: grab;
146
144
  }
147
145
 
148
- .emoji-picker__handle:active { cursor: grabbing; }
146
+ .lc-emoji-picker__handle:active { cursor: grabbing; }
149
147
 
150
148
  /* ---- Search row --------------------------------------------------- */
151
- .emoji-picker__search {
149
+ .lc-emoji-picker__search {
152
150
  flex: 0 0 auto;
153
151
  display: flex;
154
152
  gap: 0.5rem;
@@ -156,14 +154,14 @@
156
154
  padding: 0.75rem 0.75rem 0.5rem;
157
155
  }
158
156
 
159
- .emoji-picker__search-field {
157
+ .lc-emoji-picker__search-field {
160
158
  position: relative;
161
159
  flex: 1 1 auto;
162
160
  display: flex;
163
161
  align-items: center;
164
162
  }
165
163
 
166
- .emoji-picker__search-icon {
164
+ .lc-emoji-picker__search-icon {
167
165
  position: absolute;
168
166
  inset-inline-start: 14px;
169
167
  inline-size: 18px;
@@ -172,7 +170,7 @@
172
170
  pointer-events: none;
173
171
  }
174
172
 
175
- .emoji-picker__search-input {
173
+ .lc-emoji-picker__search-input {
176
174
  inline-size: 100%;
177
175
  min-block-size: 44px;
178
176
  padding-inline: 40px 1rem;
@@ -198,12 +196,12 @@
198
196
  }
199
197
 
200
198
  /* ---- Skin-tone control ------------------------------------------- */
201
- .emoji-picker__tones {
199
+ .lc-emoji-picker__tones {
202
200
  position: relative;
203
201
  flex: 0 0 auto;
204
202
  }
205
203
 
206
- .emoji-picker__tone-toggle {
204
+ .lc-emoji-picker__tone-toggle {
207
205
  display: inline-grid;
208
206
  place-items: center;
209
207
  inline-size: 44px;
@@ -221,7 +219,7 @@
221
219
  &[aria-expanded="true"] { background: color-mix(in oklab, var(--primary) 12%, var(--background)); }
222
220
  }
223
221
 
224
- .emoji-picker__tone-menu {
222
+ .lc-emoji-picker__tone-menu {
225
223
  position: absolute;
226
224
  inset-block-end: calc(100% + 6px);
227
225
  inset-inline-end: 0;
@@ -234,7 +232,7 @@
234
232
  &[hidden] { display: none; }
235
233
  }
236
234
 
237
- .emoji-picker__tone {
235
+ .lc-emoji-picker__tone {
238
236
  display: inline-grid;
239
237
  place-items: center;
240
238
  inline-size: 36px;
@@ -255,7 +253,7 @@
255
253
 
256
254
  /* Category tabs: a segmented track, like the app's tab strips — a paper-2
257
255
  track with the active tab raised on a paper thumb. */
258
- .emoji-picker__tabs {
256
+ .lc-emoji-picker__tabs {
259
257
  flex: 0 0 auto;
260
258
  display: flex;
261
259
  gap: 2px;
@@ -269,7 +267,7 @@
269
267
  &::-webkit-scrollbar { display: none; }
270
268
  }
271
269
 
272
- .emoji-picker__tab {
270
+ .lc-emoji-picker__tab {
273
271
  flex: 0 0 auto;
274
272
  display: inline-grid;
275
273
  place-items: center;
@@ -294,7 +292,7 @@
294
292
  }
295
293
 
296
294
  /* ---- Scrollable body --------------------------------------------- */
297
- .emoji-picker__body {
295
+ .lc-emoji-picker__body {
298
296
  position: relative;
299
297
  flex: 1 1 auto;
300
298
  overflow-y: auto;
@@ -309,15 +307,15 @@
309
307
  @media (prefers-reduced-motion: reduce) { scroll-behavior: auto; }
310
308
  }
311
309
 
312
- .emoji-picker__category + .emoji-picker__category,
313
- .emoji-picker__recent + .emoji-picker__catalog,
314
- .emoji-picker__results + * {
310
+ .lc-emoji-picker__category + .lc-emoji-picker__category,
311
+ .lc-emoji-picker__recent + .lc-emoji-picker__catalog,
312
+ .lc-emoji-picker__results + * {
315
313
  margin-block-start: 0.5rem;
316
314
  }
317
315
 
318
316
  /* Heads scroll with their section: the tab track already marks the current
319
317
  category, and a sticky band would sit as a stripe across the panel. */
320
- .emoji-picker__category-head {
318
+ .lc-emoji-picker__category-head {
321
319
  display: flex;
322
320
  align-items: baseline;
323
321
  justify-content: space-between;
@@ -338,7 +336,7 @@
338
336
  }
339
337
  }
340
338
 
341
- .emoji-picker__grid {
339
+ .lc-emoji-picker__grid {
342
340
  display: grid;
343
341
  grid-template-columns: repeat(auto-fill, minmax(var(--lc-emoji-cell), 1fr));
344
342
  gap: 2px;
@@ -370,13 +368,13 @@
370
368
  }
371
369
 
372
370
  /* Section that owns the sticky header needs the scroll anchor, not the grid */
373
- .emoji-picker__category,
374
- .emoji-picker__recent,
375
- .emoji-picker__results {
371
+ .lc-emoji-picker__category,
372
+ .lc-emoji-picker__recent,
373
+ .lc-emoji-picker__results {
376
374
  scroll-margin-block-start: 0;
377
375
  }
378
376
 
379
- .emoji-picker__empty {
377
+ .lc-emoji-picker__empty {
380
378
  margin: 0;
381
379
  padding: 1rem 0.5rem;
382
380
  color: var(--muted-foreground);
@@ -385,16 +383,16 @@
385
383
  }
386
384
 
387
385
  /* ---- Search mode: swap catalog for a single ranked result list ---- */
388
- .emoji-picker__popover:not([data-searching]) .emoji-picker__results { display: none; }
386
+ .lc-emoji-picker__popover:not([data-searching]) .lc-emoji-picker__results { display: none; }
389
387
 
390
- .emoji-picker__popover[data-searching] {
391
- .emoji-picker__tabs,
392
- .emoji-picker__recent,
393
- .emoji-picker__catalog { display: none; }
388
+ .lc-emoji-picker__popover[data-searching] {
389
+ .lc-emoji-picker__tabs,
390
+ .lc-emoji-picker__recent,
391
+ .lc-emoji-picker__catalog { display: none; }
394
392
  }
395
393
 
396
394
  /* ---- Preview footer ---------------------------------------------- */
397
- .emoji-picker__preview {
395
+ .lc-emoji-picker__preview {
398
396
  flex: 0 0 auto;
399
397
  display: flex;
400
398
  gap: 0.5rem;
@@ -403,12 +401,12 @@
403
401
  padding: 0.25rem 1rem 0.75rem;
404
402
  }
405
403
 
406
- .emoji-picker__preview-emoji {
404
+ .lc-emoji-picker__preview-emoji {
407
405
  font-size: 1.5rem;
408
406
  line-height: 1;
409
407
  }
410
408
 
411
- .emoji-picker__preview-name {
409
+ .lc-emoji-picker__preview-name {
412
410
  overflow: hidden;
413
411
  font-size: 1rem;
414
412
  color: var(--muted-foreground);
@@ -422,7 +420,7 @@
422
420
 
423
421
  /* ==================================================================== */
424
422
  @media (width >= 40rem) {
425
- .emoji-picker__popover {
423
+ .lc-emoji-picker__popover {
426
424
  position: absolute;
427
425
  inset-block: calc(100% + 8px) auto;
428
426
  inset-inline: 0 auto;
@@ -452,10 +450,10 @@
452
450
  @media (prefers-reduced-motion: reduce) { transition: none; }
453
451
  }
454
452
 
455
- .emoji-picker__handle { display: none; }
453
+ .lc-emoji-picker__handle { display: none; }
456
454
 
457
- main:has(.emoji-picker__popover[data-open]),
458
- .emoji-picker:has(.emoji-picker__popover[data-open]) {
455
+ main:has(.lc-emoji-picker__popover[data-open]),
456
+ .lc-emoji-picker:has(.lc-emoji-picker__popover[data-open]) {
459
457
  z-index: var(--lc-z-overlay);
460
458
  }
461
459
  }
@@ -58,3 +58,9 @@
58
58
  }
59
59
  }
60
60
  }
61
+
62
+ @layer utilities {
63
+ .lc-prose :where(code):not(:where(pre code)) {
64
+ @apply lc-inline-code;
65
+ }
66
+ }
@@ -4,13 +4,12 @@
4
4
  position: fixed;
5
5
 
6
6
  /* Anchored from the top edge at the screen height it opened at
7
- (sheet_controller), then lifted by its own height. In-app browsers
7
+ (lc-sheet), then lifted by its own height. In-app browsers
8
8
  (Brave, Chrome) resize the page under the keyboard; anything hung
9
9
  from the bottom edge jumps a frame as they do, but the top holds. */
10
10
  inset: var(--lc-sheet-floor, 100dvh) 0 auto;
11
11
  translate: 0 -100%;
12
12
  margin: 0;
13
- z-index: var(--lc-z-sheet);
14
13
  inline-size: 100%;
15
14
  max-block-size: calc(var(--lc-sheet-floor, 100dvh) * 0.9);
16
15
  overflow-y: auto;
@@ -74,7 +73,7 @@
74
73
  }
75
74
 
76
75
  @layer modules {
77
- /* Phones: every modal dialog is an iOS sheet (sheet_controller). It floats
76
+ /* Phones: every modal dialog is an iOS sheet (lc-sheet). It floats
78
77
  inset with all corners round while it is short, and docks to the screen
79
78
  edges as it reaches the large detent: --lc-sheet-p runs 0 → 1 between them.
80
79
  Lives in this layer so it out-ranks a dialog's own frame. */
@@ -151,7 +150,7 @@
151
150
  }
152
151
 
153
152
  /* Behind a large sheet the page shrinks back and rounds off, over black,
154
- as UIKit's page sheet does; sheet_controller drives the variables. */
153
+ as UIKit's page sheet does; lc-sheet drives the variables. */
155
154
  html[data-lc-sheet-page] {
156
155
  background: #000;
157
156
 
@@ -15,6 +15,11 @@
15
15
  --color-secondary-foreground: var(--secondary-foreground);
16
16
  --color-muted: var(--muted);
17
17
  --color-muted-foreground: var(--muted-foreground);
18
+
19
+ /* Muted fills that a card switches to stay visible on it (card.css): bg-tile for choice tiles,
20
+ alerts, neutral badges, skeletons and key hints; bg-track for toggle group tracks. */
21
+ --color-tile: var(--lc-tile-bg, var(--muted));
22
+ --color-track: var(--lc-track-bg, var(--muted));
18
23
  --color-accent: var(--accent);
19
24
  --color-accent-foreground: var(--accent-foreground);
20
25
  --color-destructive: var(--destructive);
@@ -56,6 +61,11 @@
56
61
  --spacing-control-sm: var(--lc-control-sm);
57
62
  --spacing-control-md: var(--lc-control-md);
58
63
  --spacing-control-lg: var(--lc-control-lg);
64
+
65
+ /* In-page layers: z-raised lifts content over its neighbours, z-sticky keeps
66
+ app chrome above content. Overlays need no z-index; see --lc-z-popover. */
67
+ --z-index-raised: var(--lc-z-raised);
68
+ --z-index-sticky: var(--lc-z-sticky);
59
69
  }
60
70
 
61
71
  @theme {
@@ -116,8 +126,13 @@
116
126
  --lc-sheet-duration: 500ms;
117
127
  --lc-sheet-offscreen: calc(100% + 2rem);
118
128
 
129
+ --lc-z-raised: 1;
130
+ --lc-z-sticky: 20;
131
+
132
+ /* Dialogs, sheets, menus, popovers, tooltips and toasts open in the
133
+ browser's top layer, above every z-index. These order them only where
134
+ the Popover API is missing. */
119
135
  --lc-z-popover: 30;
120
- --lc-z-sheet: 50;
121
136
  --lc-z-overlay: 60;
122
137
  --lc-z-toast: 70;
123
138
 
@@ -0,0 +1,23 @@
1
+ @layer components {
2
+ /* lc-toast-region opens it as a popover; these rules also override the
3
+ popover defaults, which would centre it and hide it while closed. */
4
+ .lc-toast-region {
5
+ position: fixed;
6
+ inset: auto var(--spacing-gutter) var(--spacing-gutter) auto;
7
+ z-index: var(--lc-z-toast);
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: flex-end;
11
+ gap: var(--spacing-related);
12
+ max-inline-size: calc(100vw - var(--spacing-gutter) * 2);
13
+ margin: 0;
14
+ padding: 0;
15
+ overflow: visible;
16
+ border: 0;
17
+ background: none;
18
+ color: inherit;
19
+
20
+ &:dir(rtl) { inset: auto auto var(--spacing-gutter) var(--spacing-gutter); }
21
+ &:empty { display: none; }
22
+ }
23
+ }
@@ -1,4 +1,6 @@
1
1
  @layer components {
2
+ /* lc-tooltip lifts the content into the top layer while it shows. Without
3
+ JavaScript or the Popover API it shows in place, over its neighbours. */
2
4
  .lc-tooltip__content {
3
5
  position: absolute;
4
6
  inset-block-end: calc(100% + var(--spacing-related));
@@ -6,7 +8,9 @@
6
8
  z-index: var(--lc-z-popover);
7
9
  inline-size: max-content;
8
10
  max-inline-size: min(16rem, calc(100vw - 2rem));
11
+ overflow: visible;
9
12
  padding: var(--spacing-tight) var(--spacing-related);
13
+ border: 0;
10
14
  border-radius: var(--radius-md);
11
15
  background: var(--foreground);
12
16
  color: var(--background);
@@ -17,31 +21,28 @@
17
21
  pointer-events: none;
18
22
  opacity: 0;
19
23
  visibility: hidden;
20
- translate: -50% 0.125rem;
24
+ translate: -50% 0;
25
+
26
+ /* allow-discrete keeps a lifted tooltip in the top layer while it fades out. */
21
27
  transition:
22
28
  opacity 120ms var(--lc-ease-out),
23
- translate 120ms var(--lc-ease-out),
24
- visibility 120ms;
29
+ visibility 120ms,
30
+ display 120ms allow-discrete,
31
+ overlay 120ms allow-discrete;
25
32
 
26
- &:dir(rtl) { translate: 50% 0.125rem; }
33
+ &:dir(rtl) { translate: 50% 0; }
27
34
  }
28
35
 
29
36
  .lc-tooltip[data-side="bottom"] > .lc-tooltip__content {
30
37
  inset-block: calc(100% + var(--spacing-related)) auto;
31
- translate: -50% -0.125rem;
32
38
  }
33
39
 
34
- .lc-tooltip:is(:hover, :has(:focus-visible)) > .lc-tooltip__content {
40
+ .lc-tooltip:is([data-open], :hover, :has(:focus-visible)) > .lc-tooltip__content {
35
41
  opacity: 1;
36
42
  visibility: visible;
37
- translate: -50% 0;
38
43
  transition-delay: 300ms;
39
44
  }
40
45
 
41
- .lc-tooltip:is(:hover, :has(:focus-visible)) > .lc-tooltip__content:dir(rtl) {
42
- translate: 50% 0;
43
- }
44
-
45
46
  .lc-tooltip[data-dismissed] > .lc-tooltip__content {
46
47
  opacity: 0;
47
48
  visibility: hidden;
@@ -31,7 +31,7 @@
31
31
  text-box: trim-both cap alphabetic; /* browser-compat: enhancement */
32
32
  }
33
33
 
34
- /* Inline code inside running text. base.css applies it to every <code>; prose
34
+ /* Inline code inside running text. base.css applies it to every <code>; prose.css
35
35
  applies it inside .lc-prose. */
36
36
  @utility lc-inline-code {
37
37
  font-family: var(--font-mono);
@@ -49,9 +49,3 @@
49
49
  content: none;
50
50
  }
51
51
  }
52
-
53
- @layer utilities {
54
- .lc-prose :where(code):not(:where(pre code)) {
55
- @apply lc-inline-code;
56
- }
57
- }
@@ -58,7 +58,7 @@ module Lostcoords::Ui
58
58
  "font-sans text-caption font-medium whitespace-nowrap"
59
59
 
60
60
  BADGE_TONES = {
61
- neutral: "bg-muted text-muted-foreground",
61
+ neutral: "bg-tile text-muted-foreground",
62
62
  success: "bg-success-soft text-success",
63
63
  warning: "bg-warning-soft text-warning",
64
64
  destructive: "bg-destructive-soft text-destructive",
@@ -86,7 +86,7 @@ module Lostcoords::Ui
86
86
  shape = lc_option!(:shape, shape, ICON_BADGE_SHAPES.keys)
87
87
  tone = lc_option!(:tone, tone, BADGE_TONES.keys)
88
88
  lc_tw(ICON_BADGE_BASE, ICON_BADGE_SIZES.fetch(size), ICON_BADGE_SHAPES.fetch(shape),
89
- (tone == :neutral ? "bg-muted text-foreground" : BADGE_TONES.fetch(tone)), extra)
89
+ (tone == :neutral ? "bg-tile text-foreground" : BADGE_TONES.fetch(tone)), extra)
90
90
  end
91
91
 
92
92
  def lc_avatar_class(size: :md, ink: false, linked: false, extra: nil)
@@ -1,10 +1,16 @@
1
1
  module Lostcoords::Ui
2
2
  module ButtonHelper
3
+ # Disabled controls drop their variant colours for a faint tint and dim ink, so they read as unavailable on any surface.
4
+ BUTTON_DISABLED =
5
+ "disabled:cursor-not-allowed disabled:shadow-none " \
6
+ "disabled:[--lc-button-bg:color-mix(in_oklab,var(--foreground)_10%,transparent)] " \
7
+ "disabled:text-[color-mix(in_oklab,var(--foreground)_38%,transparent)]"
8
+
3
9
  BUTTON_BASE =
4
10
  "inline-flex items-center justify-center gap-related rounded-pill font-sans " \
5
11
  "whitespace-nowrap no-underline cursor-pointer " \
6
12
  "transition-[background-color,border-color,color,box-shadow,transform,translate,scale,filter] duration-(--lc-motion-control) ease-(--lc-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 " \
13
+ "motion-safe:not-disabled:hover:-translate-y-px motion-safe:not-disabled:active:translate-y-px active:shadow-none #{BUTTON_DISABLED} " \
8
14
  "[&_svg]:block [&_svg:not([class*='size-'])]:size-[1.1em] [&_svg]:shrink-0"
9
15
 
10
16
  # Height comes from the shared control sizes; an icon trims the padding on its side.
@@ -15,15 +21,15 @@ module Lostcoords::Ui
15
21
  }.freeze
16
22
 
17
23
  BUTTON_HOVER =
18
- "hover:bg-[color-mix(in_oklab,var(--lc-button-bg,var(--secondary))_94%,var(--lc-button-ink,var(--foreground)))] " \
19
- "active:bg-[color-mix(in_oklab,var(--lc-button-bg,var(--secondary))_94%,var(--lc-button-ink,var(--foreground)))]"
24
+ "not-disabled:hover:bg-[color-mix(in_oklab,var(--lc-button-bg,var(--secondary))_94%,var(--lc-button-ink,var(--foreground)))] " \
25
+ "not-disabled:active:bg-[color-mix(in_oklab,var(--lc-button-bg,var(--secondary))_94%,var(--lc-button-ink,var(--foreground)))]"
20
26
 
21
27
  BUTTON_VARIANTS = {
22
28
  primary: "border border-transparent [--lc-button-bg:var(--primary)] [--lc-button-ink:var(--primary-foreground)] bg-(--lc-button-bg) text-(--lc-button-ink) #{BUTTON_HOVER}",
23
29
  secondary: "border border-transparent [--lc-button-bg:var(--secondary)] [--lc-button-ink:var(--secondary-foreground)] bg-(--lc-button-bg) text-(--lc-button-ink) #{BUTTON_HOVER}",
24
30
  outline: "border border-border bg-transparent text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
25
31
  glass: "[--lc-button-bg:var(--_lc-glass-fill)] text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
26
- ghost: "border border-transparent bg-transparent text-muted-foreground hover:text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
32
+ ghost: "border border-transparent bg-transparent text-muted-foreground not-disabled:hover:text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER}",
27
33
  destructive: "border border-transparent [--lc-button-bg:color-mix(in_oklab,var(--destructive)_60%,black)] [--lc-button-ink:#fff] bg-(--lc-button-bg) text-(--lc-button-ink) #{BUTTON_HOVER}"
28
34
  }.freeze
29
35
 
@@ -31,8 +37,8 @@ module Lostcoords::Ui
31
37
  "lc-press inline-grid place-items-center shrink-0 border-0 bg-transparent " \
32
38
  "text-muted-foreground cursor-pointer no-underline " \
33
39
  "transition-[background-color,color,transform,scale] duration-(--lc-motion-quick) ease-(--lc-ease-out) " \
34
- "hover:text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER} " \
35
- "disabled:opacity-40 disabled:pointer-events-none " \
40
+ "not-disabled:hover:text-(--lc-button-ink,var(--foreground)) #{BUTTON_HOVER} " \
41
+ "#{BUTTON_DISABLED} " \
36
42
  "[&_svg]:block [&_svg]:shrink-0"
37
43
 
38
44
  ICON_BUTTON_SHAPES = {
@@ -6,7 +6,7 @@ module Lostcoords::Ui
6
6
  CHOICE_LABEL = "lc-choice inline-flex min-w-0 items-start gap-related text-body text-foreground cursor-pointer has-disabled:cursor-not-allowed has-disabled:opacity-60"
7
7
 
8
8
  # card: true draws the whole label as a selectable tile, for plan or theme pickers.
9
- CHOICE_CARD = "flex w-full rounded-2xl lc-squircle border border-transparent bg-(--lc-tile-bg,var(--muted)) p-content " \
9
+ CHOICE_CARD = "flex w-full rounded-2xl lc-squircle border border-transparent bg-tile p-content " \
10
10
  "transition-[background-color,border-color] duration-(--lc-motion-quick) ease-(--lc-ease-out) hover:bg-accent " \
11
11
  "has-checked:border-primary has-checked:bg-primary-soft has-focus-visible:outline-2 has-focus-visible:outline-offset-2 " \
12
12
  "has-focus-visible:outline-(--ring,color-mix(in_oklab,var(--primary)_50%,transparent))"
@@ -42,15 +42,18 @@ module Lostcoords::Ui
42
42
  lc_choice("switch", label, hint, false, name, unchecked_value, { type: "checkbox", role: "switch", name: name, value: value, checked: checked, class: lc_switch_class }, attributes)
43
43
  end
44
44
 
45
- # A fieldset of checkboxes, radios or switches with a legend, hint and error.
46
- def lc_choice_group(legend:, hint: nil, error: nil, **attributes, &block)
45
+ # A fieldset of checkboxes, radios or switches with a legend, hint and error. With an id, the
46
+ # fieldset's aria-describedby points at the hint and error, as lc_field does for its control.
47
+ def lc_choice_group(legend:, id: nil, hint: nil, error: nil, **attributes, &block)
47
48
  lc_label!(legend, :legend)
48
49
  errors = Array(error).compact_blank
49
- tag.fieldset(**lc_attributes({ class: lc_field_class(extra: "m-0 min-w-0 border-0 p-0"), aria: { invalid: (true if errors.any?) }, data: { slot: "choice-group" } }, attributes)) do
50
+ described = [ ("#{id}-hint" if id && hint.present?), ("#{id}-error" if id && errors.any?) ].compact.presence&.join(" ")
51
+ defaults = { id: id, class: lc_field_class(extra: "m-0 min-w-0 border-0 p-0"), aria: { invalid: (true if errors.any?), describedby: described }, data: { slot: "choice-group", invalid: (true if errors.any?) } }
52
+ tag.fieldset(**lc_attributes(defaults, attributes)) do
50
53
  safe_join([
51
- tag.legend(legend, class: lc_field_label_class(extra: "p-0")),
54
+ tag.legend(legend, class: lc_field_label_class(extra: "p-0"), data: { slot: "choice-group-legend" }),
52
55
  tag.div(class: "flex flex-col gap-related", &block),
53
- *lc_field_messages(nil, hint, errors)
56
+ *lc_field_messages(id, hint, errors)
54
57
  ])
55
58
  end
56
59
  end
@@ -64,7 +67,7 @@ module Lostcoords::Ui
64
67
  end
65
68
 
66
69
  # Shared with Lostcoords::Ui::FormBuilder, which renders its own inputs.
67
- def lc_choice_label(slot, label, hint, input, card: false, **attributes, &block)
70
+ def lc_choice_label(slot, label, hint, input, card: false, errors: [], error_id: nil, **attributes, &block)
68
71
  extra = capture(&block) if block
69
72
  tag.label(**lc_attributes({ class: lc_choice_label_class(card: card), data: { slot: slot, card: (true if card) } }, attributes)) do
70
73
  safe_join([
@@ -73,6 +76,7 @@ module Lostcoords::Ui
73
76
  safe_join([
74
77
  tag.span(label, class: ("text-label" if card)),
75
78
  (tag.span(hint, class: "text-small text-muted-foreground") if hint.present?),
79
+ (tag.span(safe_join(errors, tag.br), id: error_id, class: "text-small text-destructive", data: { slot: "field-error" }) if errors.any?),
76
80
  (tag.span(extra, class: "flex min-w-0 flex-col pt-tight") if extra.present?)
77
81
  ].compact)
78
82
  end
@@ -0,0 +1,9 @@
1
+ module Lostcoords::Ui
2
+ module CollapsibleHelper
3
+ def lc_collapsible(title:, open: false, **attributes, &block)
4
+ lc_label!(title, :title)
5
+ defaults = { class: lc_card_class(extra: "lc-collapsible"), open: open, data: { slot: "collapsible", controller: "lc-collapsible", action: "click->lc-collapsible#toggle" } }
6
+ render "lostcoords/ui/components/lc_collapsible", title: title, attributes: lc_attributes(defaults, attributes), content: (capture(&block) if block)
7
+ end
8
+ end
9
+ end
@@ -14,13 +14,18 @@ module Lostcoords::Ui
14
14
  }.freeze
15
15
 
16
16
  def lc_dialog_class(kind = :modal, extra: nil)
17
- lc_tw("lc-squircle", lc_dialog_kinds.fetch(kind), extra)
17
+ lc_tw("lc-squircle", DIALOGS.fetch(lc_option!(:kind, kind, DIALOGS.keys)), extra)
18
18
  end
19
19
 
20
- private
21
-
22
- def lc_dialog_kinds
23
- DIALOGS
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)
24
29
  end
25
30
  end
26
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")
@@ -9,13 +9,13 @@ module Lostcoords::Ui
9
9
  lg: "size-8 border-[3px]"
10
10
  }.freeze
11
11
 
12
- SKELETON_BASE = "block rounded-xl bg-muted animate-pulse"
12
+ SKELETON_BASE = "block rounded-xl bg-tile animate-pulse"
13
13
 
14
14
  EMPTY_STATE_BASE =
15
15
  "flex flex-col items-center gap-content text-center " \
16
16
  "py-section px-content"
17
17
 
18
- ALERT_BASE = "flex items-start gap-related py-heading px-content rounded-2xl lc-squircle bg-muted text-foreground text-label"
18
+ ALERT_BASE = "flex items-start gap-related py-heading px-content rounded-2xl lc-squircle bg-tile text-foreground text-label"
19
19
  ALERT_ICONS = {
20
20
  neutral: [ "information-circle", "text-muted-foreground" ],
21
21
  primary: [ "information-circle", "text-primary" ],
@@ -1,7 +1,7 @@
1
1
  module Lostcoords::Ui
2
2
  module FormHelper
3
3
  TOGGLE_GROUP_BASE =
4
- "inline-flex items-center gap-1 rounded-2xl bg-muted p-1 lc-squircle"
4
+ "inline-flex items-center gap-1 rounded-2xl bg-track p-1 lc-squircle"
5
5
 
6
6
  TOGGLE_GROUP_ITEM_BASE =
7
7
  "lc-press inline-flex items-center justify-center gap-1.5 whitespace-nowrap " \