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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +364 -96
  3. data/app/assets/tailwind/lostcoords/ui/avatar.css +6 -6
  4. data/app/assets/tailwind/lostcoords/ui/base.css +45 -0
  5. data/app/assets/tailwind/lostcoords/ui/card.css +23 -22
  6. data/app/assets/tailwind/lostcoords/ui/choice.css +39 -0
  7. data/app/assets/tailwind/lostcoords/ui/code-block.css +10 -0
  8. data/app/assets/tailwind/lostcoords/ui/{disclosure.css → collapsible.css} +6 -6
  9. data/app/assets/tailwind/lostcoords/ui/components/code-block.css +5 -0
  10. data/app/assets/tailwind/lostcoords/ui/components/collapsible.css +5 -0
  11. data/app/assets/tailwind/lostcoords/ui/components/dialog.css +9 -0
  12. data/app/assets/tailwind/lostcoords/ui/components/emoji-picker.css +5 -0
  13. data/app/assets/tailwind/lostcoords/ui/components/menu.css +5 -0
  14. data/app/assets/tailwind/lostcoords/ui/components/prose.css +3 -0
  15. data/app/assets/tailwind/lostcoords/ui/components/table.css +3 -0
  16. data/app/assets/tailwind/lostcoords/ui/components/toast.css +5 -0
  17. data/app/assets/tailwind/lostcoords/ui/components/tooltip.css +4 -0
  18. data/app/assets/tailwind/lostcoords/ui/components.css +12 -0
  19. data/app/assets/tailwind/lostcoords/ui/copy-button.css +9 -9
  20. data/app/assets/tailwind/lostcoords/ui/core.css +21 -0
  21. data/app/assets/tailwind/lostcoords/ui/dialog.css +17 -17
  22. data/app/assets/tailwind/lostcoords/ui/emoji-picker.css +86 -88
  23. data/app/assets/tailwind/lostcoords/ui/field.css +2 -2
  24. data/app/assets/tailwind/lostcoords/ui/glass.css +9 -8
  25. data/app/assets/tailwind/lostcoords/ui/layout.css +2 -2
  26. data/app/assets/tailwind/lostcoords/ui/popover.css +14 -14
  27. data/app/assets/tailwind/lostcoords/ui/prose.css +66 -0
  28. data/app/assets/tailwind/lostcoords/ui/sheet.css +62 -63
  29. data/app/assets/tailwind/lostcoords/ui/squircle.css +7 -27
  30. data/app/assets/tailwind/lostcoords/ui/table.css +51 -0
  31. data/app/assets/tailwind/lostcoords/ui/theme.css +209 -225
  32. data/app/assets/tailwind/lostcoords/ui/toast.css +23 -0
  33. data/app/assets/tailwind/lostcoords/ui/tooltip.css +51 -0
  34. data/app/assets/tailwind/lostcoords/ui/typography.css +31 -61
  35. data/app/assets/tailwind/lostcoords/ui/ui.css +2 -21
  36. data/app/assets/tailwind/lostcoords/ui/utilities.css +14 -22
  37. data/app/helpers/lostcoords/ui/badge_helper.rb +67 -110
  38. data/app/helpers/lostcoords/ui/button_helper.rb +44 -35
  39. data/app/helpers/lostcoords/ui/card_helper.rb +9 -52
  40. data/app/helpers/lostcoords/ui/choice_helper.rb +87 -0
  41. data/app/helpers/lostcoords/ui/code_block_helper.rb +22 -0
  42. data/app/helpers/lostcoords/ui/collapsible_helper.rb +9 -0
  43. data/app/helpers/lostcoords/ui/copy_button_helper.rb +7 -7
  44. data/app/helpers/lostcoords/ui/description_list_helper.rb +12 -0
  45. data/app/helpers/lostcoords/ui/dialog_helper.rb +15 -17
  46. data/app/helpers/lostcoords/ui/emoji_picker_helper.rb +6 -0
  47. data/app/helpers/lostcoords/ui/feedback_helper.rb +25 -6
  48. data/app/helpers/lostcoords/ui/form_helper.rb +36 -59
  49. data/app/helpers/lostcoords/ui/glass_helper.rb +7 -7
  50. data/app/helpers/lostcoords/ui/layout_helper.rb +13 -11
  51. data/app/helpers/lostcoords/ui/menu_helper.rb +45 -10
  52. data/app/helpers/lostcoords/ui/navigation_helper.rb +144 -0
  53. data/app/helpers/lostcoords/ui/primitives_helper.rb +39 -61
  54. data/app/helpers/lostcoords/ui/reading_helper.rb +2 -2
  55. data/app/helpers/lostcoords/ui/render_attributes_helper.rb +17 -1
  56. data/app/helpers/lostcoords/ui/sheet_helper.rb +7 -45
  57. data/app/helpers/lostcoords/ui/structures_helper.rb +26 -125
  58. data/app/helpers/lostcoords/ui/table_helper.rb +11 -0
  59. data/app/helpers/lostcoords/ui/tailwind_helper.rb +13 -4
  60. data/app/helpers/lostcoords/ui/toast_helper.rb +22 -0
  61. data/app/helpers/lostcoords/ui/tooltip_helper.rb +17 -0
  62. data/app/helpers/lostcoords/ui/typography_helper.rb +41 -11
  63. data/app/javascript/lostcoords/ui/controllers/lc_code_block_controller.js +23 -0
  64. data/app/javascript/lostcoords/ui/controllers/{disclosure_controller.js → lc_collapsible_controller.js} +4 -4
  65. data/app/javascript/lostcoords/ui/controllers/{confirm_dialog_controller.js → lc_confirm_dialog_controller.js} +4 -4
  66. data/app/javascript/lostcoords/ui/controllers/{modal_controller.js → lc_dialog_controller.js} +1 -1
  67. data/app/javascript/lostcoords/ui/controllers/{emoji_picker_controller.js → lc_emoji_picker_controller.js} +38 -10
  68. data/app/javascript/lostcoords/ui/controllers/lc_kbd_controller.js +12 -0
  69. data/app/javascript/lostcoords/ui/controllers/{row_menu_controller.js → lc_menu_controller.js} +25 -3
  70. data/app/javascript/lostcoords/ui/controllers/{ui_popover_controller.js → lc_popover_controller.js} +1 -1
  71. data/app/javascript/lostcoords/ui/controllers/{ui_selection_controller.js → lc_selection_controller.js} +7 -15
  72. data/app/javascript/lostcoords/ui/controllers/{sheet_controller.js → lc_sheet_controller.js} +17 -17
  73. data/app/javascript/lostcoords/ui/controllers/lc_shortcut_controller.js +45 -0
  74. data/app/javascript/lostcoords/ui/controllers/lc_tabs_controller.js +42 -0
  75. data/app/javascript/lostcoords/ui/controllers/{toast_controller.js → lc_toast_controller.js} +2 -2
  76. data/app/javascript/lostcoords/ui/controllers/lc_toast_region_controller.js +62 -0
  77. data/app/javascript/lostcoords/ui/controllers/lc_tooltip_controller.js +54 -0
  78. data/app/javascript/lostcoords/ui/helpers/catalog_picker.js +77 -25
  79. data/app/javascript/lostcoords/ui/helpers/context_dialog.js +6 -6
  80. data/app/javascript/lostcoords/ui/helpers/dialog.js +2 -2
  81. data/app/javascript/lostcoords/ui/helpers/dismiss_stack.js +30 -7
  82. data/app/javascript/lostcoords/ui/helpers/floating_panel.js +7 -8
  83. data/app/javascript/lostcoords/ui/helpers/menu_keys.js +19 -18
  84. data/app/javascript/lostcoords/ui/helpers/roving_focus.js +175 -0
  85. data/app/javascript/lostcoords/ui/helpers/sheet_motion.js +4 -4
  86. data/app/javascript/lostcoords/ui/helpers/shortcuts.js +162 -0
  87. data/app/javascript/lostcoords/ui/helpers/top_layer.js +48 -10
  88. data/app/javascript/lostcoords/ui/index.js +57 -0
  89. data/app/views/lostcoords/ui/components/_emoji_picker.html.erb +46 -46
  90. data/app/views/lostcoords/ui/components/_emoji_picker_grid.html.erb +3 -3
  91. data/app/views/lostcoords/ui/components/_lc_code_block.html.erb +18 -0
  92. data/app/views/lostcoords/ui/components/_lc_collapsible.html.erb +5 -0
  93. data/app/views/lostcoords/ui/components/_lc_dialog.html.erb +11 -8
  94. data/app/views/lostcoords/ui/components/_lc_floating_panel.html.erb +8 -0
  95. data/config/importmap.rb +10 -2
  96. data/config/locales/en.yml +23 -0
  97. data/config/locales/es.yml +23 -0
  98. data/config/locales/ja.yml +23 -0
  99. data/lib/lostcoords/ui/action_view.rb +6 -3
  100. data/lib/lostcoords/ui/builders.rb +2 -2
  101. data/lib/lostcoords/ui/form_builder.rb +126 -0
  102. data/lib/lostcoords/ui/parts.rb +154 -0
  103. data/lib/lostcoords/ui/stylesheets.rb +25 -0
  104. data/lib/lostcoords/ui/tokens.rb +24 -1
  105. data/lib/lostcoords/ui/version.rb +1 -1
  106. data/lib/tasks/lostcoords_ui.rake +11 -0
  107. metadata +54 -21
  108. data/app/assets/tailwind/lostcoords/ui/reading.css +0 -60
  109. data/app/views/lostcoords/ui/components/_form_alert.html.erb +0 -4
  110. data/app/views/lostcoords/ui/components/_lc_disclosure.html.erb +0 -5
  111. data/app/views/lostcoords/ui/components/_skeleton.html.erb +0 -4
  112. data/app/views/lostcoords/ui/components/_spinner.html.erb +0 -10
  113. data/app/views/lostcoords/ui/components/_ui_floating_panel.html.erb +0 -8
  114. data/config/helper_renames.json +0 -113
  115. data/script/migrate_helpers.rb +0 -136
  116. /data/app/javascript/lostcoords/ui/controllers/{ui_copy_controller.js → lc_copy_controller.js} +0 -0
  117. /data/app/javascript/lostcoords/ui/controllers/{ui_toggle_controller.js → lc_toggle_controller.js} +0 -0
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toggle"]
5
+
6
+ toggle() {
7
+ this.setExpanded(!this.element.hasAttribute("data-expanded"))
8
+ }
9
+
10
+ copyFailed() {
11
+ this.setExpanded(true)
12
+ }
13
+
14
+ setExpanded(expanded) {
15
+ this.element.toggleAttribute("data-expanded", expanded)
16
+ if (!this.hasToggleTarget) return
17
+
18
+ this.toggleTarget.setAttribute("aria-expanded", String(expanded))
19
+ this.toggleTarget.textContent = expanded
20
+ ? this.toggleTarget.dataset.collapseLabel
21
+ : this.toggleTarget.dataset.expandLabel
22
+ }
23
+ }
@@ -1,9 +1,9 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { prefersReducedMotion } from "helpers/viewport"
2
+ import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
3
3
 
