studio-engine 0.141.3 → 0.142.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +103 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +90 -18
  5. data/app/components/previews/studio/button_component_preview/disabled.html.erb +12 -0
  6. data/app/components/previews/studio/button_component_preview/icon_and_link.html.erb +7 -0
  7. data/app/components/previews/studio/button_component_preview/loading.html.erb +12 -0
  8. data/app/components/previews/studio/button_component_preview/sizes.html.erb +9 -0
  9. data/app/components/previews/studio/button_component_preview/tones.html.erb +5 -0
  10. data/app/components/previews/studio/button_component_preview.rb +34 -0
  11. data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb +5 -0
  12. data/app/components/previews/studio/empty_state_component_preview.rb +29 -0
  13. data/app/components/previews/studio/table_component_preview/rows.html.erb +14 -0
  14. data/app/components/previews/studio/table_component_preview.rb +33 -0
  15. data/app/components/studio/button_component.html.erb +1 -0
  16. data/app/components/studio/button_component.rb +151 -0
  17. data/app/components/studio/empty_state_component.html.erb +12 -0
  18. data/app/components/studio/empty_state_component.rb +64 -0
  19. data/app/components/studio/table_component.html.erb +32 -0
  20. data/app/components/studio/table_component.rb +152 -0
  21. data/app/controllers/studio/modals_controller.rb +48 -0
  22. data/app/javascript/studio/carousel.js +52 -0
  23. data/app/javascript/studio/controllers/carousel_controller.js +7 -13
  24. data/app/javascript/studio/in_page_links.js +85 -11
  25. data/app/javascript/studio/modal_host.js +219 -0
  26. data/app/models/error_log.rb +5 -5
  27. data/app/views/components/_empty_state.html.erb +2 -6
  28. data/app/views/studio/modals/_host.html.erb +34 -2
  29. data/app/views/studio/modals/remote.html.erb +5 -0
  30. data/app/views/style/_components.html.erb +5 -2
  31. data/app/views/style/_glass.html.erb +9 -1
  32. data/app/views/style/_primitives.html.erb +106 -0
  33. data/config/studio_tricks.yml +6 -2
  34. data/db/migrate/20261109180000_add_engine_not_null_checks.rb +148 -0
  35. data/lib/studio/modals.rb +101 -0
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +11 -0
  38. metadata +22 -1
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The one button every app renders: a <button>, or an <a> when it has an href.
5
+ #
6
+ # render Studio::ButtonComponent.new(label: "Save")
7
+ # render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm)
8
+ # render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: "/docs")
9
+ # render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…")
10
+ # render(Studio::ButtonComponent.new(label: "New")) { |b| b.with_icon { plus_svg } }
11
+ #
12
+ # It draws the engine's shipped button language and adds none of its own: each
13
+ # tone is a btn-* utility in engine.css and each size is btn-sm, bare btn or
14
+ # btn-lg. An app that retunes --btn-secondary-* or --btn-neutral-* retunes the
15
+ # component with it.
16
+ #
17
+ # tone primary btn-primary the page's one main action
18
+ # secondary btn-secondary the success role, or the app's retune
19
+ # quiet btn-neutral a second action: Cancel, Back, Next
20
+ # danger btn-danger a destructive action
21
+ # size sm btn-sm · md btn · lg btn-lg
22
+ #
23
+ # Its public surface, which a release changes only with a Breaking line in the
24
+ # changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
25
+ # inputs label: (or the block's content), tone:, size:, href:, type:,
26
+ # disabled:, loading:, busy_label:, id:, title:, data:, aria:,
27
+ # and the icon slot (with_icon)
28
+ # classes .btn plus the tone's and the size's btn-* classes
29
+ # aria-* aria-busy="true" while loading; aria-disabled="true" on a
30
+ # disabled link
31
+ #
32
+ # LOADING is the engine's aria-busy contract (engine-motion.css, "Pending";
33
+ # Studio::LoaderHelper#studio_loader_pending), not a class of its own. A
34
+ # loading button is aria-busy and disabled, so it is not clickable and keeps
35
+ # its full colour (engine.css, the .btn[aria-busy] rule). A submit button
36
+ # always carries the pending loader, hidden, so a Turbo form that marks itself
37
+ # aria-busy shows it with no script here. The icon is the idle content and
38
+ # gives way to the loader; busy_label: words replace the label while busy.
39
+ # A link cannot be busy, so loading: with href: is refused.
40
+ #
41
+ # Every class string is written out whole: Tailwind finds utilities by scanning
42
+ # source text, and engine.css adds app/components to every host's scan.
43
+ class ButtonComponent < ViewComponent::Base
44
+ # studio_loader_pending, the loader the aria-busy contract shows (the plain
45
+ # spinner at 16px, in the label's colour). Included,
46
+ # not reached through helpers, so the button works in a host whose
47
+ # controllers never mixed the engine's helpers in.
48
+ include Studio::LoaderHelper
49
+
50
+ strip_trailing_whitespace
51
+
52
+ TONES = {
53
+ primary: "btn-primary",
54
+ secondary: "btn-secondary",
55
+ quiet: "btn-neutral",
56
+ danger: "btn-danger"
57
+ }.freeze
58
+
59
+ SIZES = {
60
+ sm: "btn-sm",
61
+ md: nil,
62
+ lg: "btn-lg"
63
+ }.freeze
64
+
65
+ TYPES = %w[button submit reset].freeze
66
+
67
+ # The gap between icon, loader and label; .btn is already inline-flex.
68
+ LAYOUT = "gap-2"
69
+
70
+ # A link has no disabled state of its own, so a disabled link draws it.
71
+ DISABLED_LINK = "opacity-50 cursor-not-allowed"
72
+
73
+ renders_one :icon
74
+
75
+ attr_reader :href, :busy_label
76
+
77
+ def initialize(label: nil, tone: :primary, size: :md, href: nil, type: "button", disabled: false,
78
+ loading: false, busy_label: nil, id: nil, title: nil, data: {}, aria: {})
79
+ super()
80
+ @tone = fetch_choice(TONES, tone, "tone")
81
+ @size = fetch_choice(SIZES, size, "size")
82
+ raise ArgumentError, "unknown type #{type.inspect}; expected one of #{TYPES.join(', ')}" unless TYPES.include?(type.to_s)
83
+ raise ArgumentError, "Studio::ButtonComponent cannot be loading with an href: a link is never busy" if loading && href
84
+
85
+ @label = label
86
+ @href = href
87
+ @type = type.to_s
88
+ @disabled = disabled ? true : false
89
+ @loading = loading ? true : false
90
+ @busy_label = busy_label
91
+ @id = id
92
+ @title = title
93
+ @data = data
94
+ @aria = aria
95
+ end
96
+
97
+ def before_render
98
+ raise ArgumentError, "Studio::ButtonComponent needs a label: or a block" if text.blank?
99
+ end
100
+
101
+ def link? = href.present?
102
+
103
+ def loading? = @loading
104
+
105
+ def disabled? = @disabled || @loading
106
+
107
+ def text
108
+ content.presence || @label
109
+ end
110
+
111
+ def css_classes
112
+ parts = ["btn", TONES.fetch(@tone), SIZES.fetch(@size), LAYOUT]
113
+ parts << DISABLED_LINK if link? && @disabled
114
+ parts.compact.join(" ")
115
+ end
116
+
117
+ # The pending loader rides along on a loading button and on every submit
118
+ # button, so the aria-busy contract can show it.
119
+ def pending_loader?
120
+ !link? && (loading? || @type == "submit")
121
+ end
122
+
123
+ def element_attributes
124
+ attrs = { class: css_classes, id: @id, title: @title, data: @data.presence }
125
+ aria = @aria.dup
126
+ if link?
127
+ if @disabled
128
+ aria[:disabled] = "true"
129
+ attrs[:role] = "link"
130
+ else
131
+ attrs[:href] = href
132
+ end
133
+ else
134
+ attrs[:type] = @type
135
+ attrs[:disabled] = true if disabled?
136
+ aria[:busy] = "true" if loading?
137
+ end
138
+ attrs[:aria] = aria.presence
139
+ attrs.compact
140
+ end
141
+
142
+ private
143
+
144
+ def fetch_choice(table, value, name)
145
+ key = value.to_s.to_sym
146
+ return key if table.key?(key)
147
+
148
+ raise ArgumentError, "unknown #{name} #{value.inspect}; expected one of #{table.keys.join(', ')}"
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,12 @@
1
+ <div class="<%= root_classes %>" data-empty-state>
2
+ <% if icon? %>
3
+ <div class="<%= Studio::EmptyStateComponent::ICON %>" aria-hidden="true"><%= icon %></div>
4
+ <% end %>
5
+ <p class="<%= Studio::EmptyStateComponent::MESSAGE %>"><%= message %></p>
6
+ <% if detail.present? %>
7
+ <p class="<%= Studio::EmptyStateComponent::DETAIL %>"><%= detail %></p>
8
+ <% end %>
9
+ <% if action? %>
10
+ <div class="<%= Studio::EmptyStateComponent::ACTION %>"><%= action %></div>
11
+ <% end %>
12
+ </div>
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # What a list, a table or a page says when it has nothing to show: an optional
5
+ # icon, the message, an optional line of detail, and at most one action.
6
+ #
7
+ # render Studio::EmptyStateComponent.new(message: "No errors logged yet.")
8
+ # render(Studio::EmptyStateComponent.new(detail: "Invite someone to start.")) do |empty|
9
+ # empty.with_icon { inbox_svg }
10
+ # empty.with_action(label: "Invite", href: new_invite_path)
11
+ # "No members yet."
12
+ # end
13
+ #
14
+ # The action is a Studio::ButtonComponent (with_action takes its keywords), so an
15
+ # empty state cannot grow a hand-made button. One action only: an empty state
16
+ # points at the one next step.
17
+ #
18
+ # Its public surface, which a release changes only with a Breaking line in the
19
+ # changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
20
+ # inputs message: (or the block's content), detail:, framed:, and the
21
+ # icon and action slots (with_icon, with_action)
22
+ # classes .empty-state when framed (the default)
23
+ # data-* data-empty-state on the root
24
+ #
25
+ # framed: false drops the .empty-state card for a place that already has a
26
+ # frame: the empty row of Studio::TableComponent.
27
+ #
28
+ # Type is the scale (README "Type scale"): the message at type-body, the detail
29
+ # at type-fine.
30
+ class EmptyStateComponent < ViewComponent::Base
31
+ strip_trailing_whitespace
32
+
33
+ FRAMED = "empty-state"
34
+ BARE = "px-4 py-10 text-center"
35
+ MESSAGE = "type-body text-secondary"
36
+ DETAIL = "type-fine text-muted mt-1"
37
+ ICON = "mb-3 inline-flex text-muted"
38
+ ACTION = "mt-4"
39
+
40
+ renders_one :icon
41
+ renders_one :action, Studio::ButtonComponent
42
+
43
+ attr_reader :detail
44
+
45
+ def initialize(message: nil, detail: nil, framed: true)
46
+ super()
47
+ @message = message
48
+ @detail = detail
49
+ @framed = framed ? true : false
50
+ end
51
+
52
+ def before_render
53
+ raise ArgumentError, "Studio::EmptyStateComponent needs a message: or a block" if message.blank?
54
+ end
55
+
56
+ def message
57
+ content.presence || @message
58
+ end
59
+
60
+ def root_classes
61
+ @framed ? FRAMED : BARE
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,32 @@
1
+ <div class="<%= Studio::TableComponent::WRAPPER %>" data-table>
2
+ <%= tag.table(**table_attributes) do %>
3
+ <thead class="<%= Studio::TableComponent::HEAD %>">
4
+ <tr>
5
+ <% columns.each do |column| %>
6
+ <%= tag.th(**header_attributes(column)) do %>
7
+ <% if column.sortable %>
8
+ <a href="<%= sort_href(column) %>" class="<%= Studio::TableComponent::SORT_LINK %>"><span><%= column.label %></span><% if sorted?(column) %><span class="<%= Studio::TableComponent::SORT_ARROW %>" aria-hidden="true"><%= arrow %></span><% end %></a>
9
+ <% else %>
10
+ <%= column.label %>
11
+ <% end %>
12
+ <% end %>
13
+ <% end %>
14
+ </tr>
15
+ </thead>
16
+ <tbody class="<%= Studio::TableComponent::BODY %>">
17
+ <% if rows.any? %>
18
+ <% rows.each do |row| %>
19
+ <tr class="<%= Studio::TableComponent::ROW %>">
20
+ <% columns.each do |column| %>
21
+ <td class="<%= cell_classes(column) %>"><%= cell_value(column, row) %></td>
22
+ <% end %>
23
+ </tr>
24
+ <% end %>
25
+ <% else %>
26
+ <tr data-table-empty>
27
+ <td colspan="<%= columns.size %>"><%= render Studio::EmptyStateComponent.new(message: empty, framed: false) %></td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ <% end %>
32
+ </div>
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # A data table: columns, rows, a sort hook in plain links, and an empty row.
5
+ #
6
+ # render Studio::TableComponent.new(
7
+ # id: "users-table",
8
+ # rows: @users,
9
+ # columns: [
10
+ # { key: :name, label: "User", sortable: true },
11
+ # { key: :role, label: "Role", cell: ->(user) { render Studio::BadgeComponent.new(text: user.role) } },
12
+ # { key: :created_at, label: "Joined", align: :right, cell: ->(user) { l(user.created_at.to_date) } }
13
+ # ],
14
+ # sort: params[:sort], direction: params[:direction],
15
+ # empty: "No users yet."
16
+ # )
17
+ #
18
+ # A column is a Hash: key: (required), label: (required), sortable: (false),
19
+ # align: (:left, :center or :right) and cell: (a callable that takes the row and
20
+ # returns its cell; without one the cell is row[key] for a Hash, or
21
+ # row.public_send(key)). A cell's value is escaped unless it is html_safe, so a
22
+ # cell may render a component.
23
+ #
24
+ # SORTING is links, no script. A sortable header is a link to the current URL
25
+ # with ?sort=<key>&direction=<asc|desc> (the page param dropped), the
26
+ # team_sort_url pattern from Studio::AdminModels; the column already sorted asc
27
+ # links to desc. The controller sorts. sort_url: takes a callable (key,
28
+ # direction) for an app with its own URL shape. The sorted header carries
29
+ # aria-sort and the arrow.
30
+ #
31
+ # PHONE LAYOUT is a horizontal scroll, as every engine table does today
32
+ # (admin_model_table, the emails index): the table keeps its columns and its
33
+ # wrapper scrolls. min_width: sets the width under which it scrolls.
34
+ #
35
+ # Its public surface, which a release changes only with a Breaking line in the
36
+ # changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
37
+ # inputs rows:, columns:, id:, sort:, direction:, sort_url:, sort_param:,
38
+ # direction_param:, empty:, min_width:, sticky_header:
39
+ # data-* data-table on the wrapper; data-table-sort-key on a sortable
40
+ # header; data-table-empty on the empty row; data-sticky-table-header
41
+ # on the table when sticky_header: (studio/sticky_table_header)
42
+ # aria-* aria-sort on the sorted header
43
+ #
44
+ # Every class string is written out whole: Tailwind finds utilities by scanning
45
+ # source text, and engine.css adds app/components to every host's scan.
46
+ class TableComponent < ViewComponent::Base
47
+ strip_trailing_whitespace
48
+
49
+ WRAPPER = "overflow-x-auto"
50
+ TABLE = "w-full type-body"
51
+ HEAD = "bg-surface-alt text-muted type-fine uppercase tracking-wide"
52
+ BODY = "divide-y divide-subtle"
53
+ ROW = "hover:bg-surface-alt/60 transition"
54
+ HEADER_CELL = "px-4 py-3 font-semibold"
55
+ CELL = "px-4 py-3 text-body"
56
+ SORT_LINK = "inline-flex items-center gap-1 rounded-sm hover:text-heading focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
57
+ SORT_ARROW = "text-primary-ink"
58
+
59
+ ALIGN = {
60
+ left: "text-left",
61
+ center: "text-center",
62
+ right: "text-right"
63
+ }.freeze
64
+
65
+ DIRECTIONS = %w[asc desc].freeze
66
+ ARIA_SORT = { "asc" => "ascending", "desc" => "descending" }.freeze
67
+ ARROWS = { "asc" => "↑", "desc" => "↓" }.freeze
68
+
69
+ Column = Struct.new(:key, :label, :sortable, :align, :cell, keyword_init: true)
70
+
71
+ attr_reader :rows, :columns, :empty
72
+
73
+ def initialize(rows:, columns:, id: nil, sort: nil, direction: nil, sort_url: nil, sort_param: "sort",
74
+ direction_param: "direction", empty: "Nothing here yet.", min_width: nil, sticky_header: false)
75
+ super()
76
+ raise ArgumentError, "Studio::TableComponent needs at least one column" if columns.blank?
77
+
78
+ @rows = rows.to_a
79
+ @columns = columns.map { |column| build_column(column) }
80
+ @id = id
81
+ @sort = sort.presence&.to_s
82
+ @direction = DIRECTIONS.include?(direction.to_s) ? direction.to_s : "asc"
83
+ @sort_url = sort_url
84
+ @sort_param = sort_param.to_s
85
+ @direction_param = direction_param.to_s
86
+ @empty = empty
87
+ @min_width = min_width
88
+ @sticky_header = sticky_header ? true : false
89
+ end
90
+
91
+ def table_attributes
92
+ attrs = { class: TABLE, id: @id }
93
+ attrs[:style] = "min-width: #{@min_width.to_i}px" if @min_width
94
+ attrs[:data] = { sticky_table_header: "" } if @sticky_header
95
+ attrs.compact
96
+ end
97
+
98
+ def header_attributes(column)
99
+ attrs = { scope: "col", class: [HEADER_CELL, ALIGN.fetch(column.align)].join(" ") }
100
+ if column.sortable
101
+ attrs[:data] = { table_sort_key: column.key }
102
+ attrs[:aria] = { sort: ARIA_SORT.fetch(@direction) } if sorted?(column)
103
+ end
104
+ attrs
105
+ end
106
+
107
+ def cell_classes(column)
108
+ [CELL, ALIGN.fetch(column.align)].join(" ")
109
+ end
110
+
111
+ def sorted?(column) = @sort == column.key
112
+
113
+ def arrow = ARROWS.fetch(@direction)
114
+
115
+ # The next direction for a header link: the sorted column flips, any other
116
+ # starts ascending.
117
+ def next_direction(column)
118
+ sorted?(column) && @direction == "asc" ? "desc" : "asc"
119
+ end
120
+
121
+ def sort_href(column)
122
+ direction = next_direction(column)
123
+ return @sort_url.call(column.key, direction) if @sort_url
124
+
125
+ query = request.query_parameters.merge(@sort_param => column.key, @direction_param => direction)
126
+ query.delete("page")
127
+ "#{request.path}?#{query.to_query}"
128
+ end
129
+
130
+ def cell_value(column, row)
131
+ return column.cell.call(row) if column.cell
132
+ return row[column.key.to_sym] || row[column.key] if row.is_a?(Hash)
133
+
134
+ row.public_send(column.key)
135
+ end
136
+
137
+ private
138
+
139
+ def build_column(column)
140
+ column = column.to_h.symbolize_keys
141
+ unknown = column.keys - Column.members
142
+ raise ArgumentError, "unknown column key #{unknown.first.inspect}; expected #{Column.members.join(', ')}" if unknown.any?
143
+ raise ArgumentError, "Studio::TableComponent: every column takes key: and label:" if column[:key].blank? || column[:label].blank?
144
+
145
+ align = (column[:align] || :left).to_sym
146
+ raise ArgumentError, "unknown align #{align.inspect}; expected one of #{ALIGN.keys.join(', ')}" unless ALIGN.key?(align)
147
+
148
+ Column.new(key: column[:key].to_s, label: column[:label], sortable: column[:sortable] ? true : false,
149
+ align: align, cell: column[:cell])
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # GET /_studio/modals/:id: one on-demand modal's content partial, for
5
+ # studio/modal_host to insert into the shared host the first time the modal
6
+ # opens (Studio.modals, README "Modal host", "Modals on demand").
7
+ #
8
+ # THE ALLOWLIST IS THE REGISTRY. Only an id Studio.modals names renders; any
9
+ # other id answers 404, so the endpoint can never be used to render an
10
+ # arbitrary partial by name.
11
+ #
12
+ # AUTH. The host's require_authentication is skipped so an :any modal (the
13
+ # sign-in card) serves a signed-out browser. A :user modal answers a bare
14
+ # 401 to a signed-out request rather than the engine's HTML redirect to
15
+ # /login: the caller is fetch(), and a followed redirect would hand it the
16
+ # login PAGE to insert as a modal. Host filters that still run (a revoked
17
+ # session token, an onboarding redirect) answer as they do for any request;
18
+ # the X-Studio-Modal header below is how the browser tells a real fragment
19
+ # from whatever such a filter sent instead.
20
+ #
21
+ # CACHING. The fragment can carry the viewer's data and a form's CSRF token,
22
+ # so it is private: Rails' default for a dynamic GET (max-age=0, private,
23
+ # must-revalidate, with an ETag) is kept, and Vary: Cookie is added so no
24
+ # cache answers one session with another's fragment. The browser holds the
25
+ # fragment for the page's lifetime anyway (studio/modal_host).
26
+ class ModalsController < ::ApplicationController
27
+ skip_before_action :require_authentication, raise: false
28
+
29
+ def show
30
+ entry = Studio.modals[params[:id]]
31
+ return head(:not_found) unless entry
32
+ return head(:unauthorized) if entry.user_only? && !studio_modal_viewer_signed_in?
33
+
34
+ @studio_modal = entry
35
+ render "studio/modals/remote", layout: false, formats: [:html]
36
+ # Stamped AFTER the render, so a partial that raises leaves no header on
37
+ # the host's error page for the browser to mistake for a fragment.
38
+ response.headers["X-Studio-Modal"] = entry.id
39
+ response.headers["Vary"] = [response.headers["Vary"], "Cookie"].compact.join(", ")
40
+ end
41
+
42
+ private
43
+
44
+ def studio_modal_viewer_signed_in?
45
+ respond_to?(:logged_in?, true) && logged_in?
46
+ end
47
+ end
48
+ end
@@ -232,6 +232,58 @@ export function seamJump(virtual, total) {
232
232
  return virtual < 0 || virtual >= total ? wrapIndex(virtual, total) : null
233
233
  }
