Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions crates/hypercolor-ui/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,54 @@
@import "./tokens/primitives.css";
@import "./tokens/semantic.css";

/* Studio keeps live surfaces mounted. Motion follows selection and
disclosure state without interpolating pointer-driven geometry. */
.studio-tab-indicator {
transition: transform 200ms var(--ease-silk), width 200ms var(--ease-silk);
}

.studio-workspace[data-switch="1"] .studio-zone-content {
animation: studio-settle-a 160ms var(--ease-silk);
}
.studio-workspace[data-switch="2"] .studio-zone-content {
animation: studio-settle-b 160ms var(--ease-silk);
}
@keyframes studio-settle-a { from { opacity: 0.65; } to { opacity: 1; } }
@keyframes studio-settle-b { from { opacity: 0.65; } to { opacity: 1; } }

.studio-workspace[data-dragging="true"] .studio-zone-content {
animation: none;
}

.studio-disclosure {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 200ms var(--ease-silk), opacity 160ms var(--ease-silk);
}
.studio-disclosure[data-open="true"] {
grid-template-rows: 1fr;
opacity: 1;
}

.studio-composition-panel,
.studio-device-drawer {
transition: translate 240ms var(--ease-silk);
}
.studio-composition-scrim {
transition: opacity 200ms var(--ease-silk);
}

@media (prefers-reduced-motion: reduce) {
.studio-workspace[data-switch] .studio-zone-content { animation: none; }
.studio-tab-indicator,
.studio-disclosure,
.studio-composition-panel,
.studio-composition-scrim,
.studio-device-drawer,
.studio-workspace [aria-expanded] svg { transition: none; }
}