4
4
  // Animates a <details> both ways and keeps what it reveals on screen. Only
5
5
  // user toggles go through here: programmatic opens (validation) already scroll
6
- // to the field they care about. Durations live in ui/disclosure.css.
6
+ // to the field they care about. Durations live in ui/collapsible.css.
7
7
  export default class extends Controller {
8
8
  // Where the panel's height can't ease to `auto` in CSS (Safari), ease the
9
9
  // <details> itself between measured heights.
@@ -40,7 +40,7 @@ export default class extends Controller {
40
40
  from,
41
41
  this.element.offsetHeight,
42
42
  duration,
43
- "--ease-out-expo",
43
+ "--lc-ease-out-expo",
44
44
  )
45
45
  animation.finished.then(
46
46
  () => animation.cancel(),
@@ -55,7 +55,7 @@ export default class extends Controller {
55
55
  this.element.toggleAttribute("data-closing", true)
56
56
  const duration = Math.max(...this.transitionDurations())
57
57
  if (!this.constructor.easesHeightInCss) {
58
- this.animateHeight(this.element.offsetHeight, this.closedHeight(), duration, "--ease-out")
58
+ this.animateHeight(this.element.offsetHeight, this.closedHeight(), duration, "--lc-ease-out")
59
59
  }
60
60
  this.closeTimeout = setTimeout(() => {
61
61
  this.element.open = false
@@ -1,6 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { showContextDialog } from "helpers/context_dialog"
3
- import { registerDialog } from "helpers/dialog"
2
+ import { showContextDialog } from "lostcoords/ui/helpers/context_dialog"
3
+ import { registerDialog } from "lostcoords/ui/helpers/dialog"
4
4
 
5
5
  export default class extends Controller {
6
6
  static targets = ["dialog"]
@@ -16,8 +16,8 @@ export default class extends Controller {
16
16
  if (!dialog || dialog.open) return
17
17
 
18
18
  this.activeDialog = dialog
19
- if (dialog.matches(".context-dialog")) {
20
- showContextDialog(dialog, this.element.querySelector("[data-row-menu-target='popover']"))
19
+ if (dialog.matches(".lc-dialog-context")) {
20
+ showContextDialog(dialog, this.element.querySelector("[data-lc-menu-target='popover']"))
21
21
  } else {
22
22
  dialog.showModal()
23
23
  }
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { registerDialog } from "helpers/dialog"
2
+ import { registerDialog } from "lostcoords/ui/helpers/dialog"
3
3
 
4
4
  // Opens a dialog whose body is a Turbo frame fetched on open. It reloads on every
5
5
  // open, so what it shows is never staler than the page that opened it.
@@ -1,8 +1,11 @@
1
- import { CatalogPicker } from "helpers/catalog_picker"
2
- import { prefersReducedMotion } from "helpers/viewport"
1
+ import { CatalogPicker } from "lostcoords/ui/helpers/catalog_picker"
2
+ import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { menuItems, navigateMenu } from "lostcoords/ui/helpers/menu_keys"
4
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
5
+ import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
3
6
 
4
7
  const TONE_MODIFIERS = ["", "\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]
5
- const TONE_STORAGE_KEY = "lostcoords_ui:emoji-picker:tone"
8
+ const TONE_STORAGE_KEY = "lostcoords_ui:lc-emoji-picker:tone"
6
9
 
7
10
  function markCurrent(element, current) {
8
11
  element.setAttribute("aria-current", String(current))
@@ -25,7 +28,7 @@ export default class extends CatalogPicker {
25
28
  }
26
29
 
27
30
  static optionKey = "emoji"
28
- static catalogClass = "emoji-picker__catalog"
31
+ static catalogClass = "lc-emoji-picker__catalog"
29
32
  static previewTargets = ["previewEmoji", "previewName"]
30
33
 
31
34
  connect() {
@@ -36,6 +39,7 @@ export default class extends CatalogPicker {
36
39
  }
37
40
 
38
41
  disconnect() {
42
+ this.unregisterTones?.()
39
43
  this.clearPopover()
40
44
  super.disconnect()
41
45
  }
@@ -151,13 +155,33 @@ export default class extends CatalogPicker {
151
155
  })
152
156
  }
153
157
 
158
+ navigateTabs(event) {
159
+ navigate(event, this.tabTargets, { orientation: "horizontal", wrap: true })
160
+ }
161
+
162
+ // Escape closes the tone menu before the picker around it.
154
163
  toggleTones(event) {
155
164
  event.preventDefault()
156
165
  event.stopPropagation()
157
- const open = this.toneMenuTarget.hidden
158
- this.toneMenuTarget.hidden = !open
159
- this.toneToggleTarget.setAttribute("aria-expanded", String(open))
160
- if (open) this.syncTones()
166
+ if (!this.toneMenuTarget.hidden) return this.closeTones()
167
+
168
+ this.toneMenuTarget.hidden = false
169
+ this.toneToggleTarget.setAttribute("aria-expanded", "true")
170
+ this.syncTones()
171
+ this.unregisterTones = registerDismissable({
172
+ element: this.toneMenuTarget,
173
+ type: "popover",
174
+ close: () => this.closeTones(),
175
+ restoreFocus: () => this.toneToggleTarget.focus({ preventScroll: true }),
176
+ })
177
+ if (event.detail === 0) {
178
+ const items = menuItems(this.toneMenuTarget)
179
+ ;(items.find((item) => item.getAttribute("aria-current") === "true") || items[0])?.focus()
180
+ }
181
+ }
182
+
183
+ navigateTones(event) {
184
+ navigateMenu(event, this.toneMenuTarget, { orientation: "both" })
161
185
  }
162
186
 
163
187
  selectTone(event) {
@@ -174,7 +198,11 @@ export default class extends CatalogPicker {
174
198
 
175
199
  closeTones() {
176
200
  if (!this.hasToneMenuTarget) return
201
+ this.unregisterTones?.()
202
+ this.unregisterTones = null
203
+ const hadFocus = this.toneMenuTarget.contains(document.activeElement)
177
204
  this.toneMenuTarget.hidden = true
205
+ if (hadFocus) this.toneToggleTarget.focus({ preventScroll: true })
178
206
  this.toneToggleTarget.setAttribute("aria-expanded", "false")
179
207
  }
180
208
 
@@ -255,7 +283,7 @@ export default class extends CatalogPicker {
255
283
  renderRecent(emojis) {
256
284
  if (!this.hasRecentTarget) return
257
285
 
258
- const grid = this.recentTarget.querySelector(".emoji-picker__grid")
286
+ const grid = this.recentTarget.querySelector(".lc-emoji-picker__grid")
259
287
  const count = this.recentTarget.querySelector(".count")
260
288
  if (!grid) return
261
289
 
@@ -322,7 +350,7 @@ export default class extends CatalogPicker {
322
350
  return {
323
351
  duration,
324
352
  settle: duration && 280,
325
- transition: "transform 260ms var(--ease-out-expo, ease-out)",
353
+ transition: "transform 260ms var(--lc-ease-out-expo, ease-out)",
326
354
  offscreen: "translateY(100%)",
327
355
  }
328
356
  }
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { formatShortcut } from "lostcoords/ui/helpers/shortcuts"
3
+
4
+ // Shows a key hint in the platform's own notation: "mod+k" reads ⌘ K on
5
+ // Apple platforms. The server renders the Ctrl form, which stays without JS.
6
+ export default class extends Controller {
7
+ static values = { keys: String }
8
+
9
+ keysValueChanged() {
10
+ if (this.keysValue) this.element.textContent = formatShortcut(this.keysValue)
11
+ }
12
+ }
@@ -1,5 +1,9 @@
1
- import { FloatingPanel } from "helpers/floating_panel"
2
- import { focusFirstMenuItem, navigateMenu } from "helpers/menu_keys"
1
+ import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
2
+ import {
3
+ focusFirstMenuItem,
4
+ focusLastMenuItem,
5
+ navigateMenu,
6
+ } from "lostcoords/ui/helpers/menu_keys"
3
7
 
4
8
  // The phone dock's action stack lays itself out over the fixed nav, so it
5
9
  // passes lift: false and stays in place instead of lifting into the top layer.
@@ -9,15 +13,33 @@ export default class extends FloatingPanel {
9
13
  this.focusOutHandler = this.handleFocusOut.bind(this)
10
14
  this.pointerDownHandler = this.handlePointerDown.bind(this)
11
15
  this.keydownHandler = (event) => navigateMenu(event, this.popoverTarget)
16
+ this.triggerKeydownHandler = this.handleTriggerKeydown.bind(this)
12
17
  this.focusOutSuppressed = false
13
18
  this.focusOutSuppressionTimer = null
14
19
  }
15
20
 
21
+ connect() {
22
+ super.connect()
23
+ this.triggerTarget.addEventListener("keydown", this.triggerKeydownHandler)
24
+ }
25
+
16
26
  disconnect() {
27
+ this.triggerTarget.removeEventListener("keydown", this.triggerKeydownHandler)
17
28
  this.clearFocusOutSuppression()
18
29
  super.disconnect()
19
30
  }
20
31
 
32
+ // Arrow Down opens the menu on its first item, Arrow Up on its last.
33
+ handleTriggerKeydown(event) {
34
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
35
+ if (event.altKey || event.ctrlKey || event.metaKey) return
36
+
37
+ event.preventDefault()
38
+ this.open()
39
+ if (event.key === "ArrowDown") focusFirstMenuItem(this.popoverTarget)
40
+ else focusLastMenuItem(this.popoverTarget)
41
+ }
42
+
21
43
  toggle(event) {
22
44
  event.preventDefault()
23
45
  event.stopPropagation()
@@ -29,7 +51,7 @@ export default class extends FloatingPanel {
29
51
 
30
52
  toggleFromRow(event) {
31
53
  if (this.popoverTarget.contains(event.target)) return
32
- if (event.target.closest("a, button, input, select, textarea, [data-row-menu-ignore]")) return
54
+ if (event.target.closest("a, button, input, select, textarea, [data-lc-menu-ignore]")) return
33
55
 
34
56
  event.preventDefault()
35
57
  this.isOpen ? this.close() : this.open()
@@ -1,4 +1,4 @@
1
- import { FloatingPanel } from "helpers/floating_panel"
1
+ import { FloatingPanel } from "lostcoords/ui/helpers/floating_panel"
2
2
 
3
3
  export default class extends FloatingPanel {
4
4
  toggle(event) {
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
2
3
 
3
4
  export default class extends Controller {
4
5
  static targets = ["option", "input"]
@@ -22,27 +23,18 @@ export default class extends Controller {
22
23
  }
23
24
 
24
25
  select(event) {
25
- const option = event.target.closest('[data-ui-selection-target~="option"]')
26
+ const option = event.target.closest('[data-lc-selection-target~="option"]')
26
27
  if (!this.options.includes(option)) return
27
28
  this.apply(option)
28
29
  this.changed(option)
29
30
  }
30
31
 
32
+ // All four arrows move and select, wrapping at the ends; Left and Right
33
+ // follow the text direction.
31
34
  navigate(event) {
32
- const current = this.options.indexOf(event.target)
33
- if (current < 0) return
34
- const next = {
35
- ArrowRight: (current + 1) % this.options.length,
36
- ArrowDown: (current + 1) % this.options.length,
37
- ArrowLeft: (current - 1 + this.options.length) % this.options.length,
38
- ArrowUp: (current - 1 + this.options.length) % this.options.length,
39
- Home: 0,
40
- End: this.options.length - 1,
41
- }[event.key]
42
- if (next === undefined) return
43
- event.preventDefault()
44
- const option = this.options[next]
45
- option.focus()
35
+ const option = navigate(event, this.optionTargets, { orientation: "both", wrap: true })
36
+ if (!option) return
37
+
46
38
  this.apply(option)
47
39
  this.changed(option)
48
40
  }
@@ -1,13 +1,13 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { holdDocumentScroll } from "helpers/dismiss_stack"
3
- import { SHEET_TIMING, sheetDuration } from "helpers/sheet_motion"
4
- import { isSheetViewport, prefersReducedMotion, TEXT_ENTRY } from "helpers/viewport"
2
+ import { holdDocumentScroll } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { SHEET_TIMING, sheetDuration } from "lostcoords/ui/helpers/sheet_motion"
4
+ import { isSheetViewport, prefersReducedMotion, TEXT_ENTRY } from "lostcoords/ui/helpers/viewport"
5
5
 
6
6
  const PROJECTION_MS = 180
7
7
  const MEDIUM_FRACTION = 0.5
8
8
  const PAGE_SCALE_LOSS = 0.06
9
- const NO_DRAG = "input[type=range], [data-sheet-no-drag]"
10
- const HANDLE = "[data-sheet-target~='handle']"
9
+ const NO_DRAG = "input[type=range], [data-lc-sheet-no-drag]"
10
+ const HANDLE = "[data-lc-sheet-target~='handle']"
11
11
  const GRABBER_ZONE = 28
12
12
  const KEYBOARD_FREE = 0.5
13
13
 
@@ -71,9 +71,9 @@ export default class extends Controller {
71
71
  // The keyboard rises over the sheet. Safari and in-app browsers shrink the
72
72
  // page under it, which would drag a bottom-pinned sheet up and squash it, so
73
73
  // the sheet is placed from the top at the height the screen had when it
74
- // opened (see --sheet-floor in the CSS).
74
+ // opened (see --lc-sheet-floor in the CSS).
75
75
  pinFloor() {
76
- if (this.isSheet) this.element.style.setProperty("--sheet-floor", `${window.innerHeight}px`)
76
+ if (this.isSheet) this.element.style.setProperty("--lc-sheet-floor", `${window.innerHeight}px`)
77
77
  }
78
78
 
79
79
  closed() {
@@ -330,7 +330,7 @@ export default class extends Controller {
330
330
  }
331
331
 
332
332
  setLargeness(p) {
333
- this.element.style.setProperty("--sheet-p", p.toFixed(3))
333
+ this.element.style.setProperty("--lc-sheet-p", p.toFixed(3))
334
334
  }
335
335
 
336
336
  setPage(p) {
@@ -339,12 +339,12 @@ export default class extends Controller {
339
339
  if (p <= 0) return
340
340
  this.releaseScroll = holdDocumentScroll()
341
341
  const lockedTop = -parseFloat(document.body.style.top || "0")
342
- root.style.setProperty("--sheet-page-origin", `${window.scrollY + lockedTop}px`)
343
- root.dataset.sheetPage = ""
342
+ root.style.setProperty("--lc-sheet-page-origin", `${window.scrollY + lockedTop}px`)
343
+ root.dataset.lcSheetPage = ""
344
344
  this.ownsPage = true
345
345
  }
346
- root.style.setProperty("--sheet-page-p", p.toFixed(3))
347
- root.style.setProperty("--sheet-page-scale", (1 - PAGE_SCALE_LOSS * p).toFixed(4))
346
+ root.style.setProperty("--lc-sheet-page-p", p.toFixed(3))
347
+ root.style.setProperty("--lc-sheet-page-scale", (1 - PAGE_SCALE_LOSS * p).toFixed(4))
348
348
  }
349
349
 
350
350
  animatePage(p) {
@@ -361,15 +361,15 @@ export default class extends Controller {
361
361
  if (!this.ownsPage) return
362
362
  this.ownsPage = false
363
363
  const root = document.documentElement
364
- delete root.dataset.sheetPage
364
+ delete root.dataset.lcSheetPage
365
365
  for (const name of ["p", "scale", "origin", "transition"])
366
- root.style.removeProperty(`--sheet-page-${name}`)
366
+ root.style.removeProperty(`--lc-sheet-page-${name}`)
367
367
  this.releaseScroll()
368
368
  }
369
369
 
370
370
  pageTransition(value = prefersReducedMotion() ? "0s" : SHEET_TIMING) {
371
371
  if (this.ownsPage || value !== "0s")
372
- document.documentElement.style.setProperty("--sheet-page-transition", value)
372
+ document.documentElement.style.setProperty("--lc-sheet-page-transition", value)
373
373
  }
374
374
 
375
375
  transition() {
@@ -396,8 +396,8 @@ export default class extends Controller {
396
396
  style.transform = ""
397
397
  style.willChange = ""
398
398
  style.blockSize = ""
399
- style.removeProperty("--sheet-p")
400
- style.removeProperty("--sheet-floor")
399
+ style.removeProperty("--lc-sheet-p")
400
+ style.removeProperty("--lc-sheet-floor")
401
401
  }
402
402
 
403
403
  scrollerFor(target) {
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { isEditable } from "lostcoords/ui/helpers/roving_focus"
3
+ import { ariaKeyShortcuts, registerShortcut } from "lostcoords/ui/helpers/shortcuts"
4
+
5
+ // Binds page-wide keys to the element: a field takes focus and selects its
6
+ // text, anything else is clicked. The element announces its keys through
7
+ // aria-keyshortcuts. See helpers/shortcuts.js for the key syntax.
8
+ export default class extends Controller {
9
+ static values = { keys: String }
10
+
11
+ // Stimulus keeps values across a reconnect (a moved or permanent element)
12
+ // without calling keysValueChanged again, so connect registers too.
13
+ connect() {
14
+ this.register()
15
+ }
16
+
17
+ keysValueChanged() {
18
+ if (this.connected) this.register()
19
+ }
20
+
21
+ register() {
22
+ this.connected = true
23
+ this.unregister?.()
24
+ this.unregister = null
25
+ if (!this.keysValue) return
26
+
27
+ this.element.setAttribute("aria-keyshortcuts", ariaKeyShortcuts(this.keysValue))
28
+ this.unregister = registerShortcut(this.element, this.keysValue, () => this.run())
29
+ }
30
+
31
+ disconnect() {
32
+ this.connected = false
33
+ this.unregister?.()
34
+ this.unregister = null
35
+ }
36
+
37
+ run() {
38
+ if (isEditable(this.element)) {
39
+ this.element.focus()
40
+ this.element.select?.()
41
+ } else {
42
+ this.element.click()
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,42 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { navigate } from "lostcoords/ui/helpers/roving_focus"
3
+
4
+ // The ARIA tabs pattern. Arrow keys follow the tab list's aria-orientation, and
5
+ // Home and End jump to the ends. With automatic activation, moving focus selects
6
+ // the tab; with manual activation, Enter or Space does.
7
+ export default class extends Controller {
8
+ static targets = ["tab", "panel"]
9
+ static values = { activation: { type: String, default: "automatic" } }
10
+
11
+ select(event) {
12
+ this.show(event.currentTarget)
13
+ }
14
+
15
+ navigate(event) {
16
+ const list = event.currentTarget.closest('[role="tablist"]')
17
+ const tab = navigate(event, this.tabTargets, {
18
+ orientation:
19
+ list?.getAttribute("aria-orientation") === "vertical" ? "vertical" : "horizontal",
20
+ wrap: true,
21
+ })
22
+ if (!tab) return
23
+
24
+ if (this.activationValue === "manual") this.focusOnly(tab)
25
+ else this.show(tab)
26
+ }
27
+
28
+ focusOnly(tab) {
29
+ for (const candidate of this.tabTargets) candidate.tabIndex = candidate === tab ? 0 : -1
30
+ }
31
+
32
+ show(tab) {
33
+ for (const candidate of this.tabTargets) {
34
+ const selected = candidate === tab
35
+ candidate.setAttribute("aria-selected", String(selected))
36
+ candidate.tabIndex = selected ? 0 : -1
37
+ }
38
+ const panelId = tab.getAttribute("aria-controls")
39
+ for (const panel of this.panelTargets) panel.hidden = panel.id !== panelId
40
+ this.dispatch("change", { detail: { value: tab.dataset.value } })
41
+ }
42
+ }
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { prefersReducedMotion } from "helpers/viewport"
2
+ import { prefersReducedMotion } from "lostcoords/ui/helpers/viewport"
3
3
 
4
4
  export default class extends Controller {
5
5
  static values = { timeout: { type: Number, default: 4000 } }
@@ -7,7 +7,7 @@ export default class extends Controller {
7
7
  connect() {
8
8
  for (const sibling of this.element.parentElement?.children || []) {
9
9
  if (sibling === this.element) continue
10
- this.application.getControllerForElementAndIdentifier(sibling, "toast")?.dismiss()
10
+ this.application.getControllerForElementAndIdentifier(sibling, "lc-toast")?.dismiss()
11
11
  }
12
12
 
13
13
  this.startedAt = performance.now()
@@ -0,0 +1,62 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { topModalElement } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { hideTopLayer, showTopLayer, supportsTopLayer } from "lostcoords/ui/helpers/top_layer"
4
+
5
+ // Shows the region in the top layer while it holds toasts, so no sticky header,
6
+ // sheet or dialog paints over them. Each new toast re-opens the region, which
7
+ // moves it above anything opened since. A modal dialog makes the rest of the
8
+ // page inert, so while one is open the region moves inside it, keeping toasts
9
+ // clickable and announced, and moves back when it closes. Without the Popover
10
+ // API the region stays fixed at --lc-z-toast.
11
+ const homes = new WeakMap()
12
+
13
+ export default class extends Controller {
14
+ connect() {
15
+ if (!supportsTopLayer()) return
16
+
17
+ this.observer = new MutationObserver((records) => {
18
+ if (records.some((record) => record.addedNodes.length > 0)) this.raise()
19
+ else if (this.element.childElementCount === 0) this.lower()
20
+ })
21
+ this.observer.observe(this.element, { childList: true })
22
+ if (this.element.childElementCount > 0) this.raise()
23
+ }
24
+
25
+ // A modal removed right after closing (before its close event) takes the
26
+ // region with it; put the region back in the page.
27
+ disconnect() {
28
+ this.observer?.disconnect()
29
+ hideTopLayer(this.element)
30
+ if (!this.element.isConnected) this.goHome()
31
+ }
32
+
33
+ raise() {
34
+ const modal = topModalElement()
35
+ if (modal && !modal.contains(this.element)) return this.moveInto(modal)
36
+
37
+ hideTopLayer(this.element)
38
+ showTopLayer(this.element)
39
+ }
40
+
41
+ lower() {
42
+ hideTopLayer(this.element)
43
+ this.goHome()
44
+ }
45
+
46
+ // Moving the region reconnects this controller, which raises it again.
47
+ moveInto(modal) {
48
+ if (!homes.has(this.element))
49
+ homes.set(this.element, { parent: this.element.parentNode, next: this.element.nextSibling })
50
+ const region = this.element
51
+ modal.addEventListener("close", () => this.goHome(region), { once: true })
52
+ modal.append(region)
53
+ }
54
+
55
+ goHome(region = this.element) {
56
+ const home = homes.get(region)
57
+ if (!home?.parent.isConnected) return
58
+
59
+ homes.delete(region)
60
+ home.parent.insertBefore(region, home.next?.parentNode === home.parent ? home.next : null)
61
+ }
62
+ }
@@ -0,0 +1,54 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { registerDismissable } from "lostcoords/ui/helpers/dismiss_stack"
3
+ import { liftToTopLayer, onPageLeave } from "lostcoords/ui/helpers/top_layer"
4
+
5
+ // Opens the tooltip in the top layer, so no stacking context or overflow clip
6
+ // covers it. Without the Popover API it stays in place. Escape closes it before
7
+ // any menu or dialog around it, and it stays hidden until the pointer leaves
8
+ // and focus moves away.
9
+ export default class extends Controller {
10
+ static targets = ["content"]
11
+
12
+ connect() {
13
+ this.stopPageLeave = onPageLeave(() => this.close({ immediate: true }))
14
+ }
15
+
16
+ disconnect() {
17
+ this.stopPageLeave?.()
18
+ this.close({ immediate: true })
19
+ }
20
+
21
+ show(event) {
22
+ if (event.type === "focusin" && !event.target.matches(":focus-visible")) return
23
+ if (this.release || this.element.hasAttribute("data-dismissed")) return
24
+
25
+ this.element.toggleAttribute("data-open", true)
26
+ this.release = liftToTopLayer(this.contentTarget, this.element)
27
+ this.unregister = registerDismissable({
28
+ type: "tooltip",
29
+ element: this.element,
30
+ isOpen: () => this.element.hasAttribute("data-open"),
31
+ close: () => this.dismiss(),
32
+ })
33
+ }
34
+
35
+ hide() {
36
+ if (this.element.matches(":hover") || this.element.querySelector(":focus-visible")) return
37
+
38
+ delete this.element.dataset.dismissed
39
+ this.close()
40
+ }
41
+
42
+ dismiss() {
43
+ this.element.dataset.dismissed = ""
44
+ this.close({ immediate: true })
45
+ }
46
+
47
+ close({ immediate = false } = {}) {
48
+ this.element.toggleAttribute("data-open", false)
49
+ this.release?.({ immediate })
50
+ this.release = null
51
+ this.unregister?.()
52
+ this.unregister = null
53
+ }
54
+ }