234
234
 
235
+ // -- the row's rest ----------------------------------------------------------------
236
+
237
+ // No scroll for this long is the row's rest, where the browser has no scrollend event.
238
+ export const SETTLE_MS = 150
239
+ // Where the browser has scrollend, this long after the last scroll event is the
240
+ // backstop: a scroll that scrollend never follows still rests. Chrome can send one
241
+ // scroll event and no scrollend, as when the page shortens and the browser pulls the
242
+ // scroll back; a looping row left on a clone then never jumped home and never fired
243
+ // its change event. Three quiet spells, so a slow frame mid-glide (260ms at 8x CPU,
244
+ // as studio/in_page_links measured) does not end the glide: settle() jumps only from
245
+ // a clone's own stop, so a rest read early does nothing.
246
+ export const REST_CAP_MS = SETTLE_MS * 3
247
+
248
+ // Calls onRest each time the track rests: on scrollend where the browser has it, with
249
+ // REST_CAP_MS after each scroll event as the backstop, and SETTLE_MS after the last
250
+ // scroll event where it does not. After an aim (aimed(): the controller's scrollTo),
251
+ // scrollend counts only once a scroll event has come since that aim: Chrome can send
252
+ // the end of the scroll the aim replaced before the new scroll's first frame. One
253
+ // rest per scroll: a scrollend after the backstop fired, or the backstop after
254
+ // scrollend, finds nothing to end. stop() removes the listeners and the timer.
255
+ export function watchRest(track, onRest, { scrollend = false, timers = globalThis } = {}) {
256
+ let timer = null
257
+ let scrolled = false
258
+ let pending = false
259
+ const rest = () => {
260
+ timers.clearTimeout(timer)
261
+ timer = null
262
+ if (!pending) return
263
+ pending = false
264
+ onRest()
265
+ }
266
+ const onScroll = () => {
267
+ scrolled = true
268
+ pending = true
269
+ timers.clearTimeout(timer)
270
+ timer = timers.setTimeout(rest, scrollend ? REST_CAP_MS : SETTLE_MS)
271
+ }
272
+ const onScrollend = () => { if (scrolled) rest() }
273
+ track.addEventListener("scroll", onScroll, { passive: true })
274
+ if (scrollend) track.addEventListener("scrollend", onScrollend, { passive: true })
275
+ return {
276
+ aimed() { scrolled = false },
277
+ stop() {
278
+ timers.clearTimeout(timer)
279
+ timer = null
280
+ pending = false
281
+ track.removeEventListener("scroll", onScroll)
282
+ if (scrollend) track.removeEventListener("scrollend", onScrollend)
283
+ }
284
+ }
285
+ }
286
+
235
287
  // The attributes a clone drops: ids and names (a clone must not answer a #fragment or
