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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -1
- data/README.md +103 -1
- data/app/assets/tailwind/studio_engine/engine.css +90 -18
- data/app/components/previews/studio/button_component_preview/disabled.html.erb +12 -0
- data/app/components/previews/studio/button_component_preview/icon_and_link.html.erb +7 -0
- data/app/components/previews/studio/button_component_preview/loading.html.erb +12 -0
- data/app/components/previews/studio/button_component_preview/sizes.html.erb +9 -0
- data/app/components/previews/studio/button_component_preview/tones.html.erb +5 -0
- data/app/components/previews/studio/button_component_preview.rb +34 -0
- data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb +5 -0
- data/app/components/previews/studio/empty_state_component_preview.rb +29 -0
- data/app/components/previews/studio/table_component_preview/rows.html.erb +14 -0
- data/app/components/previews/studio/table_component_preview.rb +33 -0
- data/app/components/studio/button_component.html.erb +1 -0
- data/app/components/studio/button_component.rb +151 -0
- data/app/components/studio/empty_state_component.html.erb +12 -0
- data/app/components/studio/empty_state_component.rb +64 -0
- data/app/components/studio/table_component.html.erb +32 -0
- data/app/components/studio/table_component.rb +152 -0
- data/app/controllers/studio/modals_controller.rb +48 -0
- data/app/javascript/studio/carousel.js +52 -0
- data/app/javascript/studio/controllers/carousel_controller.js +7 -13
- data/app/javascript/studio/in_page_links.js +85 -11
- data/app/javascript/studio/modal_host.js +219 -0
- data/app/models/error_log.rb +5 -5
- data/app/views/components/_empty_state.html.erb +2 -6
- data/app/views/studio/modals/_host.html.erb +34 -2
- data/app/views/studio/modals/remote.html.erb +5 -0
- data/app/views/style/_components.html.erb +5 -2
- data/app/views/style/_glass.html.erb +9 -1
- data/app/views/style/_primitives.html.erb +106 -0
- data/config/studio_tricks.yml +6 -2
- data/db/migrate/20261109180000_add_engine_not_null_checks.rb +148 -0
- data/lib/studio/modals.rb +101 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +11 -0
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9778730aefa3dc031248a8ff2535362cfb258443e2e10d8ded26997417720ef1
|
|
4
|
+
data.tar.gz: db87fa943c1ca9c97fa0323144dc7d395478e69788ad6bc248d669fb8c68323b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31af4a95d22ab3a24a72e97cccda1670d061e1287bf9c8146fadba9b1c399720729f364a13188669827fa7623bbd61bf558f1e79c8aa235bac73e1e8a3454109
|
|
7
|
+
data.tar.gz: 66fb861fb188b09e76f5bfb068947d46a28bfc2ab1add0f4605b8315edfc4faa8cd2426356d23ab764324a51e811f9055bc6d4a61a41a8f850845aecfde5c5bd
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.142.0 — 2026-11-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Modals load on demand (`Studio.modals`).** Register a modal in an initializer (`Studio.modals.register :contest_rules, partial: "modals/contest_rules"`, with `auth: :user`, `prefetch: true` and `locals:` as options) and delete its template from the layout. The shared host fetches it from `GET /_studio/modals/:id` on its first open, shows the processing card when the fetch is slow and the error card when it fails, and keeps it for the page's lifetime, Turbo visits included. Call sites do not change. An app that registers nothing renders the host as before. See the README, "Modals on demand".
|
|
12
|
+
- **`Studio::ButtonComponent`, `Studio::EmptyStateComponent` and `Studio::TableComponent`.** The engine's first UI primitives after the badge, so apps stop hand-building buttons, empty states and tables (UI/UX audit 2026-11-07, §4D). The button maps four tones (`primary`, `secondary`, `quiet`, `danger`) and three sizes onto the shipped `btn-*` utilities, renders a `<button>` or an `<a>`, and its `loading:` state is the aria-busy contract with the pending spinner. The empty state is the `.empty-state` card with an icon, a message, a detail line and one Button action. The table takes columns and rows, sorts through plain `?sort=&direction=` links with `aria-sort`, renders an empty row, and scrolls sideways on a phone. `/admin/style` draws every tone, size and state live, and `e2e/ui_primitives_screenshots.spec.js` compares each specimen to a Linux baseline at 390 and 1280 px in light and dark. Consumers adopt them in later releases; nothing changes until a view renders one.
|
|
13
|
+
- **`.lit-tile` and `.lit-tile-soft`.** Under `surface_style = :glass`, an app's own call-to-action tile that is not a `.btn` takes the buttons' light: a top light, a bottom shade and a contact shadow (`.lit-tile`), or the outline button's softer light (`.lit-tile-soft`), with a pressed state. Shadows only, so the box, the corners and the fill stay the app's. Without glass on the root, both classes do nothing.
|
|
14
|
+
- **The glass surfaces take the app's tint: `--glass-surface-tint` and `--glass-surface-shadow-tint`.** Under `surface_style = :glass`, the light theme's card hairline (`--surface-glass-line`), the soft lights' shade (`--lit-soft-bottom`, `--lit-soft-pressed`, `--lit-badge`, `--lit-input`) and the card shadow (`--surface-glass-shadow`) were the hub's violet for every app. They now read two root tokens, as `R G B`. The defaults are the old values, so an app that sets none looks as it did. An app sets them in its own stylesheet, unlayered, never in `@layer components` (the engine's default wins there and nothing reports it). The dark theme reads neither token. `docs/NEW_APP_SETUP.md` § 9 has the example. An app that already overrides the `--surface-glass-*` or `--lit-*` variables directly keeps working.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **The engine tables refuse a NULL slug, and users refuse a NULL role.** A new migration, `AddEngineNotNullChecks` (run `bin/rails studio_engine:install:migrations`), backfills any NULL slug on `error_logs`, `theme_settings`, `studio_geo_settings` and `studio_site_identities` by its model's rule and any NULL `users.role` with `viewer`, then adds a validated CHECK `<column> IS NOT NULL` named `<table>_<column>_present`. It is the NOT NULL step that 0.141.0 deferred. It is a CHECK, not a column-level NOT NULL, so `db/schema.rb` only gains lines and the release's schema-dump guard accepts it; the dump after a migrate equals the dump after a schema load. A role column that is already NOT NULL (the hub, Turf) gets no CHECK. Adopt it only on a host whose production has run 0.141.0 or later for one release: up to 0.137.0, `ErrorLog.capture!` inserts a NULL slug first. To roll a host back to a release older than 0.141.0, run `bin/rails db:migrate:down VERSION=<this migration's installed version>` first; a rollback to 0.141.0 or later needs nothing.
|
|
19
|
+
- **`components/empty_state` renders `Studio::EmptyStateComponent`.** The partial keeps its locals (`message:`, `detail:`) and the `.empty-state` class for one release. Its message moves from `text-lg` to the type scale's `type-body` and its detail from `text-sm` to `type-fine`, so an existing empty state reads a step smaller. New code renders the component.
|
|
20
|
+
- **Glass is the standard for a new app.** Alex McRitchie ruled on 2026-11-09 that glass surfaces and the glass sidebar are the standard. The new-app initializer in `docs/NEW_APP_SETUP.md` now sets `config.surface_style = :glass` and `config.sidebar_style = :glass`, and its layout writes `data-surface-style` on `<html>`. The engine default stays `:default`, so an existing app does not change when it bumps the gem. Nothing to do unless you start a new app.
|
|
21
|
+
- **The glass sidebar takes the app's colours from root tokens.** It hard-coded the hub's violet, so every app that adopted it restated the shell's `--glass-*` variables. The shell now reads `--glass-sidebar-fill` and `--glass-sidebar-tint` (`R G B`) and `--glass-sidebar-ink` and `--glass-sidebar-soft-ink` from `:root` and `.dark`, and their defaults are the old violet values, so the hub and every app that sets none paint exactly as before. An app that overrides the `--glass-*` variables on the shell keeps working; it can move those values to the four tokens (example in `docs/NEW_APP_SETUP.md` § 9).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **A looping carousel that gets no `scrollend` still comes home.** Chrome can send a scroll event and no `scrollend` (for example, when the page shortens and the browser pulls the scroll back). A looping row (`loop: true`, plain or spotlight) that glided across the seam then rested on a clone: it did not jump to the real slide and did not fire `studio:carousel:change`. `studio/carousel` now has `watchRest`: `scrollend` rests the row, and 450 ms after the last scroll event (`REST_CAP_MS`, three of the 150 ms quiet spells, `SETTLE_MS`) is the backstop. After the controller's own scroll, a `scrollend` counts only once a scroll event has come since that scroll, so the end of a replaced glide does not cut the new one short. The change event still fires once per move. A cascade row does not scroll, so this does not change it. Nothing for a consumer to do.
|
|
26
|
+
- **A tab's jump stays on its section when a lazy frame above it loads after the jump.** The body has scroll anchoring off (`studio/_head`), so a lazy frame that the smooth scroll passed and that landed after the scroll ended (the style guide's Loaders feed, 88px above Modals) pushed the section down under the pinned row, and the tab above it took the highlight. For 3 seconds after the end (`HOLD_MS`), `studio/in_page_links` now moves the page with the target, as scroll anchoring would, until the reader's first wheel, touch, key or press, or any scroll it did not make. A `scrollend` that comes after the jump aims again but before that aim's first scroll (Chrome sends the replaced scroll's end) no longer ends the jump short. Where the browser fires `scrollend`, a scroll that `scrollend` never follows (a page that shortens, and the browser pulls the scroll back) now ends the jump 400 ms after its last scroll event; before, the jump stayed open and the tab stayed highlighted. Nothing for a consumer to do.
|
|
27
|
+
|
|
7
28
|
## 0.141.3 — 2026-11-09
|
|
8
29
|
|
|
9
30
|
### Fixed
|
|
@@ -16,7 +37,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
16
37
|
### Fixed
|
|
17
38
|
|
|
18
39
|
- **The link-preview card shows text typed before its controller loads.** The `link-preview-card` controller is registered lazily, so an operator could type in /admin/link_preview before it listened, and the card kept the saved title. `connect()` now paints the card from the fields as they stand (`paintAll` in `studio/link_preview_card`). No host change.
|
|
19
|
-
- **An in-page jump ends at the browser's scrollend, not at a quiet spell.** `studio/in_page_links` ended a smooth jump after 150 ms with no scroll event, even in a browser that fires `scrollend`. On a slow main thread (a phone, or CI at 8x CPU) a smooth scroll's frames come further apart than that, so the jump ended at its first frame: the tab highlight followed the passing sections, and a target that moved on the way (a lazy feed above it) was not aimed at again. Now only `scrollend` ends a scroll that moved, where the browser has it; the quiet spell stays for a browser without it, and the 400 ms cap still ends a scroll that never starts. Nothing for a consumer to do.
|
|
40
|
+
- **An in-page jump ends at the browser's scrollend, not at a quiet spell.** `studio/in_page_links` ended a smooth jump after 150 ms with no scroll event, even in a browser that fires `scrollend`. On a slow main thread (a phone, or CI at 8x CPU) a smooth scroll's frames come further apart than that, so the jump ended at its first frame: the tab highlight followed the passing sections, and a target that moved on the way (a lazy feed above it) was not aimed at again. Now only `scrollend` ends a scroll that moved, where the browser has it; the quiet spell stays for a browser without it, and the 400 ms cap still ends a scroll that never starts. A moved target is re-aimed only for growth before `scrollend`: growth after the end, and a scroll that `scrollend` never follows, are fixed in the next release (engine-anchor-late-growth). Nothing for a consumer to do.
|
|
20
41
|
|
|
21
42
|
## 0.141.1 — 2026-11-09
|
|
22
43
|
|
data/README.md
CHANGED
|
@@ -35,6 +35,7 @@ resolved.
|
|
|
35
35
|
- **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
|
|
36
36
|
- **Device sessions**: a logout ends that device's session on the server, so a copy of the pre-logout cookie no longer signs in; other devices stay signed in. One indexed lookup per signed-in request, legacy cookies accepted until a grace date, rows pruned after 90 idle days, no host code. See [`docs/DEVICE_SESSIONS.md`](docs/DEVICE_SESSIONS.md).
|
|
37
37
|
- **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
|
|
38
|
+
- **Glass is the standard** (Alex McRitchie, 2026-11-09): a new app sets `config.surface_style = :glass` and `config.sidebar_style = :glass`, and writes `data-surface-style` on its layout's `<html>` ([`docs/NEW_APP_SETUP.md`](docs/NEW_APP_SETUP.md) § 4 and § 9). The engine default stays `:default`, so an existing app moves only when it asks. The glass sidebar takes the app's colours from four root tokens, `--glass-sidebar-fill`, `--glass-sidebar-ink`, `--glass-sidebar-soft-ink` and `--glass-sidebar-tint`, whose defaults are the hub's violet. `.lit-tile` and `.lit-tile-soft` light an app's own call-to-action tile.
|
|
38
39
|
- **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
|
|
39
40
|
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
|
|
40
41
|
- **Sluggable concern**: a human-readable slug written once at create (`to_param` returns it); `rename_slug!` changes it and cascades to every child column in one transaction, any other write that changes a persisted slug is invalid, and a refused rename answers 422
|
|
@@ -298,6 +299,50 @@ actions; the engine supplies only the banner.
|
|
|
298
299
|
|
|
299
300
|
## UI Primitives
|
|
300
301
|
|
|
302
|
+
### Button, empty state and table — `Studio::ButtonComponent`, `Studio::EmptyStateComponent`, `Studio::TableComponent`
|
|
303
|
+
|
|
304
|
+
Three ViewComponents every app renders instead of hand-building the part. Each
|
|
305
|
+
one's class comment lists its public surface; `/admin/style` draws every tone,
|
|
306
|
+
size and state live (Components section), and the gallery has a preview per state.
|
|
307
|
+
|
|
308
|
+
```erb
|
|
309
|
+
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit") %>
|
|
310
|
+
<%= render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm) %>
|
|
311
|
+
<%= render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: docs_path) %>
|
|
312
|
+
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
|
|
313
|
+
|
|
314
|
+
<%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start.")) do |empty| %>
|
|
315
|
+
<% empty.with_action(label: "Invite", href: new_invite_path) %>
|
|
316
|
+
No members yet.
|
|
317
|
+
<% end %>
|
|
318
|
+
|
|
319
|
+
<%= render Studio::TableComponent.new(
|
|
320
|
+
rows: @users, sort: params[:sort], direction: params[:direction], empty: "No users yet.",
|
|
321
|
+
columns: [
|
|
322
|
+
{ key: :name, label: "User", sortable: true },
|
|
323
|
+
{ key: :created_at, label: "Joined", align: :right, cell: ->(u) { l(u.created_at.to_date) } }
|
|
324
|
+
]) %>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- **Button.** Tones map onto the shipped utilities: `primary` is `btn-primary`,
|
|
328
|
+
`secondary` is `btn-secondary` (the success role, or the app's
|
|
329
|
+
`--btn-secondary-*` retune), `quiet` is `btn-neutral`, `danger` is `btn-danger`.
|
|
330
|
+
Sizes are `btn-sm`, bare `btn` and `btn-lg`. `loading:` uses the aria-busy
|
|
331
|
+
contract (docs/LOADER.md): the button is `aria-busy` and disabled and shows the
|
|
332
|
+
pending spinner. A submit button always carries the hidden spinner, so a Turbo
|
|
333
|
+
form that marks itself busy shows it with no script. A link cannot be loading.
|
|
334
|
+
- **Empty state.** The `.empty-state` card, the message at `type-body`, the
|
|
335
|
+
detail at `type-fine`, an optional icon, and at most one action, which is a
|
|
336
|
+
`Studio::ButtonComponent`. `framed: false` drops the card. The
|
|
337
|
+
`components/empty_state` partial renders it for one release.
|
|
338
|
+
- **Table.** Columns and rows. Sorting is plain links: a sortable header links to
|
|
339
|
+
the current URL with `?sort=<key>&direction=<asc|desc>` (page dropped), the
|
|
340
|
+
sorted header carries `aria-sort` and an arrow, and the controller sorts.
|
|
341
|
+
`sort_url:` takes a callable for another URL shape. No rows renders one row with
|
|
342
|
+
a bare empty state. On a phone the table scrolls sideways, as every engine table
|
|
343
|
+
does; `min_width:` sets where. `sticky_header: true` adds
|
|
344
|
+
`data-sticky-table-header`.
|
|
345
|
+
|
|
301
346
|
### The page header — `studio/_page_header`
|
|
302
347
|
|
|
303
348
|
An admin page's title: a crumb trail from the app's one parent map (`Studio.crumbs`,
|
|
@@ -671,6 +716,63 @@ the block:
|
|
|
671
716
|
<% end %>
|
|
672
717
|
```
|
|
673
718
|
|
|
719
|
+
#### Modals on demand — `Studio.modals`
|
|
720
|
+
|
|
721
|
+
A layout that renders every modal template into every page ships them all on
|
|
722
|
+
every request, opened or not. Register a modal instead, and the shared host
|
|
723
|
+
fetches it the first time it opens:
|
|
724
|
+
|
|
725
|
+
```ruby
|
|
726
|
+
# config/initializers/studio.rb
|
|
727
|
+
Studio.modals.register :contest_rules, partial: "modals/contest_rules"
|
|
728
|
+
Studio.modals.register :wallet_setup, partial: "modals/wallet_setup",
|
|
729
|
+
auth: :user, locals: :wallet_setup_modal_locals
|
|
730
|
+
Studio.modals.register :auth, partial: "modals/auth", prefetch: true
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Then delete that modal's `<template x-if>` from the layout. Call sites do not
|
|
734
|
+
change: `$store.modals.open("contest_rules", props)` works as before.
|
|
735
|
+
|
|
736
|
+
| Option | Meaning |
|
|
737
|
+
|--------|---------|
|
|
738
|
+
| `partial:` | The content partial, as the layout rendered it. The two rules below still apply (one root, no double quote in `x-data`). |
|
|
739
|
+
| `auth:` | `:any` (default) or `:user`. A `:user` modal answers `401` to a signed-out request and is never prefetched for one. |
|
|
740
|
+
| `prefetch:` | `true` fetches the modal when the browser idles after load (`requestIdleCallback`, else a timeout), so it opens with no wait. Use it for a modal opened on most visits. |
|
|
741
|
+
| `locals:` | A Hash, a Symbol naming a helper the view answers (an existing `*_modal_locals` helper), or a callable taking the view. Resolved per request, in the view. |
|
|
742
|
+
|
|
743
|
+
Ids are lower-case letters, digits, `-` and `_`.
|
|
744
|
+
|
|
745
|
+
**What happens.** The host advertises the registered ids on its template
|
|
746
|
+
(`data-modal-host-remote-value`). `open(id)` for one that is not in the page
|
|
747
|
+
yet fetches `GET /_studio/modals/<id>` (`Studio::ModalsController`), wraps the
|
|
748
|
+
fragment in the same `<template x-if>` a layout would render, appends it to the
|
|
749
|
+
card, and opens. A fetch that answers within 150 ms opens with no loading state.
|
|
750
|
+
A slower one opens the card with the engine's processing card, held at least
|
|
751
|
+
`StudioModals.MIN_LOAD_MS`, then the content replaces it. A failed fetch (a
|
|
752
|
+
network error, a `500`, a `401`, or a host filter's redirect) shows the
|
|
753
|
+
engine's error card, whose button closes; the next open tries again.
|
|
754
|
+
|
|
755
|
+
The fragment is held for the page's lifetime, across Turbo visits: a later open
|
|
756
|
+
inserts it from memory into the new page's host, once, with no second request.
|
|
757
|
+
A fetch that lands after a Turbo visit opens nothing on the new page.
|
|
758
|
+
|
|
759
|
+
**What does not change.** An id the registry does not name takes the old
|
|
760
|
+
synchronous `open()` path, and an app that registers nothing renders the host
|
|
761
|
+
exactly as before (no advertisement, no card marker, no loading or error card).
|
|
762
|
+
One difference for a registered id: during the first open's fetch,
|
|
763
|
+
`isOpen(id)` is false until the card appears.
|
|
764
|
+
|
|
765
|
+
**The endpoint.** It renders only registered ids (any other answers `404`),
|
|
766
|
+
with no layout. It skips the host's `require_authentication` so an `:any`
|
|
767
|
+
modal serves a signed-out visitor, and answers a `:user` modal's signed-out
|
|
768
|
+
request with a bare `401`, never the `/login` redirect. Each answer is
|
|
769
|
+
`private` with `Vary: Cookie` and an ETag. A fragment carries no script, so
|
|
770
|
+
the page's CSP is unchanged. Only the shared host (`$store.modals`) loads on
|
|
771
|
+
demand; a scoped host renders its modals as before.
|
|
772
|
+
|
|
773
|
+
**Do not do both.** An id that the registry names and the layout still
|
|
774
|
+
renders mounts twice. Move each modal in one change.
|
|
775
|
+
|
|
674
776
|
#### Writing a modal's content partial — two rules the host imposes
|
|
675
777
|
|
|
676
778
|
Both rules let the card render and then do less than it looks like it does.
|
|
@@ -1029,7 +1131,7 @@ Two seams, both empty by default, so the navbar is unchanged until an app opts i
|
|
|
1029
1131
|
- `sidebar_sections` fills the slide-out link sidebar (the cog trigger in the icon
|
|
1030
1132
|
rail). Rules: `lib/studio/sidebar_sections.rb`. `Studio.sidebar_style = :glass` floats it
|
|
1031
1133
|
as an inset translucent panel with line icons (`studio_icon`, `lib/studio/icons/README.md`);
|
|
1032
|
-
see `docs/NAVBAR_SETUP.md`.
|
|
1134
|
+
see `docs/NAVBAR_SETUP.md`. Glass is the standard for a new app.
|
|
1033
1135
|
- `navbar_links` fills the navbar's own link slots: the desktop bar beside the
|
|
1034
1136
|
logo and the phone row under it. Rules: `lib/studio/navbar_links.rb`.
|
|
1035
1137
|
|
|
@@ -1709,6 +1709,19 @@ body.modal-open .studio-app-banner {
|
|
|
1709
1709
|
@apply px-8 py-3 text-base rounded-xl shadow-sm;
|
|
1710
1710
|
}
|
|
1711
1711
|
|
|
1712
|
+
/* A busy button (Studio::ButtonComponent loading:, or a submit Turbo marks busy) is
|
|
1713
|
+
disabled so it takes no second click, but it is working, not unavailable: it keeps
|
|
1714
|
+
its full colour and shows the progress cursor. The busy words sit in the button's
|
|
1715
|
+
own gap-2, so the Pending rule's margin (engine-motion.css) is dropped here. */
|
|
1716
|
+
.btn[aria-busy="true"]:disabled {
|
|
1717
|
+
opacity: 1;
|
|
1718
|
+
cursor: progress;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
.btn[aria-busy="true"] [data-loader-busy] {
|
|
1722
|
+
margin-inline-start: 0;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1712
1725
|
/* -- Link sidebar (out-of-the-box navigation) ------------------------------
|
|
1713
1726
|
Paired with components/_link_sidebar + _link_sidebar_trigger and the
|
|
1714
1727
|
Studio.sidebar_sections config. A full-cover drawer, so it takes the shared
|
|
@@ -2337,7 +2350,30 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2337
2350
|
and below them.
|
|
2338
2351
|
|
|
2339
2352
|
THE PHONE gets a narrower inset, the full width and a lighter blur, which costs less
|
|
2340
|
-
through the slide.
|
|
2353
|
+
through the slide.
|
|
2354
|
+
|
|
2355
|
+
THE COLOURS ARE THE APP'S, from four tokens on the root. The values below are the hub's
|
|
2356
|
+
violet, so an app that sets none looks like the hub. An app sets them in its own
|
|
2357
|
+
stylesheet, unlayered, on :root and again on .dark (an unlayered :root also wins in the
|
|
2358
|
+
dark theme, so a token set only on :root carries into it):
|
|
2359
|
+
--glass-sidebar-fill the panel, as "R G B" (the shell adds the alpha)
|
|
2360
|
+
--glass-sidebar-ink the label colour
|
|
2361
|
+
--glass-sidebar-soft-ink headers and descriptions; keep 4.5:1 on the fill
|
|
2362
|
+
--glass-sidebar-tint the light theme's hover, hairline and shadow, as "R G B"
|
|
2363
|
+
The light theme's thin fill stays white, and the dark theme's hover, hairline and shadow
|
|
2364
|
+
stay white and black, so the dark block sets no tint. The --glass-* variables on the shell
|
|
2365
|
+
still take a direct override, as the apps that adopted glass before these tokens do. */
|
|
2366
|
+
:root {
|
|
2367
|
+
--glass-sidebar-fill: 250 249 253;
|
|
2368
|
+
--glass-sidebar-ink: #1d1832;
|
|
2369
|
+
--glass-sidebar-soft-ink: #474260;
|
|
2370
|
+
--glass-sidebar-tint: 29 24 50;
|
|
2371
|
+
}
|
|
2372
|
+
.dark {
|
|
2373
|
+
--glass-sidebar-fill: 36 30 62;
|
|
2374
|
+
--glass-sidebar-ink: #f1effa;
|
|
2375
|
+
--glass-sidebar-soft-ink: #dedaed; /* 4.5:1 on the thin fill alone over white, if a blur never paints */
|
|
2376
|
+
}
|
|
2341
2377
|
[data-sidebar-glass] {
|
|
2342
2378
|
--sidebar-glass-inset: 0.75rem;
|
|
2343
2379
|
width: 19.5rem;
|
|
@@ -2351,14 +2387,14 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2351
2387
|
--glass-icon-gap: 0.625rem;
|
|
2352
2388
|
--glass-section-gap: 1rem;
|
|
2353
2389
|
--sidebar-row-lift: 0.4375rem;
|
|
2354
|
-
--glass-fill: rgb(
|
|
2390
|
+
--glass-fill: rgb(var(--glass-sidebar-fill) / 0.97);
|
|
2355
2391
|
--glass-thin-fill: rgb(255 255 255 / 0.72);
|
|
2356
2392
|
--glass-filter: blur(24px) saturate(150%) brightness(1.15);
|
|
2357
|
-
--glass-ink:
|
|
2358
|
-
--glass-soft-ink:
|
|
2359
|
-
--glass-hover: rgb(
|
|
2360
|
-
--glass-line: rgb(
|
|
2361
|
-
--glass-shadow: 0 24px 60px rgb(
|
|
2393
|
+
--glass-ink: var(--glass-sidebar-ink);
|
|
2394
|
+
--glass-soft-ink: var(--glass-sidebar-soft-ink);
|
|
2395
|
+
--glass-hover: rgb(var(--glass-sidebar-tint) / 0.08);
|
|
2396
|
+
--glass-line: rgb(var(--glass-sidebar-tint) / 0.14);
|
|
2397
|
+
--glass-shadow: 0 24px 60px rgb(var(--glass-sidebar-tint) / 0.24);
|
|
2362
2398
|
pointer-events: auto;
|
|
2363
2399
|
overflow: hidden;
|
|
2364
2400
|
min-height: 0;
|
|
@@ -2371,11 +2407,11 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2371
2407
|
font-size: 0.84375rem;
|
|
2372
2408
|
}
|
|
2373
2409
|
.dark [data-sidebar-glass-shell] {
|
|
2374
|
-
--glass-fill: rgb(
|
|
2375
|
-
--glass-thin-fill: rgb(
|
|
2410
|
+
--glass-fill: rgb(var(--glass-sidebar-fill) / 0.97);
|
|
2411
|
+
--glass-thin-fill: rgb(var(--glass-sidebar-fill) / 0.72);
|
|
2376
2412
|
--glass-filter: blur(24px) saturate(150%) brightness(0.8);
|
|
2377
|
-
--glass-ink:
|
|
2378
|
-
--glass-soft-ink:
|
|
2413
|
+
--glass-ink: var(--glass-sidebar-ink);
|
|
2414
|
+
--glass-soft-ink: var(--glass-sidebar-soft-ink);
|
|
2379
2415
|
--glass-hover: rgb(255 255 255 / 0.1);
|
|
2380
2416
|
--glass-line: rgb(255 255 255 / 0.14);
|
|
2381
2417
|
--glass-shadow: 0 24px 60px rgb(0 0 0 / 0.5);
|
|
@@ -2509,13 +2545,27 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2509
2545
|
.bg-surface.rounded-xl.border. NOT a menu, popover or modal: anything .absolute, .fixed,
|
|
2510
2546
|
.sticky or .shadow-2xl, or inside a .fixed scrim or a dialog, stays solid, because it
|
|
2511
2547
|
floats over text and has no blur to protect it. Under prefers-reduced-transparency
|
|
2512
|
-
every surface goes solid.
|
|
2548
|
+
every surface goes solid.
|
|
2549
|
+
|
|
2550
|
+
THE TINT IS THE APP'S, from two tokens on the root, as "R G B". The values below are the
|
|
2551
|
+
hub's violet, so an app that sets none looks like the hub. An app sets them in its own
|
|
2552
|
+
stylesheet, unlayered (in @layer components the engine's default wins):
|
|
2553
|
+
--glass-surface-tint the light theme's hairline (--surface-glass-line) and the
|
|
2554
|
+
soft lights' shade (--lit-soft-bottom, --lit-soft-pressed,
|
|
2555
|
+
--lit-badge, --lit-input)
|
|
2556
|
+
--glass-surface-shadow-tint the light theme's card shadow (--surface-glass-shadow)
|
|
2557
|
+
The dark theme's hairline, lights and shadows are white and black, so neither token
|
|
2558
|
+
reaches it, and a token set only on :root is safe there. */
|
|
2559
|
+
:root {
|
|
2560
|
+
--glass-surface-tint: 29 24 50;
|
|
2561
|
+
--glass-surface-shadow-tint: 60 50 120;
|
|
2562
|
+
}
|
|
2513
2563
|
html[data-surface-style="glass"] {
|
|
2514
2564
|
--surface-glass-fill: color-mix(in srgb, var(--color-surface) 62%, transparent);
|
|
2515
2565
|
--surface-glass-fill-hover: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
|
2516
|
-
--surface-glass-line: rgb(
|
|
2566
|
+
--surface-glass-line: rgb(var(--glass-surface-tint) / 0.1);
|
|
2517
2567
|
--surface-glass-highlight: inset 0 1px 0 rgb(255 255 255 / 0.7);
|
|
2518
|
-
--surface-glass-shadow: 0 6px 20px rgb(
|
|
2568
|
+
--surface-glass-shadow: 0 6px 20px rgb(var(--glass-surface-shadow-tint) / 0.08);
|
|
2519
2569
|
--surface-glass-filter: blur(16px) saturate(140%);
|
|
2520
2570
|
--surface-glass-ambient:
|
|
2521
2571
|
radial-gradient(60vw 55vh at 10% 5%, rgb(var(--color-primary-rgb) / 0.17), transparent 70%),
|
|
@@ -2649,11 +2699,11 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2649
2699
|
--lit-glow: 0 8px 24px -6px;
|
|
2650
2700
|
--lit-glow-alpha: 0.6;
|
|
2651
2701
|
--lit-soft-top: inset 0 1px 0 rgb(255 255 255 / 0.8);
|
|
2652
|
-
--lit-soft-bottom: inset 0 -1px 0 rgb(
|
|
2702
|
+
--lit-soft-bottom: inset 0 -1px 0 rgb(var(--glass-surface-tint) / 0.08);
|
|
2653
2703
|
--lit-pressed: inset 0 2px 4px rgb(0 0 0 / 0.27);
|
|
2654
|
-
--lit-soft-pressed: inset 0 2px 3px rgb(
|
|
2655
|
-
--lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.5), 0 1px 1px rgb(
|
|
2656
|
-
--lit-input: inset 0 1px 2px rgb(
|
|
2704
|
+
--lit-soft-pressed: inset 0 2px 3px rgb(var(--glass-surface-tint) / 0.16);
|
|
2705
|
+
--lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.5), 0 1px 1px rgb(var(--glass-surface-tint) / 0.12);
|
|
2706
|
+
--lit-input: inset 0 1px 2px rgb(var(--glass-surface-tint) / 0.14), inset 0 -1px 0 rgb(255 255 255 / 0.6);
|
|
2657
2707
|
}
|
|
2658
2708
|
html.dark[data-surface-style="glass"] {
|
|
2659
2709
|
--lit-bottom: inset 0 -2px 0 rgb(0 0 0 / 0.3);
|
|
@@ -2754,6 +2804,28 @@ html[data-theme-flip]::view-transition-new(*) {
|
|
|
2754
2804
|
html[data-surface-style="glass"] .badge { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-badge); }
|
|
2755
2805
|
html[data-surface-style="glass"] .input-field { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-input); }
|
|
2756
2806
|
|
|
2807
|
+
/* A LIT TILE: an app's own call to action that is not a .btn, such as a square tile with a
|
|
2808
|
+
brand fill, takes the same light. .lit-tile is a filled tile: a top light, a bottom shade
|
|
2809
|
+
and a contact shadow. .lit-tile-soft is an outlined or pale one, with the outline button's
|
|
2810
|
+
softer light. Shadows only, so no box moves, the corners stay the app's and the fill stays
|
|
2811
|
+
exact. A press flips the light inset and, with motion allowed, moves the tile 1px. With no
|
|
2812
|
+
glass on the root, both classes do nothing. */
|
|
2813
|
+
html[data-surface-style="glass"] .lit-tile {
|
|
2814
|
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-top), var(--lit-bottom), var(--lit-contact);
|
|
2815
|
+
}
|
|
2816
|
+
html[data-surface-style="glass"] .lit-tile-soft {
|
|
2817
|
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-soft-top), var(--lit-soft-bottom), var(--lit-contact);
|
|
2818
|
+
}
|
|
2819
|
+
html[data-surface-style="glass"] .lit-tile:is(:active, [data-lit-state="pressed"]) {
|
|
2820
|
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-pressed);
|
|
2821
|
+
}
|
|
2822
|
+
html[data-surface-style="glass"] .lit-tile-soft:is(:active, [data-lit-state="pressed"]) {
|
|
2823
|
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-soft-pressed);
|
|
2824
|
+
}
|
|
2825
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
2826
|
+
html[data-surface-style="glass"] :is(.lit-tile, .lit-tile-soft):is(:active, [data-lit-state="pressed"]) { translate: 0 1px; }
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2757
2829
|
/* BUTTON TRICKS ON A LIT BUTTON (the lit rules above).
|
|
2758
2830
|
A lit button already uses its box-shadow for its contact shadow and glow, and
|
|
2759
2831
|
its ::after for its inset light. These rules sit in the utilities layer and the
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div class="space-y-3" data-specimen="button-disabled">
|
|
2
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
3
|
+
<% tones.each do |tone| %>
|
|
4
|
+
<%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, disabled: true) %>
|
|
5
|
+
<% end %>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
8
|
+
<% tones.each do |tone| %>
|
|
9
|
+
<%= render Studio::ButtonComponent.new(label: "#{tone.to_s.capitalize} link", tone: tone, href: "#", disabled: true) %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div class="flex flex-wrap items-center gap-3" data-specimen="button-icon-link">
|
|
2
|
+
<%= render(Studio::ButtonComponent.new(label: "New task")) do |button| %>
|
|
3
|
+
<% button.with_icon do %><svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 4a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H5a1 1 0 1 1 0-2h4V5a1 1 0 0 1 1-1Z"/></svg><% end %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<%= render Studio::ButtonComponent.new(label: "Read the docs", tone: :quiet, href: "#") %>
|
|
6
|
+
<%= render Studio::ButtonComponent.new(label: "Remove", tone: :danger, size: :sm, href: "#") %>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div class="space-y-3" data-specimen="button-loading">
|
|
2
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
3
|
+
<% tones.each do |tone| %>
|
|
4
|
+
<%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, loading: true) %>
|
|
5
|
+
<% end %>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
8
|
+
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
|
|
9
|
+
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit", size: :sm, loading: true, busy_label: "Saving…") %>
|
|
10
|
+
<%= render Studio::ButtonComponent.new(label: "Save", type: "submit", size: :lg, loading: true, busy_label: "Saving…") %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<div class="space-y-3" data-specimen="button-sizes">
|
|
2
|
+
<% tones.each do |tone| %>
|
|
3
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
4
|
+
<% sizes.each do |size| %>
|
|
5
|
+
<%= render Studio::ButtonComponent.new(label: "#{tone.to_s.capitalize} #{size}", tone: tone, size: size) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<% end %>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Every state Studio::ButtonComponent can show: the four tones, the three
|
|
5
|
+
# sizes, a link, an icon, disabled and loading. No @param tags: the gallery
|
|
6
|
+
# takes no input.
|
|
7
|
+
class ButtonComponentPreview < ViewComponent::Preview
|
|
8
|
+
# The four tones, at the default size.
|
|
9
|
+
def tones
|
|
10
|
+
render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The three sizes, in each tone.
|
|
14
|
+
def sizes
|
|
15
|
+
render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys, sizes: Studio::ButtonComponent::SIZES.keys })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Disabled, as a button and as a link, in each tone.
|
|
19
|
+
def disabled
|
|
20
|
+
render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Loading: aria-busy and disabled, the pending loader shown, with and
|
|
24
|
+
# without busy words.
|
|
25
|
+
def loading
|
|
26
|
+
render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# An icon before the label, and a link that looks like a button.
|
|
30
|
+
def icon_and_link
|
|
31
|
+
render_with_template
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start the team.")) do |empty| %>
|
|
2
|
+
<% empty.with_icon do %><svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859M2.25 13.5V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.5M2.25 13.5l2.4-7.2A2.25 2.25 0 0 1 6.784 4.5h10.432a2.25 2.25 0 0 1 2.134 1.8l2.4 7.2"/></svg><% end %>
|
|
3
|
+
<% empty.with_action(label: "Invite a member", href: "#") %>
|
|
4
|
+
No members yet.
|
|
5
|
+
<% end %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Every state Studio::EmptyStateComponent can show: the message alone, with
|
|
5
|
+
# detail, with an icon and an action, and bare (inside a frame of its own).
|
|
6
|
+
# No @param tags: the gallery takes no input.
|
|
7
|
+
class EmptyStateComponentPreview < ViewComponent::Preview
|
|
8
|
+
# The message alone: what the components/empty_state partial renders.
|
|
9
|
+
def message
|
|
10
|
+
render Studio::EmptyStateComponent.new(message: "No errors logged yet.")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# A message and one line of detail.
|
|
14
|
+
def with_detail
|
|
15
|
+
render Studio::EmptyStateComponent.new(message: "No errors logged yet.",
|
|
16
|
+
detail: "Errors appear here when ErrorLog.capture! records one.")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# An icon, the message, the detail and the one action.
|
|
20
|
+
def with_icon_and_action
|
|
21
|
+
render_with_template
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Without the card, for a place that has its own frame.
|
|
25
|
+
def bare
|
|
26
|
+
render Studio::EmptyStateComponent.new(message: "Nothing matches that filter.", framed: false)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%
|
|
2
|
+
tones = { "passed" => :success, "blocked" => :danger, "waiting" => :warning }
|
|
3
|
+
columns = [
|
|
4
|
+
{ key: :name, label: "Member", sortable: true },
|
|
5
|
+
{ key: :role, label: "Role" },
|
|
6
|
+
{ key: :tasks, label: "Tasks", sortable: true, align: :right },
|
|
7
|
+
{ key: :status, label: "Status", align: :center,
|
|
8
|
+
cell: ->(row) { render Studio::BadgeComponent.new(text: row[:status], tone: tones.fetch(row[:status], :muted)) } }
|
|
9
|
+
]
|
|
10
|
+
%>
|
|
11
|
+
<div class="card overflow-hidden" data-specimen="table">
|
|
12
|
+
<%= render Studio::TableComponent.new(rows: rows, columns: columns, sort: sort, direction: direction,
|
|
13
|
+
sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" }, empty: "No members yet.", min_width: 520) %>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Every state Studio::TableComponent can show: rows, a sorted column in each
|
|
5
|
+
# direction, and the empty row. No @param tags: the gallery takes no input.
|
|
6
|
+
class TableComponentPreview < ViewComponent::Preview
|
|
7
|
+
ROWS = [
|
|
8
|
+
{ name: "Ada Lovelace", role: "Admin", tasks: 12, status: "passed" },
|
|
9
|
+
{ name: "Grace Hopper", role: "Builder", tasks: 7, status: "blocked" },
|
|
10
|
+
{ name: "Katherine Johnson", role: "Reviewer", tasks: 3, status: "waiting" }
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# Rows, a badge cell, and no sort.
|
|
14
|
+
def rows
|
|
15
|
+
render_with_template(locals: { rows: ROWS, sort: nil, direction: nil })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Sorted by name, ascending: aria-sort and the up arrow.
|
|
19
|
+
def sorted_ascending
|
|
20
|
+
render_with_template(template: "studio/table_component_preview/rows", locals: { rows: ROWS, sort: "name", direction: "asc" })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sorted by tasks, descending.
|
|
24
|
+
def sorted_descending
|
|
25
|
+
render_with_template(template: "studio/table_component_preview/rows", locals: { rows: ROWS.sort_by { |r| -r[:tasks] }, sort: "tasks", direction: "desc" })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# No rows: the empty row, an EmptyStateComponent across every column.
|
|
29
|
+
def empty
|
|
30
|
+
render_with_template(template: "studio/table_component_preview/rows", locals: { rows: [], sort: nil, direction: nil })
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= content_tag(link? ? :a : :button, **element_attributes) do %><% if pending_loader? %><%= studio_loader_pending(style: :spinner, size: 16) %><% end %><% if icon? %><span class="inline-flex shrink-0" aria-hidden="true" data-loader-idle><%= icon %></span><% end %><% if busy_label.present? && !link? %><span data-loader-idle><%= text %></span><span data-loader-busy><%= busy_label %></span><% else %><span><%= text %></span><% end %><% end %>
|