Compare commits
2 Commits
5f643350c5
...
6c91ec0385
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c91ec0385 | ||
|
|
39db59c71a |
@@ -9,6 +9,7 @@ var Tray = (function () {
|
|||||||
var _wrap = null;
|
var _wrap = null;
|
||||||
var _btn = null;
|
var _btn = null;
|
||||||
var _tray = null;
|
var _tray = null;
|
||||||
|
var _grid = null;
|
||||||
|
|
||||||
// Portrait bounds (X axis)
|
// Portrait bounds (X axis)
|
||||||
var _minLeft = 0;
|
var _minLeft = 0;
|
||||||
@@ -39,34 +40,67 @@ var Tray = (function () {
|
|||||||
return window.innerWidth > window.innerHeight;
|
return window.innerWidth > window.innerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute the square cell size from the tray's interior dimension and set
|
||||||
|
// --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
|
||||||
|
// Portrait: divide height / 8. Landscape: divide width / 8.
|
||||||
|
// In portrait the tray may be display:none; we show it with visibility:hidden
|
||||||
|
// briefly so clientHeight returns a real value, then restore display:none.
|
||||||
|
function _computeCellSize() {
|
||||||
|
if (!_tray) return;
|
||||||
|
var size;
|
||||||
|
if (_isLandscape()) {
|
||||||
|
size = Math.floor(_tray.clientWidth / 8);
|
||||||
|
} else {
|
||||||
|
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
|
||||||
|
if (wasHidden) {
|
||||||
|
_tray.style.visibility = 'hidden';
|
||||||
|
_tray.style.display = 'grid';
|
||||||
|
}
|
||||||
|
size = Math.floor(_tray.clientHeight / 8);
|
||||||
|
if (wasHidden) {
|
||||||
|
_tray.style.display = 'none';
|
||||||
|
_tray.style.visibility = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (size > 0) {
|
||||||
|
_tray.style.setProperty('--tray-cell-size', size + 'px');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _computeBounds() {
|
function _computeBounds() {
|
||||||
if (_isLandscape()) {
|
if (_isLandscape()) {
|
||||||
// Landscape: the wrap slides on the Y axis.
|
// Landscape: the wrap slides on the Y axis.
|
||||||
// Structure (column-reverse): tray above, handle below.
|
// Structure (column-reverse): tray above, handle below.
|
||||||
// Tray is always display:block in landscape — wrap top hides/reveals it.
|
// Wrap height is fixed to gearBtnTop so the handle bottom always
|
||||||
// Closed: wrap top = -(trayH) so tray is above viewport, handle at y=0.
|
// meets the gear button when open. Tray is flex:1 and fills the rest.
|
||||||
// Open: wrap top = gearBtnTop - wrapH so handle bottom = gear btn top.
|
// Open: wrap top = 0 (pinned to viewport top).
|
||||||
|
// Closed: wrap top = -(gearBtnTop - handleH) = tray fully above viewport.
|
||||||
var gearBtn = document.getElementById('id_gear_btn');
|
var gearBtn = document.getElementById('id_gear_btn');
|
||||||
var gearBtnTop = window.innerHeight;
|
var gearBtnTop = window.innerHeight;
|
||||||
if (gearBtn) {
|
if (gearBtn) {
|
||||||
gearBtnTop = Math.round(gearBtn.getBoundingClientRect().top);
|
gearBtnTop = Math.round(gearBtn.getBoundingClientRect().top);
|
||||||
}
|
}
|
||||||
var handleH = (_btn && _btn.offsetHeight) || 48;
|
var handleH = (_btn && _btn.offsetHeight) || 48;
|
||||||
// Tray is display:block so offsetHeight includes it; fall back to 280.
|
|
||||||
var wrapH = (_wrap && _wrap.offsetHeight) || (handleH + 280);
|
// Pin wrap height so handle bottom = gear btn top when open.
|
||||||
|
if (_wrap) _wrap.style.height = gearBtnTop + 'px';
|
||||||
|
|
||||||
|
// Open: wrap pinned to viewport top.
|
||||||
|
_minTop = 0;
|
||||||
|
|
||||||
// Closed: tray hidden above viewport, handle visible at y=0.
|
// Closed: tray hidden above viewport, handle visible at y=0.
|
||||||
_maxTop = -(wrapH - handleH);
|
_maxTop = -(gearBtnTop - handleH);
|
||||||
|
|
||||||
// Open: handle bottom at gear btn top.
|
|
||||||
_minTop = gearBtnTop - wrapH;
|
|
||||||
} else {
|
} else {
|
||||||
// Portrait: slide on X axis.
|
// Portrait: slide on X axis.
|
||||||
var rightPx = parseInt(getComputedStyle(_wrap).right, 10);
|
// Wrap width is pinned to viewportW (JS) so its right edge only
|
||||||
if (isNaN(rightPx)) rightPx = 0;
|
// reaches the viewport boundary when left = 0 (fully open).
|
||||||
|
// This mirrors landscape: the open edge appears only at the last moment.
|
||||||
|
// Open: left = 0 → wrap right = viewportW exactly.
|
||||||
|
// Closed: left = viewportW - handleW → tray fully off-screen right.
|
||||||
var handleW = _btn.offsetWidth || 48;
|
var handleW = _btn.offsetWidth || 48;
|
||||||
|
if (_wrap) _wrap.style.width = window.innerWidth + 'px';
|
||||||
_minLeft = 0;
|
_minLeft = 0;
|
||||||
_maxLeft = window.innerWidth - rightPx - handleW;
|
_maxLeft = window.innerWidth - handleW;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +130,7 @@ var Tray = (function () {
|
|||||||
_open = true;
|
_open = true;
|
||||||
// Portrait only: toggle tray display.
|
// Portrait only: toggle tray display.
|
||||||
// Landscape: tray is always display:block; wrap position controls visibility.
|
// Landscape: tray is always display:block; wrap position controls visibility.
|
||||||
if (!_isLandscape() && _tray) _tray.style.display = 'block';
|
if (!_isLandscape() && _tray) _tray.style.display = 'grid';
|
||||||
if (_btn) _btn.classList.add('open');
|
if (_btn) _btn.classList.add('open');
|
||||||
if (_wrap) {
|
if (_wrap) {
|
||||||
_wrap.classList.remove('tray-dragging');
|
_wrap.classList.remove('tray-dragging');
|
||||||
@@ -156,7 +190,7 @@ var Tray = (function () {
|
|||||||
if (!_wrap) return;
|
if (!_wrap) return;
|
||||||
// Portrait: show tray so it peeks in during the translateX animation,
|
// Portrait: show tray so it peeks in during the translateX animation,
|
||||||
// then re-hide it if the tray is still closed when the animation ends.
|
// then re-hide it if the tray is still closed when the animation ends.
|
||||||
if (!_isLandscape() && _tray) _tray.style.display = 'block';
|
if (!_isLandscape() && _tray) _tray.style.display = 'grid';
|
||||||
_wrap.classList.add('wobble');
|
_wrap.classList.add('wobble');
|
||||||
_wrap.addEventListener('animationend', function handler() {
|
_wrap.addEventListener('animationend', function handler() {
|
||||||
_wrap.classList.remove('wobble');
|
_wrap.classList.remove('wobble');
|
||||||
@@ -185,19 +219,22 @@ var Tray = (function () {
|
|||||||
_wrap = document.getElementById('id_tray_wrap');
|
_wrap = document.getElementById('id_tray_wrap');
|
||||||
_btn = document.getElementById('id_tray_btn');
|
_btn = document.getElementById('id_tray_btn');
|
||||||
_tray = document.getElementById('id_tray');
|
_tray = document.getElementById('id_tray');
|
||||||
|
_grid = document.getElementById('id_tray_grid');
|
||||||
if (!_btn) return;
|
if (!_btn) return;
|
||||||
|
|
||||||
if (_isLandscape()) {
|
if (_isLandscape()) {
|
||||||
// Show tray before measuring so offsetHeight includes it.
|
// Show tray before measuring so offsetHeight includes it.
|
||||||
if (_tray) _tray.style.display = 'block';
|
if (_tray) _tray.style.display = 'grid';
|
||||||
_computeBounds();
|
_computeBounds();
|
||||||
// Clear portrait's inline left/bottom so media-query CSS applies.
|
// Clear portrait's inline left/bottom so media-query CSS applies.
|
||||||
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; }
|
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; }
|
||||||
if (_wrap) _wrap.style.top = _maxTop + 'px';
|
if (_wrap) _wrap.style.top = _maxTop + 'px';
|
||||||
|
_computeCellSize();
|
||||||
} else {
|
} else {
|
||||||
// Clear landscape's inline top so portrait CSS applies.
|
// Clear landscape's inline top so portrait CSS applies.
|
||||||
if (_wrap) _wrap.style.top = '';
|
if (_wrap) _wrap.style.top = '';
|
||||||
_applyVerticalBounds();
|
_applyVerticalBounds();
|
||||||
|
_computeCellSize(); // wrap has correct height after _applyVerticalBounds
|
||||||
_computeBounds();
|
_computeBounds();
|
||||||
if (_wrap) _wrap.style.left = _maxLeft + 'px';
|
if (_wrap) _wrap.style.left = _maxLeft + 'px';
|
||||||
}
|
}
|
||||||
@@ -244,7 +281,7 @@ var Tray = (function () {
|
|||||||
if (newLeft < _maxLeft) {
|
if (newLeft < _maxLeft) {
|
||||||
if (!_open) {
|
if (!_open) {
|
||||||
_open = true;
|
_open = true;
|
||||||
if (_tray) _tray.style.display = 'block';
|
if (_tray) _tray.style.display = 'grid';
|
||||||
if (_btn) _btn.classList.add('open');
|
if (_btn) _btn.classList.add('open');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -292,19 +329,39 @@ var Tray = (function () {
|
|||||||
_btn.addEventListener('click', _onBtnClick);
|
_btn.addEventListener('click', _onBtnClick);
|
||||||
|
|
||||||
window.addEventListener('resize', function () {
|
window.addEventListener('resize', function () {
|
||||||
|
// Always close on resize: bounds change invalidates current position.
|
||||||
|
// Cancel any in-flight close animation, then force-close state.
|
||||||
|
_cancelPendingHide();
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||||
|
|
||||||
if (_isLandscape()) {
|
if (_isLandscape()) {
|
||||||
// Ensure tray is visible before measuring bounds.
|
// Ensure tray is visible before measuring bounds.
|
||||||
if (_tray) _tray.style.display = 'block';
|
if (_tray) _tray.style.display = 'grid';
|
||||||
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; }
|
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; }
|
||||||
_computeBounds();
|
_computeBounds();
|
||||||
if (!_open && _wrap) _wrap.style.top = _maxTop + 'px';
|
_computeCellSize();
|
||||||
|
// Snap to closed without transition (resize fires continuously).
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
void _wrap.offsetWidth; // flush reflow so position lands before transition restored
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Switching to portrait: hide tray if closed.
|
if (_tray) _tray.style.display = 'none';
|
||||||
if (!_open && _tray) _tray.style.display = 'none';
|
if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; }
|
||||||
if (_wrap) _wrap.style.top = '';
|
|
||||||
_computeBounds();
|
_computeBounds();
|
||||||
_applyVerticalBounds();
|
_applyVerticalBounds();
|
||||||
if (!_open && _wrap) _wrap.style.left = _maxLeft + 'px';
|
_computeCellSize();
|
||||||
|
// Snap to closed without transition.
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
void _wrap.offsetWidth; // flush reflow
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -319,12 +376,17 @@ var Tray = (function () {
|
|||||||
_dragHandled = false;
|
_dragHandled = false;
|
||||||
_landscapeOverride = null;
|
_landscapeOverride = null;
|
||||||
// Restore portrait default (display:none); landscape init() will show it.
|
// Restore portrait default (display:none); landscape init() will show it.
|
||||||
if (_tray) _tray.style.display = 'none';
|
if (_tray) {
|
||||||
|
_tray.style.display = 'none';
|
||||||
|
_tray.style.removeProperty('--tray-cell-size');
|
||||||
|
}
|
||||||
if (_btn) _btn.classList.remove('open');
|
if (_btn) _btn.classList.remove('open');
|
||||||
if (_wrap) {
|
if (_wrap) {
|
||||||
_wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
_wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||||
_wrap.style.left = '';
|
_wrap.style.left = '';
|
||||||
_wrap.style.top = '';
|
_wrap.style.top = '';
|
||||||
|
_wrap.style.height = '';
|
||||||
|
_wrap.style.width = '';
|
||||||
}
|
}
|
||||||
if (_onDocMove) {
|
if (_onDocMove) {
|
||||||
document.removeEventListener('pointermove', _onDocMove);
|
document.removeEventListener('pointermove', _onDocMove);
|
||||||
@@ -344,6 +406,7 @@ var Tray = (function () {
|
|||||||
_wrap = null;
|
_wrap = null;
|
||||||
_btn = null;
|
_btn = null;
|
||||||
_tray = null;
|
_tray = null;
|
||||||
|
_grid = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -230,3 +230,106 @@ class TrayTest(FunctionalTest):
|
|||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T8 — portrait: 1 column × 8 rows of square cells #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag(btn, -300)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(
|
||||||
|
self.browser.find_element(By.ID, "id_tray").is_displayed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
|
||||||
|
self.assertEqual(len(cells), 8)
|
||||||
|
|
||||||
|
# 8 explicit rows set via grid-template-rows
|
||||||
|
row_count = self.browser.execute_script("""
|
||||||
|
var s = getComputedStyle(document.getElementById('id_tray_grid'));
|
||||||
|
return s.gridTemplateRows.trim().split(/\\s+/).length;
|
||||||
|
""")
|
||||||
|
self.assertEqual(row_count, 8)
|
||||||
|
|
||||||
|
# All 8 cells share the same x position — one column only
|
||||||
|
xs = {round(c.location['x']) for c in cells}
|
||||||
|
self.assertEqual(len(xs), 1)
|
||||||
|
|
||||||
|
# Cells are square
|
||||||
|
cell = cells[0]
|
||||||
|
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T9 — landscape: 8 columns × 1 row of square cells #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T9a — column/row count (structure)
|
||||||
|
def test_tray_grid_is_8_columns_by_1_row_in_landscape(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.set_window_size(900, 500)
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag_y(btn, 300)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
|
)
|
||||||
|
|
||||||
|
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
|
||||||
|
self.assertEqual(len(cells), 8)
|
||||||
|
|
||||||
|
# 8 explicit columns set via grid-template-columns
|
||||||
|
col_count = self.browser.execute_script("""
|
||||||
|
var s = getComputedStyle(document.getElementById('id_tray_grid'));
|
||||||
|
return s.gridTemplateColumns.trim().split(/\\s+/).length;
|
||||||
|
""")
|
||||||
|
self.assertEqual(col_count, 8)
|
||||||
|
|
||||||
|
# All 8 cells share the same y position — one row only
|
||||||
|
ys = {round(c.location['y']) for c in cells}
|
||||||
|
self.assertEqual(len(ys), 1)
|
||||||
|
|
||||||
|
# Cells are square
|
||||||
|
cell = cells[0]
|
||||||
|
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T9b — landscape: all 8 cells visible within the tray interior #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_landscape_tray_all_8_cells_visible(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.set_window_size(900, 500)
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag_y(btn, 300)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
|
)
|
||||||
|
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
|
||||||
|
self.assertEqual(len(cells), 8)
|
||||||
|
|
||||||
|
tray_right = tray.location['x'] + tray.size['width']
|
||||||
|
tray_bottom = tray.location['y'] + tray.size['height']
|
||||||
|
|
||||||
|
# Each cell must fit within the tray interior (2px rounding slack)
|
||||||
|
for cell in cells:
|
||||||
|
self.assertLessEqual(
|
||||||
|
cell.location['x'] + cell.size['width'], tray_right + 2,
|
||||||
|
msg="Cell overflows tray right edge"
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
cell.location['y'] + cell.size['height'], tray_bottom + 2,
|
||||||
|
msg="Cell overflows tray bottom edge"
|
||||||
|
)
|
||||||
|
|||||||
@@ -358,5 +358,68 @@ describe("Tray", () => {
|
|||||||
const top = parseInt(wrap.style.top, 10);
|
const top = parseInt(wrap.style.top, 10);
|
||||||
expect(top).toBeLessThan(0);
|
expect(top).toBeLessThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── resize closes landscape tray ─────────────────────────────── //
|
||||||
|
|
||||||
|
describe("resize closes the tray", () => {
|
||||||
|
it("closes when landscape tray is open", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from btn on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets wrap to closed top position on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open a closed tray on resize", () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// window resize — portrait //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("window resize (portrait)", () => {
|
||||||
|
it("closes the tray when open", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from btn on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the tray panel on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(tray.style.display).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets wrap to closed left position on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(wrap.style.left).toBe("0px");
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open a closed tray on resize", () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -296,6 +296,10 @@ html:has(.gate-overlay) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
|
// Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
|
||||||
@@ -698,7 +702,8 @@ $inv-strip: 30px; // visible height of each stacked card after the first
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
h3 { font-size: 0.85rem; margin: 0.25rem 0; }
|
margin-top: 0.75rem;
|
||||||
|
h3 { font-size: 0.85rem; margin: 0.5rem 0; }
|
||||||
|
|
||||||
form { gap: 0.35rem; }
|
form { gap: 0.35rem; }
|
||||||
|
|
||||||
@@ -779,250 +784,4 @@ $inv-strip: 30px; // visible height of each stacked card after the first
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Seat tray ──────────────────────────────────────────────────────────────
|
// ─── Seat tray — see _tray.scss ─────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Structure:
|
|
||||||
// #id_tray_wrap — fixed right edge, flex row, slides on :has(.open)
|
|
||||||
// #id_tray_handle — $handle-exposed wide; contains grip + button
|
|
||||||
// #id_tray_grip — position:absolute; ::before/::after = concentric rects
|
|
||||||
// #id_tray_btn — circle button (z-index:1, paints above grip)
|
|
||||||
// #id_tray — 280px panel; covers grip's rightward extension when open
|
|
||||||
//
|
|
||||||
// Closed: wrap translateX($tray-w) → only button circle visible at right edge.
|
|
||||||
// Open: translateX(0) → full tray panel slides in; grip rects visible as handle.
|
|
||||||
|
|
||||||
$tray-w: 280px;
|
|
||||||
$handle-rect-w: 10000px;
|
|
||||||
$handle-rect-h: 72px;
|
|
||||||
$handle-exposed: 48px;
|
|
||||||
$handle-r: 1rem;
|
|
||||||
|
|
||||||
#id_tray_wrap {
|
|
||||||
position: fixed;
|
|
||||||
// left set by JS: closed = vw - handle; open = 0
|
|
||||||
// top/bottom set by JS from nav/footer measurements
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 310;
|
|
||||||
pointer-events: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: stretch;
|
|
||||||
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
&.tray-dragging { transition: none; }
|
|
||||||
&.wobble { animation: tray-wobble 0.45s ease; }
|
|
||||||
&.snap { animation: tray-snap 0.30s ease; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray_handle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
width: $handle-exposed;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray_grip {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: calc(#{$handle-exposed} / 2 - 0.125rem);
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: $handle-rect-w;
|
|
||||||
height: $handle-rect-h;
|
|
||||||
pointer-events: none;
|
|
||||||
// Border + overflow:hidden on the grip itself clips ::before's shadow with correct radius
|
|
||||||
border-radius: $handle-r;
|
|
||||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
// Inset inner window: box-shadow spills outward to fill the opaque frame area,
|
|
||||||
// clipped to grip's rounded edge by overflow:hidden. background:transparent = see-through hole.
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0.4rem;
|
|
||||||
border-radius: calc(#{$handle-r} - 0.35rem);
|
|
||||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: 0 0 0 200px rgba(var(--priUser), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray_btn {
|
|
||||||
pointer-events: auto;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1; // above #id_tray_grip
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: rgba(var(--priUser), 1);
|
|
||||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
|
||||||
cursor: grab;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
color: rgba(var(--secUser), 1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active { cursor: grabbing; }
|
|
||||||
&.open {
|
|
||||||
cursor: pointer;
|
|
||||||
border-color: rgba(var(--quaUser), 1);
|
|
||||||
i { color: rgba(var(--quaUser), 1); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grip borders → --quaUser when tray is open (btn.open precedes grip in DOM so :has() needed)
|
|
||||||
#id_tray_wrap:has(#id_tray_btn.open) #id_tray_grip {
|
|
||||||
border-color: rgba(var(--quaUser), 1);
|
|
||||||
&::before { border-color: rgba(var(--quaUser), 1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tray-wobble {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
20% { transform: translateX(-8px); }
|
|
||||||
40% { transform: translateX(6px); }
|
|
||||||
60% { transform: translateX(-5px); }
|
|
||||||
80% { transform: translateX(3px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inverted wobble — handle overshoots past the wall on close, then bounces back.
|
|
||||||
@keyframes tray-snap {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
20% { transform: translateX(8px); }
|
|
||||||
40% { transform: translateX(-6px); }
|
|
||||||
60% { transform: translateX(5px); }
|
|
||||||
80% { transform: translateX(-3px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start
|
|
||||||
pointer-events: auto;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1; // above #id_tray_grip pseudo-elements
|
|
||||||
background: rgba(var(--duoUser), 1);
|
|
||||||
border-left:2.5rem solid rgba(var(--quaUser), 1);
|
|
||||||
border-top: 2.5rem solid rgba(var(--quaUser), 1);
|
|
||||||
border-bottom: 2.5rem solid rgba(var(--quaUser), 1);
|
|
||||||
box-shadow:
|
|
||||||
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.55),
|
|
||||||
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge
|
|
||||||
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
|
|
||||||
inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // left wall depth (hue)
|
|
||||||
inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth
|
|
||||||
inset 0 0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // top wall depth (hue)
|
|
||||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth
|
|
||||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5) // bottom wall depth (hue)
|
|
||||||
;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 85vh; // cap on very tall portrait screens
|
|
||||||
// scrollbar-width: thin;
|
|
||||||
// scrollbar-color: rgba(var(--terUser), 0.3) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tray: landscape reorientation ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Must come AFTER the portrait tray rules above to win the cascade
|
|
||||||
// (same specificity — later declaration wins).
|
|
||||||
//
|
|
||||||
// In landscape the tray slides DOWN from the top instead of in from the right.
|
|
||||||
// Structure (column-reverse): tray panel above, handle below.
|
|
||||||
// JS controls style.top for the Y-axis slide:
|
|
||||||
// Closed: top = -(trayH) → only handle visible at y = 0
|
|
||||||
// Open: top = gearBtnTop - wrapH → handle bottom at gear btn top
|
|
||||||
//
|
|
||||||
// The wrap fits horizontally between the fixed left-nav and right-footer sidebars.
|
|
||||||
|
|
||||||
@media (orientation: landscape) {
|
|
||||||
$sidebar-w: 4rem;
|
|
||||||
$tray-landscape-max-w: 960px; // cap tray width on very wide screens
|
|
||||||
|
|
||||||
#id_tray_wrap {
|
|
||||||
flex-direction: column-reverse; // tray panel above, handle below
|
|
||||||
left: $sidebar-w;
|
|
||||||
right: $sidebar-w;
|
|
||||||
top: auto; // JS controls style.top for the Y-axis slide
|
|
||||||
bottom: auto;
|
|
||||||
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
&.tray-dragging { transition: none; }
|
|
||||||
&.wobble { animation: tray-wobble-landscape 0.45s ease; }
|
|
||||||
&.snap { animation: tray-snap-landscape 0.30s ease; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray_handle {
|
|
||||||
width: auto; // full width of wrap
|
|
||||||
height: 48px; // $handle-exposed — same exposed dimension as portrait
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray_grip {
|
|
||||||
// Rotate 90°: centred horizontally, extends vertically.
|
|
||||||
// bottom mirrors portrait's left: grip starts at handle centre and extends
|
|
||||||
// toward the tray (upward in column-reverse layout).
|
|
||||||
bottom: calc(48px / 2 - 0.125rem); // $handle-exposed / 2 from handle bottom
|
|
||||||
top: auto;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 72px; // $handle-rect-h — narrow visible dimension
|
|
||||||
height: 10000px; // $handle-rect-w — extends upward into tray area
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_tray {
|
|
||||||
// Borders: left/right/bottom are visible walls; top edge is open.
|
|
||||||
// Bottom faces the handle (same logic as portrait's left border facing handle).
|
|
||||||
border-left: 2.5rem solid rgba(var(--quaUser), 1);
|
|
||||||
border-right: 2.5rem solid rgba(var(--quaUser), 1);
|
|
||||||
border-bottom: 2.5rem solid rgba(var(--quaUser), 1);
|
|
||||||
border-top: none;
|
|
||||||
|
|
||||||
margin-left: 0; // portrait horizontal gap no longer needed
|
|
||||||
margin-bottom: 0.5rem; // gap between tray bottom and handle top
|
|
||||||
|
|
||||||
// Cap width on ultra-wide screens; center within the handle shelf.
|
|
||||||
width: 100%;
|
|
||||||
max-width: $tray-landscape-max-w;
|
|
||||||
align-self: center;
|
|
||||||
|
|
||||||
box-shadow:
|
|
||||||
0 0.25rem 0.5rem rgba(0, 0, 0, 0.55),
|
|
||||||
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring
|
|
||||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth
|
|
||||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue)
|
|
||||||
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
|
|
||||||
inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // left wall depth (hue)
|
|
||||||
inset -0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // right wall depth
|
|
||||||
inset -0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5) // right wall depth (hue)
|
|
||||||
;
|
|
||||||
min-height: 2000px; // give tray real height so JS offsetHeight > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes tray-wobble-landscape {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
20% { transform: translateY(-8px); }
|
|
||||||
40% { transform: translateY(6px); }
|
|
||||||
60% { transform: translateY(-5px); }
|
|
||||||
80% { transform: translateY(3px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inverted wobble — wrap overshoots upward on close, then bounces back.
|
|
||||||
@keyframes tray-snap-landscape {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
20% { transform: translateY(8px); }
|
|
||||||
40% { transform: translateY(-6px); }
|
|
||||||
60% { transform: translateY(5px); }
|
|
||||||
80% { transform: translateY(-3px); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
293
src/static_src/scss/_tray.scss
Normal file
293
src/static_src/scss/_tray.scss
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
// ─── Seat tray ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Structure:
|
||||||
|
// #id_tray_wrap — fixed right edge, flex row, slides on :has(.open)
|
||||||
|
// #id_tray_handle — $handle-exposed wide; contains grip + button
|
||||||
|
// #id_tray_grip — position:absolute; ::before/::after = concentric rects
|
||||||
|
// #id_tray_btn — circle button (z-index:1, paints above grip)
|
||||||
|
// #id_tray — 280px panel; covers grip's rightward extension when open
|
||||||
|
//
|
||||||
|
// Closed: wrap translateX($tray-w) → only button circle visible at right edge.
|
||||||
|
// Open: translateX(0) → full tray panel slides in; grip rects visible as handle.
|
||||||
|
//
|
||||||
|
// Grid layout (portrait):
|
||||||
|
// 8 explicit rows; columns auto-added as items arrive (grid-auto-flow: column).
|
||||||
|
// --tray-cell-size set by JS from tray.clientHeight / 8 → always square cells.
|
||||||
|
//
|
||||||
|
// Grid layout (landscape):
|
||||||
|
// 8 explicit columns; rows auto-added as items arrive (grid-auto-flow: row).
|
||||||
|
// --tray-cell-size set by JS from tray.clientWidth / 8 → always square cells.
|
||||||
|
|
||||||
|
$tray-w: 280px;
|
||||||
|
$handle-rect-w: 10000px;
|
||||||
|
$handle-rect-h: 72px;
|
||||||
|
$handle-exposed: 48px;
|
||||||
|
$handle-r: 1rem;
|
||||||
|
|
||||||
|
#id_tray_wrap {
|
||||||
|
position: fixed;
|
||||||
|
// left set by JS: closed = vw - handleW; open = vw - wrapW
|
||||||
|
// top/bottom set by JS from nav/footer measurements
|
||||||
|
// right intentionally absent — wrap has fixed CSS width (handle + tray)
|
||||||
|
// so the open edge only reaches the viewport boundary when fully open.
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 310;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.tray-dragging { transition: none; }
|
||||||
|
&.wobble { animation: tray-wobble 0.45s ease; }
|
||||||
|
&.snap { animation: tray-snap 0.30s ease; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_handle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
width: $handle-exposed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_grip {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(#{$handle-exposed} / 2 - 0.125rem);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: $handle-rect-w;
|
||||||
|
height: $handle-rect-h;
|
||||||
|
pointer-events: none;
|
||||||
|
// Border + overflow:hidden on the grip itself clips ::before's shadow with correct radius
|
||||||
|
border-radius: $handle-r;
|
||||||
|
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Inset inner window: box-shadow spills outward to fill the opaque frame area,
|
||||||
|
// clipped to grip's rounded edge by overflow:hidden. background:transparent = see-through hole.
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.4rem;
|
||||||
|
border-radius: calc(#{$handle-r} - 0.35rem);
|
||||||
|
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: 0 0 0 200px rgba(var(--priUser), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_btn {
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1; // above #id_tray_grip
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(var(--priUser), 1);
|
||||||
|
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||||
|
cursor: grab;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: rgba(var(--secUser), 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { cursor: grabbing; }
|
||||||
|
&.open {
|
||||||
|
cursor: pointer;
|
||||||
|
border-color: rgba(var(--quaUser), 1);
|
||||||
|
i { color: rgba(var(--quaUser), 1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grip borders → --quaUser when tray is open (btn.open precedes grip in DOM so :has() needed)
|
||||||
|
#id_tray_wrap:has(#id_tray_btn.open) #id_tray_grip {
|
||||||
|
border-color: rgba(var(--quaUser), 1);
|
||||||
|
&::before { border-color: rgba(var(--quaUser), 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tray-wobble {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-8px); }
|
||||||
|
40% { transform: translateX(6px); }
|
||||||
|
60% { transform: translateX(-5px); }
|
||||||
|
80% { transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverted wobble — handle overshoots past the wall on close, then bounces back.
|
||||||
|
@keyframes tray-snap {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(8px); }
|
||||||
|
40% { transform: translateX(-6px); }
|
||||||
|
60% { transform: translateX(5px); }
|
||||||
|
80% { transform: translateX(-3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1; // above #id_tray_grip pseudo-elements
|
||||||
|
background: rgba(var(--duoUser), 1);
|
||||||
|
border-left:2.5rem solid rgba(var(--quaUser), 1);
|
||||||
|
border-top: 2.5rem solid rgba(var(--quaUser), 1);
|
||||||
|
border-bottom: 2.5rem solid rgba(var(--quaUser), 1);
|
||||||
|
box-shadow:
|
||||||
|
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.55),
|
||||||
|
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge
|
||||||
|
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
|
||||||
|
inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // left wall depth (hue)
|
||||||
|
inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth
|
||||||
|
inset 0 0.6rem 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // top wall depth (hue)
|
||||||
|
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth
|
||||||
|
inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quiUser), 0.5) // bottom wall depth (hue)
|
||||||
|
;
|
||||||
|
overflow: hidden; // clip #id_tray_grid to the felt interior
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_grid {
|
||||||
|
display: grid;
|
||||||
|
// Portrait: 8 explicit rows; columns auto-added as items arrive.
|
||||||
|
// --tray-cell-size set by JS from tray.clientHeight / 8 → always square cells.
|
||||||
|
grid-template-rows: repeat(8, var(--tray-cell-size, 48px));
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: var(--tray-cell-size, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-cell {
|
||||||
|
border-right: 2px dotted rgba(var(--quaUser), 0.35);
|
||||||
|
border-bottom: 2px dotted rgba(var(--quaUser), 0.35);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tray: landscape reorientation ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Must come AFTER the portrait tray rules above to win the cascade
|
||||||
|
// (same specificity — later declaration wins).
|
||||||
|
//
|
||||||
|
// In landscape the tray slides DOWN from the top instead of in from the right.
|
||||||
|
// Structure (column-reverse): tray panel above, handle below.
|
||||||
|
// JS controls style.top for the Y-axis slide:
|
||||||
|
// Closed: top = -(trayH) → only handle visible at y = 0
|
||||||
|
// Open: top = gearBtnTop - wrapH → handle bottom at gear btn top
|
||||||
|
//
|
||||||
|
// The wrap fits horizontally between the fixed left-nav and right-footer sidebars.
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
$sidebar-w: 4rem;
|
||||||
|
$tray-landscape-max-w: 960px; // cap tray width on very wide screens
|
||||||
|
|
||||||
|
#id_tray_wrap {
|
||||||
|
flex-direction: column-reverse; // tray panel above, handle below
|
||||||
|
left: $sidebar-w;
|
||||||
|
right: $sidebar-w;
|
||||||
|
top: auto; // JS controls style.top for the Y-axis slide
|
||||||
|
bottom: auto;
|
||||||
|
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.tray-dragging { transition: none; }
|
||||||
|
&.wobble { animation: tray-wobble-landscape 0.45s ease; }
|
||||||
|
&.snap { animation: tray-snap-landscape 0.30s ease; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_handle {
|
||||||
|
width: auto; // full width of wrap
|
||||||
|
height: 48px; // $handle-exposed — same exposed dimension as portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_grip {
|
||||||
|
// Rotate 90°: centred horizontally, extends vertically.
|
||||||
|
// bottom mirrors portrait's left: grip starts at handle centre and extends
|
||||||
|
// toward the tray (upward in column-reverse layout).
|
||||||
|
bottom: calc(48px / 2 - 0.125rem); // $handle-exposed / 2 from handle bottom
|
||||||
|
top: auto;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 72px; // $handle-rect-h — narrow visible dimension
|
||||||
|
height: 10000px; // $handle-rect-w — extends upward into tray area
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray {
|
||||||
|
// Borders: left/right/bottom are visible walls; top edge is open.
|
||||||
|
// Bottom faces the handle (same logic as portrait's left border facing handle).
|
||||||
|
border-left: 2.5rem solid rgba(var(--quaUser), 1);
|
||||||
|
border-right: 2.5rem solid rgba(var(--quaUser), 1);
|
||||||
|
border-bottom: 2.5rem solid rgba(var(--quaUser), 1);
|
||||||
|
border-top: none;
|
||||||
|
|
||||||
|
margin-left: 0; // portrait horizontal gap no longer needed
|
||||||
|
margin-bottom: 0.5rem; // gap between tray bottom and handle top
|
||||||
|
|
||||||
|
// Cap width on ultra-wide screens; center within the handle shelf.
|
||||||
|
width: 100%;
|
||||||
|
max-width: $tray-landscape-max-w;
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0.25rem 0.5rem rgba(0, 0, 0, 0.55),
|
||||||
|
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring
|
||||||
|
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth
|
||||||
|
inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue)
|
||||||
|
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
|
||||||
|
inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // left wall depth (hue)
|
||||||
|
inset -0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // right wall depth
|
||||||
|
inset -0.6rem 0 1.5rem -0.5rem rgba(var(--quaUser), 0.5) // right wall depth (hue)
|
||||||
|
;
|
||||||
|
flex: 1; // fill wrap height (JS sets wrap height = gearBtnTop)
|
||||||
|
height: auto;
|
||||||
|
min-height: unset;
|
||||||
|
overflow: hidden; // clip #id_tray_grid to the felt interior
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_tray_grid {
|
||||||
|
// Landscape: 8 explicit columns; rows auto-added as items arrive.
|
||||||
|
// --tray-cell-size set by JS from tray.clientWidth / 8 → always square cells.
|
||||||
|
grid-template-columns: repeat(8, var(--tray-cell-size, 48px));
|
||||||
|
grid-template-rows: none; // clear portrait's 8-row template
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-auto-rows: var(--tray-cell-size, 48px);
|
||||||
|
// Anchor grid to the handle-side (bottom) of the tray so the first row
|
||||||
|
// is visible when partially open; additional rows grow upward.
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In landscape the first row sits at the bottom; border-top divides it from
|
||||||
|
// the felt above. border-bottom would face the wall — swap it out.
|
||||||
|
.tray-cell {
|
||||||
|
border-top: 2px dotted rgba(var(--quaUser), 0.35);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tray-wobble-landscape {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
20% { transform: translateY(-8px); }
|
||||||
|
40% { transform: translateY(6px); }
|
||||||
|
60% { transform: translateY(-5px); }
|
||||||
|
80% { transform: translateY(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverted wobble — wrap overshoots upward on close, then bounces back.
|
||||||
|
@keyframes tray-snap-landscape {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
20% { transform: translateY(8px); }
|
||||||
|
40% { transform: translateY(-6px); }
|
||||||
|
60% { transform: translateY(5px); }
|
||||||
|
80% { transform: translateY(-3px); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
@import 'gameboard';
|
@import 'gameboard';
|
||||||
@import 'palette-picker';
|
@import 'palette-picker';
|
||||||
@import 'room';
|
@import 'room';
|
||||||
|
@import 'tray';
|
||||||
@import 'billboard';
|
@import 'billboard';
|
||||||
@import 'game-kit';
|
@import 'game-kit';
|
||||||
@import 'wallet-tokens';
|
@import 'wallet-tokens';
|
||||||
|
|||||||
@@ -358,5 +358,68 @@ describe("Tray", () => {
|
|||||||
const top = parseInt(wrap.style.top, 10);
|
const top = parseInt(wrap.style.top, 10);
|
||||||
expect(top).toBeLessThan(0);
|
expect(top).toBeLessThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── resize closes landscape tray ─────────────────────────────── //
|
||||||
|
|
||||||
|
describe("resize closes the tray", () => {
|
||||||
|
it("closes when landscape tray is open", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from btn on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets wrap to closed top position on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open a closed tray on resize", () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// window resize — portrait //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("window resize (portrait)", () => {
|
||||||
|
it("closes the tray when open", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from btn on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the tray panel on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(tray.style.display).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets wrap to closed left position on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(wrap.style.left).toBe("0px");
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open a closed tray on resize", () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
<i class="fa-solid fa-dice-d20"></i>
|
<i class="fa-solid fa-dice-d20"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="id_tray" style="display:none"></div>
|
<div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||||
</div>
|
</div>
|
||||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user