236
288
  // join a form), test hooks, and Stimulus wiring (a clone runs no controller and is
237
289
  // no one's target), and the carousel's own marks.
@@ -30,8 +30,8 @@
30
30
  // continuous. connect() copies the last slides before the first and the first slides
31
31
  // after the last (cloneLayout), so the space beside either end shows the slides
32
32
  // round the seam. Next at the last slide glides on to the clone of the first;
33
- // once the row rests there (scrollend, or a short quiet spell where the browser has
34
- // no scrollend) it jumps, with no glide and no transition, to the real first slide,
33
+ // once the row rests there (studio/carousel watchRest: scrollend with a backstop, or
34
+ // a short quiet spell where the browser has no scrollend) it jumps, with no glide and no transition, to the real first slide,
35
35
  // whose neighbours look the same. Previous at the first does the same backwards, and
36
36
  // a swipe that rests on a clone too. Under reduced motion the row goes straight to
37
37
  // the real slide. Neither arrow is ever aria-disabled.
@@ -77,7 +77,7 @@ import {
77
77
  CHANGE_EVENT, CLONE_STRIP, TOLERANCE, autoplayRunning, changeDetail, clickedSlide, autoplaySeconds, autoplayStop, cascadeJumps, cascadeReach, cascadeSlot,
78
78
  cascadeStep, cascadeSteps, cascadeTarget, centerStops, cloneCount, cloneLayout, dragSteps,
79
79
  currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
80
- perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
80
+ perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, watchRest, withReason, wrapIndex
81
81
  } from "studio/carousel"
