active_storage-async_variants-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +24 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Appraisals +11 -0
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +89 -0
- data/Rakefile +27 -0
- data/app/assets/javascripts/progress-bar.js +347 -0
- data/app/assets/javascripts/retry.js +11 -0
- data/app/assets/stylesheets/progress.css +35 -0
- data/app/assets/stylesheets/retry.css +98 -0
- data/app/controllers/active_storage/async_variants/states_controller.rb +71 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +44 -0
- data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
- data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
- data/app/views/active_storage/async_variants/states/_processing.html.erb +15 -0
- data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
- data/config/routes.rb +20 -0
- data/features/retry_flow.feature +20 -0
- data/features/retry_visibility.feature +23 -0
- data/features/state_rendering.feature +40 -0
- data/features/step_definitions/dummy_steps.rb +144 -0
- data/features/support/env.rb +28 -0
- data/gemfiles/rails_7.2.gemfile +18 -0
- data/gemfiles/rails_8.0.gemfile +18 -0
- data/gemfiles/rails_8.1.gemfile +18 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +59 -0
- data/lib/active_storage/async_variants/helper.rb +80 -0
- data/lib/active_storage/async_variants/ui/version.rb +9 -0
- data/lib/active_storage/async_variants/ui.rb +45 -0
- data/log/.gitkeep +0 -0
- metadata +118 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 279f3c8402b7c582403983ff679ecf6fead34d5a90e72f8952f27647ce9f1a5b
|
|
4
|
+
data.tar.gz: d931c539a3661e61c0f9fa20a144d399f8f10910169fb5aed75206cdda09c99a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b58f2d6f42a0122f79ea9d1c831cd53476353be2f2a98666bd0983919343a92dd20b4045c6803246ddc458aaa9e912412eee3baa5362d7fb446a418abdc206e8
|
|
7
|
+
data.tar.gz: 5e6a2837652e6e17388f3abf00e22a087ebe1cb6ca5e7d383e6a8a2a1d43f7f800ac78a05541cb91ecf95692bcbfa6a843b730842719588abc7dff6d92c282d7
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
ruby: ["3.3", "3.4", "4.0"]
|
|
16
|
+
rails: ["rails-7.2", "rails-8.0", "rails-8.1"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: ruby/setup-ruby@v1
|
|
20
|
+
with:
|
|
21
|
+
ruby-version: ${{ matrix.ruby }}
|
|
22
|
+
bundler-cache: true
|
|
23
|
+
- run: bundle exec appraisal ${{ matrix.rails }} bundle install
|
|
24
|
+
- run: bundle exec appraisal ${{ matrix.rails }} rake
|
data/.ruby-gemset
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
active_storage-async_variants-ui
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby-3.4.2
|
data/Appraisals
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## [0.1.0]
|
|
2
|
+
|
|
3
|
+
- Initial release. Extracted from `active_storage-async_variants` (≤ 0.8.0), which now ships the UI-free plumbing. This gem layers on the opinionated UI:
|
|
4
|
+
- `image_tag` / `video_tag` with `async:` and `direct:` (via `AssetTagHelperExtension`).
|
|
5
|
+
- The `<turbo-frame>` state endpoint (`StatesController`) and its `_processing` / `_processed` / `_failed` partials, including the self-polling reload and the filename-bearing 1x1 GIF placeholder route.
|
|
6
|
+
- The circular progress bar (`@botandrose/progress-bar`, with `rate`/`error` states) and the failed-state retry dialog, served through `isolate_assets`.
|
|
7
|
+
- Configuration: `cdn_host`, `parent_controller`, and `retry_visible_if` (reopened onto `ActiveStorage::AsyncVariants`).
|
|
8
|
+
- Depends on `active_storage-async_variants` (>= 0.9), `turbo-rails`, and `isolate_assets`. Apps that previously used the full `active_storage-async_variants` for its UI should add this gem.
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What This Is
|
|
6
|
+
|
|
7
|
+
The opinionated UI layer for `active_storage-async_variants` (the plumbing gem, a sibling checkout at `../active_storage-async_variants`). The plumbing gem is UI-free; this gem adds `image_tag`/`video_tag` rendering, a `<turbo-frame>` state endpoint, a circular progress bar, and a retry affordance. It reopens `ActiveStorage::AsyncVariants` (rather than introducing a new namespace) but defines its own engine class, `ActiveStorage::AsyncVariants::UIEngine`, since two engines can't share a class.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle exec rake # rspec + cucumber (default task is :all)
|
|
13
|
+
bundle exec rspec # Run all specs
|
|
14
|
+
bundle exec cucumber features/state_rendering.feature # one browser feature
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The `Gemfile` resolves the plumbing gem from `../active_storage-async_variants` via a path override, so local changes there are picked up without a release.
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
Loaded via `lib/active_storage/async_variants/ui.rb`, which requires the plumbing gem first, then:
|
|
22
|
+
|
|
23
|
+
- **`AssetTagHelperExtension`** → prepended onto `ActionView::Helpers::AssetTagHelper` — adds `async:`/`direct:` to `image_tag`/`video_tag`; routes unprocessed variants through a `<turbo-frame>`.
|
|
24
|
+
- **`Helper`** — shared view/controller helpers (frame id/src, resolved src, placeholder tag).
|
|
25
|
+
- **`StatesController`** (`app/controllers/...`) — renders `_processing`/`_processed`/`_failed` partials at `GET /active_storage/async_variants/states/:signed_blob_id/:variation_key`, plus a `retry` action and a filename-bearing 1x1 GIF `placeholder` action. Its parent class is `ActiveStorage::AsyncVariants.parent_controller`.
|
|
26
|
+
- **Assets** — `progress-bar.js` / `retry.js` / `progress.css` / `retry.css` served via `isolate_assets` (`Assets.draw` in `config/routes.rb`).
|
|
27
|
+
- **Config** — `cdn_host`, `parent_controller`, `retry_visible_if`, `PASS_THROUGH_HTML_OPTIONS` (defined in `ui.rb`, reopening `ActiveStorage::AsyncVariants`).
|
|
28
|
+
|
|
29
|
+
The state machine, `ProcessJob`, callbacks endpoint, `Transformer`, and `VariantRecord` columns all live in the plumbing gem.
|
|
30
|
+
|
|
31
|
+
## Testing
|
|
32
|
+
|
|
33
|
+
- RSpec specs in `spec/active_storage/` (view helpers, states controller, configuration) + browser-level cucumber features in `features/` (capybara + cuprite against `spec/dummy`).
|
|
34
|
+
- SQLite in-memory schema in `spec/support/schema.rb`; `minimum_coverage 100` is enforced over this gem's `lib`.
|
|
35
|
+
- Jobs use the `:test` queue adapter.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Micah Geisel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# ActiveStorage::AsyncVariants::UI
|
|
2
|
+
|
|
3
|
+
The opinionated UI layer for [`active_storage-async_variants`](https://github.com/botandrose/active_storage-async_variants): `image_tag` / `video_tag` rendering with `async:` / `direct:`, a `<turbo-frame>` that polls a processing variant to completion, a circular progress bar (with an error state), and an optional retry affordance — all served through an isolated asset pipeline.
|
|
4
|
+
|
|
5
|
+
The plumbing gem (`active_storage-async_variants`) is intentionally UI-free: a variant's `.url` serves the original while pending/processing/failed and the processed variant once ready. Add **this** gem when you want the turbo-frame/progress-bar/retry experience on top.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "active_storage-async_variants-ui"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This pulls in `active_storage-async_variants` (the plumbing) as a dependency. Run its migrations as documented there:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bin/rails active_storage_async_variants:install:migrations
|
|
17
|
+
bin/rails db:migrate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The only other runtime requirement is **Turbo** — the gem depends on `turbo-rails`, which a default Rails app already includes. No manual JS/CSS wiring: the async state partials are self-contained `<turbo-frame>`s and their CSS/JS are served from an isolated asset route.
|
|
21
|
+
|
|
22
|
+
## `image_tag` / `video_tag` with `async:` and `direct:`
|
|
23
|
+
|
|
24
|
+
This gem adds two options to `image_tag` and `video_tag`:
|
|
25
|
+
|
|
26
|
+
```erb
|
|
27
|
+
<%# A progress bar while pending/processing, polls for completion,
|
|
28
|
+
swaps to the real image when ready. %>
|
|
29
|
+
<%= image_tag user.avatar.variant(:web), async: true %>
|
|
30
|
+
|
|
31
|
+
<%# When the variant is ready, render its direct CDN/S3 URL instead of
|
|
32
|
+
routing through Rails. While pending/failed, falls back to the
|
|
33
|
+
Rails representation URL (which serves the original). %>
|
|
34
|
+
<%= image_tag user.avatar.variant(:web), direct: true %>
|
|
35
|
+
|
|
36
|
+
<%# Both together: direct URL once processed, progress bar while pending. %>
|
|
37
|
+
<%= image_tag user.avatar.variant(:web), async: true, direct: true %>
|
|
38
|
+
|
|
39
|
+
<%# Same for videos. %>
|
|
40
|
+
<%= video_tag user.video.variant(:web), async: true, controls: true %>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The first argument must be a `VariantWithRecord` or `Preview` when either option is set; otherwise `image_tag` / `video_tag` behave exactly as in stock Rails.
|
|
44
|
+
|
|
45
|
+
While a variant is processing, the partial renders a zero-network sized box (a tiny filename-bearing 1x1 GIF, or a source-less `<video>`) with the circular progress bar floating over it — indeterminate until progress is reported, then a determinate percentage that creeps forward between polls. Once a variant permanently fails, the same progress bar renders in its error state (a red ring with an X).
|
|
46
|
+
|
|
47
|
+
### Configure the direct URL host
|
|
48
|
+
|
|
49
|
+
By default `direct:` uses the storage service's URL. To serve from a CDN, set the host:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# config/initializers/active_storage_async_variants.rb
|
|
53
|
+
ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The resulting URL is `"#{cdn_host}/#{variant.key}"`.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
This gem reopens `ActiveStorage::AsyncVariants`, so its options sit alongside the plumbing gem's in a single `configure` block (each is also a plain accessor):
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# config/initializers/active_storage_async_variants.rb
|
|
64
|
+
ActiveStorage::AsyncVariants.configure do |config|
|
|
65
|
+
config.cdn_host = "https://d1234abcd.cloudfront.net"
|
|
66
|
+
config.parent_controller = "ApplicationController"
|
|
67
|
+
config.retry_visible_if { current_user&.admin? }
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Option | Default | Purpose |
|
|
72
|
+
|--------|---------|---------|
|
|
73
|
+
| `cdn_host` | `nil` | Host for `direct:` URLs (`"#{cdn_host}/#{variant.key}"`); falls back to the storage service URL. |
|
|
74
|
+
| `parent_controller` | `"ActionController::Base"` | Base class for the gem's `StatesController`, so the retry view can reach your app's `current_user`. Set as a String. |
|
|
75
|
+
| `retry_visible_if` | off | Block (run in the view context) gating the failed-state retry affordance. |
|
|
76
|
+
|
|
77
|
+
The `heartbeat_interval` (poll cadence) and `heartbeat_stale_after` options live in the plumbing gem; the processing `<turbo-frame>` re-polls at `heartbeat_interval`.
|
|
78
|
+
|
|
79
|
+
## Retry affordance
|
|
80
|
+
|
|
81
|
+
When `retry_visible_if` returns truthy, a failed variant reveals (on hover) a control that opens a dialog with the error and a "Retry processing" button. The button destroys the failed `VariantRecord` and re-enqueues the transform via the variant's `#enqueue!`. The dialog's CSS/JS are served as cached assets from `/active_storage/async_variants/assets/…`.
|
|
82
|
+
|
|
83
|
+
## Overriding the partials
|
|
84
|
+
|
|
85
|
+
Apps can override any state partial by creating a same-named file in their own `app/views/active_storage/async_variants/states/` (`_processing`, `_processed`, `_failed`, `show`).
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
desc "Run browser-level acceptance tests (cucumber + cuprite against spec/dummy)"
|
|
9
|
+
task :cucumber do
|
|
10
|
+
sh "bundle exec cucumber"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "Run rspec and cucumber"
|
|
14
|
+
task all: [:spec, :cucumber]
|
|
15
|
+
|
|
16
|
+
namespace :vendor do
|
|
17
|
+
desc "Pull the latest @botandrose/progress-bar from unpkg.com into app/assets"
|
|
18
|
+
task :progress_bar do
|
|
19
|
+
require "open-uri"
|
|
20
|
+
url = "https://unpkg.com/@botandrose/progress-bar"
|
|
21
|
+
dest = File.expand_path("app/assets/javascripts/progress-bar.js", __dir__)
|
|
22
|
+
File.write(dest, URI.open(url).read)
|
|
23
|
+
puts "Vendored #{url} -> #{dest}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
task default: :all
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
|
|
2
|
+
const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
|
|
3
|
+
const RATE_HZ = 30; // optimistic auto-advance tick frequency
|
|
4
|
+
|
|
5
|
+
const STYLES = `
|
|
6
|
+
:host {
|
|
7
|
+
--progress-color: #2E7D32;
|
|
8
|
+
--error-color: #7a242f;
|
|
9
|
+
--indeterminate-color: #999;
|
|
10
|
+
--track-color: #333333;
|
|
11
|
+
--progress-duration: 120ms;
|
|
12
|
+
--indeterminate-duration: 1.5s;
|
|
13
|
+
--bar-height: 32px;
|
|
14
|
+
--bar-padding: 8px;
|
|
15
|
+
--circular-size: 64px;
|
|
16
|
+
--circular-thickness: 16;
|
|
17
|
+
display: block;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
background: var(--track-color);
|
|
20
|
+
border: 1px solid #999;
|
|
21
|
+
border-radius: 4px;
|
|
22
|
+
min-height: var(--bar-height);
|
|
23
|
+
padding: var(--bar-padding);
|
|
24
|
+
font-size: 13px;
|
|
25
|
+
align-content: center;
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
position: relative;
|
|
28
|
+
}
|
|
29
|
+
.bar {
|
|
30
|
+
position: absolute;
|
|
31
|
+
top: 0;
|
|
32
|
+
left: 0;
|
|
33
|
+
height: 100%;
|
|
34
|
+
width: 0%;
|
|
35
|
+
background: var(--progress-color);
|
|
36
|
+
transition: width var(--progress-duration) ease;
|
|
37
|
+
z-index: 1;
|
|
38
|
+
}
|
|
39
|
+
.text{position: relative; z-index: 2;}
|
|
40
|
+
:host([error]) .bar {
|
|
41
|
+
background: var(--error-color);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Indeterminate (linear): no percent set — a fixed-width segment sweeping across the track. */
|
|
45
|
+
:host(:not([percent])) .bar {
|
|
46
|
+
background: var(--indeterminate-color);
|
|
47
|
+
width: 40%;
|
|
48
|
+
animation: indeterminate-linear var(--indeterminate-duration) infinite linear;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@keyframes indeterminate-linear {
|
|
52
|
+
0% { transform: translateX(-100%); }
|
|
53
|
+
100% { transform: translateX(250%); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Circular mode: the host box styling is for the linear track, so drop it. */
|
|
57
|
+
:host([mode="circular"]) {
|
|
58
|
+
background: transparent;
|
|
59
|
+
border: none;
|
|
60
|
+
overflow: visible;
|
|
61
|
+
padding: 0;
|
|
62
|
+
min-height: 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.circular {
|
|
66
|
+
position: relative;
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
width: var(--circular-size);
|
|
69
|
+
height: var(--circular-size);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.ring {
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: 100%;
|
|
75
|
+
transform: rotate(-90deg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ring circle {
|
|
79
|
+
fill: none;
|
|
80
|
+
stroke-width: var(--circular-thickness);
|
|
81
|
+
/* Shrink the radius so the stroke's outer edge always lands inside the
|
|
82
|
+
100x100 viewBox, no matter how thick --circular-thickness is. */
|
|
83
|
+
r: calc(49px - var(--circular-thickness) * 0.5px);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.track {
|
|
87
|
+
stroke: var(--track-color);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.progress-ring {
|
|
91
|
+
stroke: var(--progress-color);
|
|
92
|
+
stroke-linecap: round;
|
|
93
|
+
stroke-dasharray: ${PATH_LENGTH};
|
|
94
|
+
stroke-dashoffset: ${PATH_LENGTH};
|
|
95
|
+
transition: stroke-dashoffset var(--progress-duration) ease;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
:host(:not([percent])) .progress-ring {
|
|
99
|
+
stroke: var(--indeterminate-color);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* The X of the error glyph; revealed by [error]. */
|
|
103
|
+
.error-mark {
|
|
104
|
+
display: none;
|
|
105
|
+
fill: none;
|
|
106
|
+
stroke: var(--error-color);
|
|
107
|
+
stroke-width: var(--circular-thickness);
|
|
108
|
+
stroke-linecap: round;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.label {
|
|
112
|
+
position: absolute;
|
|
113
|
+
inset: 0;
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
color: #fff;
|
|
118
|
+
mix-blend-mode: difference;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* When the host carries its own inline styling (e.g. a color override), opt
|
|
122
|
+
out of the blend trick and just inherit, so the override wins predictably. */
|
|
123
|
+
:host([style]) .label {
|
|
124
|
+
mix-blend-mode: initial;
|
|
125
|
+
color: inherit;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Indeterminate (circular): no percent set — spin a fixed arc around the ring. */
|
|
129
|
+
:host(:not([percent])) .ring {
|
|
130
|
+
animation: indeterminate-rotate var(--indeterminate-duration) infinite linear;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
:host(:not([percent])) .progress-ring {
|
|
134
|
+
stroke-dashoffset: ${PATH_LENGTH * 0.75};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@keyframes indeterminate-rotate {
|
|
138
|
+
0% { transform: rotate(-90deg); }
|
|
139
|
+
100% { transform: rotate(270deg); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Error (circular): static full ring + X. Last so it beats the indeterminate rules. */
|
|
143
|
+
:host([error]) .track { stroke: var(--error-color); }
|
|
144
|
+
:host([error]) .progress-ring { display: none; }
|
|
145
|
+
:host([error]) .ring { animation: none; }
|
|
146
|
+
:host([error]) .error-mark { display: block; }
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const LINEAR_HTML = `
|
|
150
|
+
<div class="bar"></div>
|
|
151
|
+
<div class="text"><slot></slot></div>
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const CIRCULAR_HTML = `
|
|
155
|
+
<div class="circular">
|
|
156
|
+
<svg class="ring" viewBox="0 0 100 100">
|
|
157
|
+
<circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
|
|
158
|
+
<circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
|
|
159
|
+
<path class="error-mark" d="M37 37 L63 63 M63 37 L37 63"></path>
|
|
160
|
+
</svg>
|
|
161
|
+
<span class="label"><slot></slot></span>
|
|
162
|
+
</div>
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
let styleSheet = null;
|
|
166
|
+
function getStyleSheet() {
|
|
167
|
+
if (!styleSheet) {
|
|
168
|
+
styleSheet = new CSSStyleSheet();
|
|
169
|
+
styleSheet.replaceSync(STYLES);
|
|
170
|
+
}
|
|
171
|
+
return styleSheet;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clampPercent(value) {
|
|
175
|
+
const number = Number(value);
|
|
176
|
+
if (Number.isNaN(number)) {
|
|
177
|
+
throw new TypeError(`progress-bar: percent must be numeric, got ${JSON.stringify(value)}`);
|
|
178
|
+
}
|
|
179
|
+
return Math.min(100, Math.max(0, number));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseRate(value) {
|
|
183
|
+
const number = Number(value);
|
|
184
|
+
if (Number.isNaN(number)) {
|
|
185
|
+
throw new TypeError(`progress-bar: rate must be numeric, got ${JSON.stringify(value)}`);
|
|
186
|
+
}
|
|
187
|
+
return number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
class ProgressBar extends HTMLElement {
|
|
191
|
+
constructor() {
|
|
192
|
+
super();
|
|
193
|
+
this.attachShadow({ mode: 'open' });
|
|
194
|
+
this._percent = null;
|
|
195
|
+
this._renderedMode = null;
|
|
196
|
+
this._rateTimer = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
connectedCallback() {
|
|
200
|
+
this.render();
|
|
201
|
+
this.setAttribute('role', 'progressbar');
|
|
202
|
+
this.setAttribute('aria-valuemin', '0');
|
|
203
|
+
this.setAttribute('aria-valuemax', '100');
|
|
204
|
+
this.updateBar();
|
|
205
|
+
this._syncTicker();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
disconnectedCallback() {
|
|
209
|
+
this._stopTicker();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
get percent() {
|
|
213
|
+
return this._percent;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
set percent(value) {
|
|
217
|
+
if (value === null || value === undefined) {
|
|
218
|
+
this.removeAttribute('percent');
|
|
219
|
+
} else {
|
|
220
|
+
this.setAttribute('percent', clampPercent(value));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
get error() {
|
|
225
|
+
return this.hasAttribute('error');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
set error(value) {
|
|
229
|
+
this.toggleAttribute('error', Boolean(value));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
get rate() {
|
|
233
|
+
const value = this.getAttribute('rate');
|
|
234
|
+
return value === null ? null : Number(value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
set rate(value) {
|
|
238
|
+
if (value === null || value === undefined) {
|
|
239
|
+
this.removeAttribute('rate');
|
|
240
|
+
} else {
|
|
241
|
+
this.setAttribute('rate', parseRate(value));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get indeterminate() {
|
|
246
|
+
return !this.hasAttribute('percent');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
get mode() {
|
|
250
|
+
return this.getAttribute('mode') === 'circular' ? 'circular' : 'linear';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
set mode(value) {
|
|
254
|
+
this.setAttribute('mode', value);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static get observedAttributes() {
|
|
258
|
+
return ['percent', 'mode', 'rate'];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
262
|
+
if (name === 'percent') {
|
|
263
|
+
this._percent = newValue === null ? null : clampPercent(newValue);
|
|
264
|
+
}
|
|
265
|
+
if (name === 'rate' && newValue !== null) {
|
|
266
|
+
parseRate(newValue);
|
|
267
|
+
}
|
|
268
|
+
if (name === 'mode') {
|
|
269
|
+
this.render();
|
|
270
|
+
}
|
|
271
|
+
if (name === 'percent' || name === 'rate') {
|
|
272
|
+
this._syncTicker();
|
|
273
|
+
}
|
|
274
|
+
this.updateBar();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateBar() {
|
|
278
|
+
if (this.mode === 'circular') {
|
|
279
|
+
const ring = this.shadowRoot?.querySelector('.progress-ring');
|
|
280
|
+
if (ring) {
|
|
281
|
+
ring.style.strokeDashoffset = this.indeterminate
|
|
282
|
+
? ''
|
|
283
|
+
: String(PATH_LENGTH * (1 - this._percent / 100));
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
const bar = this.shadowRoot?.querySelector('.bar');
|
|
287
|
+
if (bar) {
|
|
288
|
+
// Leave width to the indeterminate CSS animation when unknown.
|
|
289
|
+
bar.style.width = this.indeterminate ? '' : `${this._percent}%`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (this.indeterminate) {
|
|
294
|
+
this.removeAttribute('aria-valuenow');
|
|
295
|
+
} else {
|
|
296
|
+
this.setAttribute('aria-valuenow', String(this._percent));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Optimistic auto-advance: while a nonzero rate is set on a determinate bar,
|
|
301
|
+
// creep percent forward on its own. Indeterminate bars ignore rate entirely.
|
|
302
|
+
_syncTicker() {
|
|
303
|
+
const rate = Number(this.getAttribute('rate'));
|
|
304
|
+
const shouldRun = this.isConnected && !this.indeterminate && Number.isFinite(rate) && rate !== 0;
|
|
305
|
+
if (shouldRun && this._rateTimer === null) {
|
|
306
|
+
this._rateTimer = setInterval(() => this._tick(), 1000 / RATE_HZ);
|
|
307
|
+
} else if (!shouldRun) {
|
|
308
|
+
this._stopTicker();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_stopTicker() {
|
|
313
|
+
if (this._rateTimer !== null) {
|
|
314
|
+
clearInterval(this._rateTimer);
|
|
315
|
+
this._rateTimer = null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_tick() {
|
|
320
|
+
const rate = Number(this.getAttribute('rate'));
|
|
321
|
+
const next = clampPercent(this._percent + rate / RATE_HZ);
|
|
322
|
+
if (next !== this._percent) {
|
|
323
|
+
this.percent = next;
|
|
324
|
+
}
|
|
325
|
+
// Hitting the bound it's heading toward retires the rate; a later percent
|
|
326
|
+
// change can re-arm it.
|
|
327
|
+
if (next === 0 || next === 100) {
|
|
328
|
+
this.removeAttribute('rate');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
render() {
|
|
333
|
+
const mode = this.mode;
|
|
334
|
+
if (this._renderedMode === mode) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this._renderedMode = mode;
|
|
338
|
+
this.shadowRoot.adoptedStyleSheets = [getStyleSheet()];
|
|
339
|
+
this.shadowRoot.innerHTML = mode === 'circular' ? CIRCULAR_HTML : LINEAR_HTML;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!customElements.get('progress-bar')) {
|
|
344
|
+
customElements.define('progress-bar', ProgressBar);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export default ProgressBar;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Declarative shadow roots are only attached by the initial HTML parser, not by
|
|
2
|
+
// fragment insertion (innerHTML / Turbo frame swaps), so promote them on connect.
|
|
3
|
+
customElements.define("async-variant-retry", class extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
if (this.shadowRoot) return
|
|
6
|
+
const template = this.querySelector(":scope > template[shadowrootmode]")
|
|
7
|
+
if (!template) return
|
|
8
|
+
this.attachShadow({ mode: template.getAttribute("shadowrootmode") }).append(template.content)
|
|
9
|
+
template.remove()
|
|
10
|
+
}
|
|
11
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* turbo-frame is inline by default; make it and the wrapper block-level so the
|
|
2
|
+
media box (and the progress bar centered on it) coincides with the parent
|
|
3
|
+
figure box, instead of sitting in a taller line box from the baseline gap --
|
|
4
|
+
otherwise an overlay centered on the figure lands off-center from the ring. */
|
|
5
|
+
turbo-frame:has(> .async-variant-processing),
|
|
6
|
+
turbo-frame:has(> .async-variant-failed) {
|
|
7
|
+
display: block;
|
|
8
|
+
}
|
|
9
|
+
.async-variant-processing,
|
|
10
|
+
.async-variant-failed {
|
|
11
|
+
position: relative;
|
|
12
|
+
display: block;
|
|
13
|
+
}
|
|
14
|
+
/* Present to reserve the variant's box, but rendered invisible via opacity
|
|
15
|
+
rather than visibility/display -- so it still has layout and stays queryable
|
|
16
|
+
by Capybara. The progress bar floats over it. block drops the baseline
|
|
17
|
+
descender so the ring centers on the box, not a few px below it. */
|
|
18
|
+
.async-variant-processing img,
|
|
19
|
+
.async-variant-processing video,
|
|
20
|
+
.async-variant-failed img,
|
|
21
|
+
.async-variant-failed video {
|
|
22
|
+
opacity: 0.001;
|
|
23
|
+
display: block;
|
|
24
|
+
}
|
|
25
|
+
.async-variant-progress {
|
|
26
|
+
position: absolute;
|
|
27
|
+
top: 50%;
|
|
28
|
+
left: 50%;
|
|
29
|
+
transform: translate(-50%, -50%);
|
|
30
|
+
--circular-size: 80px;
|
|
31
|
+
--progress-color: #2E7D32;
|
|
32
|
+
--indeterminate-color: #2E7D32;
|
|
33
|
+
--track-color: rgba(0, 0, 0, 0.25);
|
|
34
|
+
--error-color: #ff7c81;
|
|
35
|
+
}
|