/* ── Toasts ──────────────────────────────────────────────────────────────

Toasts float at the bottom right of the content area, clear of the
Expand Down
65 changes: 55 additions & 10 deletions crates/hypercolor-ui/src/pages/studio/composition_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use hypercolor_leptos_ext::events::target_is_text_entry;
use leptos::ev;
use leptos::prelude::*;
use leptos_icons::Icon;
use wasm_bindgen::JsCast;

use crate::api;
use crate::components::layer_panel::LayerPanel;
Expand All @@ -18,6 +19,9 @@ use super::StudioContext;
use super::face_composition::{DefaultFaceCard, ScreenCompositionSection};
use super::surface::selected_screen_device_id;

pub(super) const COMPOSITION_PANEL_ID: &str = "studio-composition-panel";
pub(super) const COMPOSITION_TRIGGER_ID: &str = "studio-composition-trigger";

/// The right-edge composition slide-over. Stays mounted and animates via
/// a transform; `inert` while closed keeps its controls out of the tab
/// order. Positioned `absolute` inside the Stage pane so it overlays the
Expand All @@ -33,6 +37,43 @@ pub fn CompositionPanel(
) -> impl IntoView {
let studio = expect_context::<StudioContext>();
let open = studio.composition_open;
let panel_ref = NodeRef::<leptos::html::Aside>::new();
let close_ref = NodeRef::<leptos::html::Button>::new();

// Focus follows the disclosure without stealing it back from a control
// elsewhere in Studio when the panel closes.
Effect::new(move |previous: Option<bool>| {
let is_open = open.get();
if is_open && previous == Some(false) {
request_animation_frame(move || {
if open.try_get_untracked() == Some(true)
&& let Some(button) = close_ref.try_get_untracked().flatten()
{
let options = web_sys::FocusOptions::new();
options.set_prevent_scroll(true);
let _ = button.focus_with_options(&options);
}
});
}
is_open
});
let close = move || {
let focused_in_panel = panel_ref.get_untracked().is_some_and(|panel| {
document()
.active_element()
.is_some_and(|active| panel.contains(Some(&active)))
});
if focused_in_panel
&& let Some(trigger) = document()
.get_element_by_id(COMPOSITION_TRIGGER_ID)
.and_then(|element| element.dyn_into::<web_sys::HtmlElement>().ok())
{
let options = web_sys::FocusOptions::new();
options.set_prevent_scroll(true);
let _ = trigger.focus_with_options(&options);
}
open.set(false);
};

// A Screen surface's backing display device — present only while the
// selection is a display-role zone with a bound target. Drives the
Expand All @@ -49,32 +90,36 @@ pub fn CompositionPanel(
let keydown = window_event_listener(ev::keydown, move |event| {
if open.get_untracked() && event.key() == "Escape" && !target_is_text_entry(event.target())
{
open.set(false);
close();
}
});
on_cleanup(move || keydown.remove());

view! {
<div
class="absolute inset-0 z-30 bg-black/45 backdrop-blur-sm transition-opacity duration-200 ease-out"
class="studio-composition-scrim absolute inset-0 z-30 bg-black/45"
class=("opacity-0", move || !open.get())
class=("pointer-events-none", move || !open.get())
on:click=move |_| open.set(false)
on:click=move |_| close()
aria-hidden="true"
/>
<aside
class="absolute inset-y-0 right-0 z-40 flex w-[420px] max-w-[88%] flex-col
border-l border-edge-subtle bg-surface-base/95 backdrop-blur-md
transition-transform duration-200 ease-out"
node_ref=panel_ref
id=COMPOSITION_PANEL_ID
aria-label="Composition"
aria-hidden=move || (!open.get()).to_string()
class="studio-composition-panel absolute inset-y-0 right-0 z-40 flex w-[420px] max-w-[88%] flex-col
border-l border-edge-subtle bg-surface-base"
class=("translate-x-full", move || !open.get())
style="box-shadow: -20px 0 52px -28px rgba(0, 0, 0, 0.75)"
inert=move || !open.get()
inert=move || (!open.get()).then_some("")
>
<div class="flex shrink-0 justify-end border-b border-edge-subtle/55 px-3 py-2">
<button
node_ref=close_ref
type="button"
class="flex h-6 w-6 items-center justify-center rounded-md text-fg-tertiary transition-all btn-press hover:bg-surface-hover/40 hover:text-fg-primary"
class="flex h-6 w-6 items-center justify-center rounded-md text-fg-tertiary btn-press hover:bg-surface-hover/40 hover:text-fg-primary"
title="Close the composition panel"
on:click=move |_| open.set(false)
on:click=move |_| close()
>
<Icon icon=LuX width="14px" height="14px" />
</button>
Expand Down
15 changes: 12 additions & 3 deletions crates/hypercolor-ui/src/pages/studio/device_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ pub fn StudioDeviceCard(
.collect();
let show_components = total_components > 1;
let channels_open = RwSignal::new(mode == CardMode::Placed);
let channels_id = format!("studio-channels-{zone_key}-{row_device_id}");

// What the card moves as a whole, then each channel it lists, for the
// drag and its "Move to" menu.
Expand Down Expand Up @@ -566,24 +567,30 @@ pub fn StudioDeviceCard(
class="flex w-full items-center gap-1.5 border-t px-2.5 py-1 text-left text-[10px] text-fg-tertiary/80 transition-colors hover:text-fg-secondary"
style=list_style.clone()
aria-expanded=move || channels_open.get().to_string()
aria-controls=channels_id.clone()
on:click=move |_| channels_open.update(|open| *open = !*open)
>
<Icon
icon=LuChevronRight
width="10px"
height="10px"
{..}
class="transition-transform duration-200"
class=("rotate-90", move || channels_open.get())
/>
{format!("{channel_count} channels")}
</button>
}
})}
<div
class="space-y-0.5 border-t px-1.5 py-1.5"
class=("hidden", move || !channels_open.get())
style=list_style.clone()
id=channels_id.clone()
class="studio-disclosure"
data-open=move || channels_open.get().to_string()
inert=move || (!channels_open.get()).then_some("")
aria-hidden=move || (!channels_open.get()).to_string()
>
<div class="min-h-0 overflow-hidden">
<div class="space-y-0.5 border-t px-1.5 py-1.5" style=list_style.clone()>
{component_rows
.into_iter()
.map(|row| {
Expand All @@ -598,6 +605,8 @@ pub fn StudioDeviceCard(
)
})
.collect_view()}
</div>
</div>
</div>
}
})}
Expand Down
13 changes: 10 additions & 3 deletions crates/hypercolor-ui/src/pages/studio/device_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ pub fn DevicePane() -> impl IntoView {
on:touchmove=move |ev| drag.on_touch_move(&ev)
>
<PaneSearch search=search on_screens=on_screens />
<div class="scrollbar-none flex-1 overflow-y-auto px-3 pb-3" data-pane-scroll="">
<div class="studio-zone-content scrollbar-none flex-1 overflow-y-auto px-3 pb-3" data-pane-scroll="">
{move || {
if on_screens.get() {
return view! { <ScreenList query=query /> }.into_any();
Expand Down Expand Up @@ -514,7 +514,13 @@ fn OtherZoneRow(
{count}
</span>
</button>
<Show when=move || expanded.get()>
<div
class="studio-disclosure"
data-open=move || expanded.get().to_string()
inert=move || (!expanded.get()).then_some("")
aria-hidden=move || (!expanded.get()).to_string()
>
<div class="min-h-0 overflow-hidden">
<div
id=list_id.clone()
class="space-y-0.5 border-t border-edge-subtle/40 bg-surface-sunken/50 px-1.5 py-1.5"
Expand All @@ -539,7 +545,8 @@ fn OtherZoneRow(
.into_any()
}}
</div>
</Show>
</div>
</div>
</div>
}
}
Expand Down
Loading