82
82
 
83
83
  const REDUCE = "(prefers-reduced-motion: reduce)"
@@ -97,9 +97,8 @@ export default class extends Controller {
97
97
  this.connectCascade()
98
98
  return
99
99
  }
100
- this.scrollEnd = "onscrollend" in window
101
100
  this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
102
- if (this.scrollEnd) this.trackTarget.addEventListener("scrollend", this.settle, { passive: true })
101
+ this.rest = watchRest(this.trackTarget, this.settle, { scrollend: "onscrollend" in window })
103
102
  this.trackTarget.addEventListener("click", this.clickClone)
104
103
  if (typeof ResizeObserver === "function") {
105
104
  this.observer = new ResizeObserver(this.schedule)
@@ -331,13 +330,12 @@ export default class extends Controller {
331
330
  disconnect() {
332
331
  if (this.cascade) this.disconnectCascade()
333
332
  this.trackTarget.removeEventListener("scroll", this.schedule)
334
- this.trackTarget.removeEventListener("scrollend", this.settle)
333
+ this.rest?.stop()
334
+ this.rest = null
335
335
  this.trackTarget.removeEventListener("click", this.clickClone)
336
336
  this.observer?.disconnect()
337
337
  if (this.frame) cancelAnimationFrame(this.frame)
338
338
  this.frame = null
339
- clearTimeout(this.settleTimer)
340
- this.settleTimer = null
341
339
  this.stopAutoplay()
342
340
  this.removeClones()
343
341
  }
@@ -389,7 +387,6 @@ export default class extends Controller {
389
387
  // The row came to rest. On a clone it jumps to the real slide the clone copies,
390
388
  // at the same place in the row, so nothing on screen changes.
391
389
  settle() {
392
- this.settleTimer = null
393
390
  if (!this.cloned) return
394
391
  this.sync()
395
392
  const real = seamJump(this.virtual, this.positions.length)
@@ -524,10 +521,6 @@ export default class extends Controller {
524
521
  }
525
522
 
526
523
  schedule() {
527
- if (this.cloned && !this.scrollEnd) {
528
- clearTimeout(this.settleTimer)
529
- this.settleTimer = setTimeout(this.settle, 150)
530
- }
531
524
  if (this.frame) return
532
525
  this.frame = requestAnimationFrame(() => {
533
526
  this.frame = null
@@ -660,6 +653,7 @@ export default class extends Controller {
660
653
  const real = wrapIndex(index, total)
661
654
  const left = reduce ? this.positions[real] : this.full[index + this.cloned]
662
655
  if (left === undefined) return
656
+ this.rest?.aimed()
663
657
  this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
664
658
  if (announce && this.hasStatusTarget) this.statusTarget.textContent = loopPositionText(real, this.page, total)
665
659
  return