Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c85db8968 | ||
|
|
02e2251bb2 |
No files matched your search
@@ -4912,3 +4912,49 @@ bridge leaves the wizard up with its "Get Started" button correctly
|
|||||||
disabled — it gates on a directory chosen *in the wizard*, and the
|
disabled — it gates on a directory chosen *in the wizard*, and the
|
||||||
existing-library check runs once, on mount. A reload clears it. Nothing
|
existing-library check runs once, on mount. A reload clears it. Nothing
|
||||||
is broken; it cost twenty minutes of believing a tap had been swallowed.
|
is broken; it cost twenty minutes of believing a tap had been swallowed.
|
||||||
|
|
||||||
|
## The sheet's scroll fade, and where a scrim may not go (measured 2026-08-23, headless)
|
||||||
|
|
||||||
|
#207's answer. The affordance is two background layers on
|
||||||
|
`wa-dialog::part(body)` and the conditionality is
|
||||||
|
`background-attachment`, not a scroll listener: a cover of the sheet's
|
||||||
|
own colour painted at the end of the *content* (`local`) over a shadow
|
||||||
|
pinned to the box (`scroll`), so the cover scrolls up and hides the
|
||||||
|
shadow exactly when there is nothing more to see.
|
||||||
|
|
||||||
|
Measured at 424x360 (which is where a menu overflows on `main`, since
|
||||||
|
`main` does not yet carry #67's eighth item — at 424x439 the track
|
||||||
|
list's seven items are `scrollHeight` 364 against `clientHeight` 364,
|
||||||
|
fitting exactly). Pixel at x=300, dark ramp, `bgElevated` `#343a40`:
|
||||||
|
|
||||||
|
| y | before | more below | at the end of the list |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 330 | 52,58,64 | 50,56,62 | 52,58,64 |
|
||||||
|
| 340 | 52,58,64 | 43,48,53 | 52,58,64 |
|
||||||
|
| 350 | 52,58,64 | 33,37,40 | 52,58,64 |
|
||||||
|
| 359 | 52,58,64 | 22,24,27 | 52,58,64 |
|
||||||
|
|
||||||
|
Three things worth keeping.
|
||||||
|
|
||||||
|
**A menu that fits draws nothing**, which is the same measurement: at
|
||||||
|
424x439 the sheet is flat 52,58,64 to its bottom edge, because with no
|
||||||
|
overflow the `local` layer's positioning area *is* the padding box and
|
||||||
|
the cover lands on top of the shadow.
|
||||||
|
|
||||||
|
**A scrim over a menu row is that row's text surface**, so the 4.5:1
|
||||||
|
rule reaches it and this is why the curve is steep rather than linear.
|
||||||
|
A row is 48px with its label centred; 32px of scrim already down to a
|
||||||
|
quarter strength at 14px puts about 0.06 at the label. Checked on the
|
||||||
|
light ramp (`bgElevated` `#e9ecef`, text `#212529`) by overriding the
|
||||||
|
two custom properties on `:root`: background at the label 205,207,210,
|
||||||
|
which is **9.9:1**. The first draft — a linear 48px at 0.8 — put ~0.375
|
||||||
|
on that label, 5.0:1, passing but visibly greyed. The bottom few pixels
|
||||||
|
go to ~2.4:1 in either draft and are deliberately below where any
|
||||||
|
label of a *fully visible* row sits; a label that lands there belongs
|
||||||
|
to the half-cut row, which is the thing being signalled.
|
||||||
|
|
||||||
|
**A dark scrim on a dark surface reads far worse in a shrunk screenshot
|
||||||
|
than on screen.** The first two probes (24px/0.45, then 32px/0.75) were
|
||||||
|
measurably present — 52,58,64 down to 30,33,37 — and invisible in the
|
||||||
|
inline preview. Crop the bottom 70px and scale it up before judging;
|
||||||
|
the pixel values are the honest answer either way.
|
||||||
@@ -1093,9 +1093,9 @@ not the fix and cannot be: that function is the `document` listener for
|
|||||||
`navigate`, so it is an infinite loop.
|
`navigate`, so it is an infinite loop.
|
||||||
|
|
||||||
**It is a store rather than an event, because a component that mounts
|
**It is a store rather than an event, because a component that mounts
|
||||||
after a navigation still has to know.** `bottom-nav`'s "More" sheet
|
after a navigation still has to know.** `bottom-nav`'s "More" drawer
|
||||||
creates its `<app-sidebar>` on open, and that copy had heard no
|
creates its `<app-sidebar>` on open, and that copy had heard no
|
||||||
`navigate` at all — standing on Albums, it opened highlighting
|
`navigate` at all — standing on Albums, the drawer opened highlighting
|
||||||
Home. An event has no answer for a listener that was not there.
|
Home. An event has no answer for a listener that was not there.
|
||||||
|
|
||||||
**A detail view is not a view here**, so the destination it was opened
|
**A detail view is not a view here**, so the destination it was opened
|
||||||
@@ -1196,7 +1196,7 @@ and then vanishing.
|
|||||||
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
|
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
|
||||||
for "what a phone shows is a different question", and it would apply —
|
for "what a phone shows is a different question", and it would apply —
|
||||||
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
|
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
|
||||||
which filters, so an unfiltered bar would contradict its own sheet one
|
which filters, so an unfiltered bar would contradict its own drawer one
|
||||||
tap away. Which four tabs is still plan 016's committed subset; this
|
tap away. Which four tabs is still plan 016's committed subset; this
|
||||||
only removes from it, and "More" is never filtered because it is how
|
only removes from it, and "More" is never filtered because it is how
|
||||||
everything else stays reachable.
|
everything else stays reachable.
|
||||||
@@ -1402,7 +1402,7 @@ descendants. On the reference device the main panel spans 0-318 of a
|
|||||||
items cut off, with no way to reach them. `showModal()` is Chrome 37
|
items cut off, with no way to reach them. `showModal()` is Chrome 37
|
||||||
and uses the real top layer, so a dialog is immune by construction.
|
and uses the real top layer, so a dialog is immune by construction.
|
||||||
|
|
||||||
Six things about it are load-bearing.
|
Seven things about it are load-bearing.
|
||||||
|
|
||||||
**"Dialogs are fine" needed checking, because every other dialog in
|
**"Dialogs are fine" needed checking, because every other dialog in
|
||||||
this app is mounted in `index.html`** — outside `.main-panel` — so it
|
this app is mounted in `index.html`** — outside `.main-panel` — so it
|
||||||
@@ -1431,6 +1431,25 @@ doing nothing, which reads as the gesture breaking. `menu-dismiss` is
|
|||||||
that signal; the three surfaces that do not use `ContextMenuController`
|
that signal; the three surfaces that do not use `ContextMenuController`
|
||||||
bind it themselves.
|
bind it themselves.
|
||||||
|
|
||||||
|
**A sheet that scrolls says so, and `background-attachment` is what
|
||||||
|
asks whether it does** (#207). The sheet is capped at 85vh — a surface
|
||||||
|
covering the whole screen is a page, not a sheet — so a long menu's
|
||||||
|
body scrolls, and for three phases it scrolled *silently*: measured at
|
||||||
|
424x439, eight items ended at y=470 with the fold at 439, and where the
|
||||||
|
cut lands on a row boundary the sheet ends in a clean edge that reads
|
||||||
|
as the end of the list. The fade is two background layers on
|
||||||
|
`wa-dialog::part(body)` — a shadow pinned to the box (`scroll`) under a
|
||||||
|
cover of the sheet's own colour painted at the end of the *content*
|
||||||
|
(`local`), which scrolls up over the shadow exactly when there is
|
||||||
|
nothing more to see. So it is absent on a menu that fits, present the
|
||||||
|
moment one does not, and gone again at the end of the list, with no
|
||||||
|
scroll listener and nothing reaching into `wa-dialog`'s shadow root for
|
||||||
|
the scroller. **The curve is steep because the rows under it stay
|
||||||
|
live**: a scrim over a menu item is that item's text surface, and the
|
||||||
|
4.5:1 rule applies to it — 32px already down to a quarter strength at
|
||||||
|
14px spends its weight below the last legible label, measured at 9.9:1
|
||||||
|
on the light ramp, whose `bgElevated` is `#e9ecef`.
|
||||||
|
|
||||||
**The playlist submenu is a sheet too, and it had to be.** It is a
|
**The playlist submenu is a sheet too, and it had to be.** It is a
|
||||||
`placement="right-start"` flyout, and making the menu full-width moved
|
`placement="right-start"` flyout, and making the menu full-width moved
|
||||||
its anchor — measured at x −182 to 0, entirely off-screen, so "Add to
|
its anchor — measured at x −182 to 0, entirely off-screen, so "Add to
|
||||||
@@ -1790,7 +1809,7 @@ listing the destinations again — but rendering it unconditionally put a
|
|||||||
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
|
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
|
||||||
specs failed with "strict mode violation: resolved to 2 elements" on a
|
specs failed with "strict mode violation: resolved to 2 elements" on a
|
||||||
desktop viewport where the element is not even visible. It renders only
|
desktop viewport where the element is not even visible. It renders only
|
||||||
while the sheet is open, and `bottom-nav.test.ts` asserts its absence
|
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
|
||||||
before that.
|
before that.
|
||||||
|
|
||||||
**The tab bar is four destinations and a way to the rest.** Three to
|
**The tab bar is four destinations and a way to the rest.** Three to
|
||||||
@@ -1799,33 +1818,6 @@ is 32px each. Which four is plan 016's committed subset, and everything
|
|||||||
else — Settings included, because a phone still needs it — is behind
|
else — Settings included, because a phone still needs it — is behind
|
||||||
"More".
|
"More".
|
||||||
|
|
||||||
**And "More" rises from the bottom, on #60's sheet rather than a
|
|
||||||
second one** (#71). It was a `wa-drawer placement="start"`: a 200px
|
|
||||||
column of a 424px screen, opening away from the thumb that asked for
|
|
||||||
it, with the rest of its 400px band empty. It is the *same element*
|
|
||||||
with `placement="bottom"` and `without-header`, which is what keeps
|
|
||||||
the change to where it comes from — `wa-drawer` renders a native
|
|
||||||
`<dialog>` and opens it with `showModal()`, so #60's containment
|
|
||||||
finding carries over with nothing new to prove, and the focus trap,
|
|
||||||
Escape, tap-outside and `wa-after-hide` all come along. Measured at
|
|
||||||
424x439: 424 wide, 373 tall (85vh, so there is an outside to tap),
|
|
||||||
48px rows.
|
|
||||||
|
|
||||||
Three things about it are load-bearing. **The sidebar is mounted
|
|
||||||
rather than re-listed as data**, which the issue offers as the
|
|
||||||
alternative: the shell's own `<app-sidebar>` is `display: none` below
|
|
||||||
600px rather than removed, so a second list drawing `nav-*` handles is
|
|
||||||
the duplication above, and it would be a second place to add the next
|
|
||||||
view to. **There is one scroller, and it is the sheet's body** — the
|
|
||||||
reported "only part of the screen scrolls under my finger" is three
|
|
||||||
nested ones (the dialog, its body, and the sidebar's own
|
|
||||||
`overflow-y: auto` host), so which box a drag moves depends on where
|
|
||||||
the finger landed; `overscroll-behavior: contain` is the other half.
|
|
||||||
And **`expanded` means the host owns the box, not just the labels**:
|
|
||||||
`app-sidebar` writes an *inline* width and caps itself at 400px, which
|
|
||||||
beats any rule the host could write, so the width, the scrolling and
|
|
||||||
the mouse-only resize handle all follow that attribute.
|
|
||||||
|
|
||||||
**There are three supported size bands, and the queue is part of the
|
**There are three supported size bands, and the queue is part of the
|
||||||
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
|
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
|
||||||
nav, reflows, fits 320px exactly), **Compact** 600–899 (icon sidebar),
|
nav, reflows, fits 320px exactly), **Compact** 600–899 (icon sidebar),
|
||||||
|
|||||||
@@ -98,58 +98,6 @@ test.describe('the shell on a phone', () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('draws "More" as a sheet on the bottom edge (#71)', async ({ app }) => {
|
|
||||||
await app.getByTestId('tab-more').click();
|
|
||||||
await expect(app.getByTestId('nav-drawer').locator('app-sidebar'))
|
|
||||||
.toBeVisible();
|
|
||||||
|
|
||||||
// What the report is about is geometry, and geometry is what no
|
|
||||||
// other assertion here can see: the side drawer was a 200px column
|
|
||||||
// opening away from the thumb that asked for it, with the rest of
|
|
||||||
// its 400px band empty. Measured rather than screenshotted, since
|
|
||||||
// the failure is a number.
|
|
||||||
//
|
|
||||||
// Polled, because a sheet *arrives*: the drawer's show animation
|
|
||||||
// translates it a full height below the fold, so a measurement
|
|
||||||
// taken the moment its content is visible reports a box hanging
|
|
||||||
// 412px off the bottom of the screen. Asking for the settled
|
|
||||||
// number is the assertion; asking once is a race.
|
|
||||||
const measure = () => app.evaluate(() => {
|
|
||||||
const nav = document.querySelector('bottom-nav');
|
|
||||||
const drawer = nav?.shadowRoot?.querySelector('wa-drawer');
|
|
||||||
const dialog = drawer?.shadowRoot?.querySelector('[part~="dialog"]');
|
|
||||||
const sidebar = nav?.shadowRoot?.querySelector('app-sidebar');
|
|
||||||
const row = sidebar?.shadowRoot?.querySelector('li button');
|
|
||||||
const box = dialog?.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: Math.round(box?.left ?? -1),
|
|
||||||
right: Math.round(box?.right ?? -1),
|
|
||||||
bottom: Math.round(box?.bottom ?? -1),
|
|
||||||
height: Math.round(box?.height ?? -1),
|
|
||||||
row: Math.round(row?.getBoundingClientRect().height ?? -1),
|
|
||||||
viewport: [window.innerWidth, window.innerHeight],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await measure()).bottom)
|
|
||||||
.toBe(PHONE.height);
|
|
||||||
|
|
||||||
const sheet = await measure();
|
|
||||||
|
|
||||||
expect(sheet.left).toBe(0);
|
|
||||||
expect(sheet.right).toBe(sheet.viewport[0]);
|
|
||||||
|
|
||||||
// A surface covering the whole screen is a page, not a sheet --
|
|
||||||
// which is also what leaves an outside to tap on, the only pointer
|
|
||||||
// route out of it (#171 is the same question one surface over).
|
|
||||||
expect(sheet.height).toBeLessThan(sheet.viewport[1]);
|
|
||||||
|
|
||||||
// 48px rows, from #186's touch floor and #60's context sheet.
|
|
||||||
expect(sheet.row).toBeGreaterThanOrEqual(48);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const vp of [PHONE, SMALL_PHONE]) {
|
for (const vp of [PHONE, SMALL_PHONE]) {
|
||||||
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
|
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
|
||||||
await app.setViewportSize(vp);
|
await app.setViewportSize(vp);
|
||||||
|
|||||||
@@ -27,43 +27,15 @@ interface Tab {
|
|||||||
* three to five items before the targets stop being thumb-sized —
|
* three to five items before the targets stop being thumb-sized —
|
||||||
* 360 px over eleven sidebar entries is 32 px each — so the four here
|
* 360 px over eleven sidebar entries is 32 px each — so the four here
|
||||||
* are the ones plan 016's subset says a phone is *for*, and "More"
|
* are the ones plan 016's subset says a phone is *for*, and "More"
|
||||||
* opens the existing `<app-sidebar>` in a sheet. That is deliberately
|
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
|
||||||
* a reuse rather than a second nav: two lists of destinations is two
|
* a reuse rather than a second nav: two lists of destinations is two
|
||||||
* places to add the next view to, and the sidebar already carries the
|
* places to add the next view to, and the sidebar already carries the
|
||||||
* drag-to-navigate behaviour, the active state and the labels.
|
* drag-to-navigate behaviour, the active state and the labels.
|
||||||
*
|
*
|
||||||
* **"More" rises from the bottom, and it is the same sheet a context
|
|
||||||
* menu is** (#71). It was a `wa-drawer` sliding in from the side: a
|
|
||||||
* 200px column of a 424px screen, opening away from the thumb that
|
|
||||||
* asked for it, with three nested scrollers in it — the dialog, its
|
|
||||||
* body, and the sidebar's own `overflow-y: auto` host — which is the
|
|
||||||
* "only part of the screen scrolls under my finger" in the report.
|
|
||||||
*
|
|
||||||
* Three things about the replacement are load-bearing.
|
|
||||||
*
|
|
||||||
* **It is the same element with another `placement`, not a new
|
|
||||||
* surface.** `wa-drawer` renders a native `<dialog>` and opens it with
|
|
||||||
* `showModal()`, which is exactly what `menu-surface`'s sheet relies
|
|
||||||
* on — Chrome 37, the real top layer — so #60's containment finding
|
|
||||||
* carries over with nothing new to prove, and the focus trap, Escape,
|
|
||||||
* tap-outside and `wa-after-hide` all come along unchanged.
|
|
||||||
*
|
|
||||||
* **The body is the only scroller**, with `overscroll-behavior:
|
|
||||||
* contain`, and the sidebar is told to stop being one. Nesting them is
|
|
||||||
* what makes a drag scroll the wrong box.
|
|
||||||
*
|
|
||||||
* **The sidebar is still mounted rather than re-listed as data**,
|
|
||||||
* which the issue offers as an alternative. Its `data-testid` per
|
|
||||||
* destination is the reason: the shell's own sidebar is `display:
|
|
||||||
* none` below 600px rather than removed, so a second list drawing
|
|
||||||
* `nav-*` handles is the duplication this component already renders
|
|
||||||
* conditionally to avoid — and it would be a second place to add the
|
|
||||||
* next view to, with its own copy of #25's visibility filter.
|
|
||||||
*
|
|
||||||
* It emits the same bubbling, composed `navigate` event the sidebar
|
* It emits the same bubbling, composed `navigate` event the sidebar
|
||||||
* does, so `index.ts` needs no knowledge of it, and it listens for that
|
* does, so `index.ts` needs no knowledge of it, and it listens for that
|
||||||
* event globally for the same reason the sidebar does: a navigation it
|
* event globally for the same reason the sidebar does: a navigation it
|
||||||
* did not send (a card click, a detail view, the sheet) still has to
|
* did not send (a card click, a detail view, the drawer) still has to
|
||||||
* move the highlight.
|
* move the highlight.
|
||||||
*/
|
*/
|
||||||
@customElement('bottom-nav')
|
@customElement('bottom-nav')
|
||||||
@@ -132,50 +104,15 @@ export class BottomNav extends LitElement {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The sheet. --size is the drawer's own API for the axis its
|
|
||||||
placement uses, so auto is what makes it hug its content
|
|
||||||
instead of being a fixed 25rem band; the rest is the shape
|
|
||||||
the menu-surface context sheet already has, so a phone meets
|
|
||||||
one sheet rather than two. 85vh for its reason too: a surface
|
|
||||||
covering the whole screen is a page, not a sheet. */
|
|
||||||
wa-drawer {
|
|
||||||
--size: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
wa-drawer::part(dialog) {
|
|
||||||
max-height: 85vh;
|
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
/* The sidebar paints its own surface, so the sheet takes
|
|
||||||
that colour rather than the menus' elevated one: two
|
|
||||||
greys in one sheet is a seam across the middle of it. */
|
|
||||||
background-color: var(--yj-bg-surface, #212529);
|
|
||||||
/* One scroller, and it is the body below. The dialog's own
|
|
||||||
overflow: auto is what let the sheet scroll as well as
|
|
||||||
its content, and it is also what would square off the
|
|
||||||
corners this rule just rounded. */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
wa-drawer::part(body) {
|
wa-drawer::part(body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
/* A scroll that reaches the end of this list must not
|
|
||||||
become a scroll of the page underneath it. */
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
/* The sheet sits on the bottom edge, so the last
|
|
||||||
destination would otherwise be under the home indicator
|
|
||||||
on a gesture-navigation phone -- the same allowance the
|
|
||||||
bar itself makes above. */
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A sheet is dragged at with a thumb, so it says where its top
|
app-sidebar {
|
||||||
edge is. Decorative: the destinations are below it. */
|
/* The sidebar sizes itself inline and collapses to icons
|
||||||
.grip {
|
below 900px, which is every phone. In the drawer there
|
||||||
width: 36px;
|
is room for the labels, so it is told not to. */
|
||||||
height: 4px;
|
height: 100%;
|
||||||
margin: 8px auto 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--yj-text-tertiary, #888);
|
|
||||||
}
|
}
|
||||||
`];
|
`];
|
||||||
|
|
||||||
@@ -210,7 +147,7 @@ export class BottomNav extends LitElement {
|
|||||||
private visibilityCtrl = new ViewVisibilityController(this);
|
private visibilityCtrl = new ViewVisibilityController(this);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the sheet has been asked for.
|
* Whether the drawer has been asked for.
|
||||||
*
|
*
|
||||||
* The sidebar inside it is rendered only while this is true, and
|
* The sidebar inside it is rendered only while this is true, and
|
||||||
* that is not an optimisation. `app-sidebar` carries a
|
* that is not an optimisation. `app-sidebar` carries a
|
||||||
@@ -252,17 +189,15 @@ export class BottomNav extends LitElement {
|
|||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
// Web Awesome renders its heading into its own shadow root and
|
// Web Awesome renders its heading into its own shadow root and
|
||||||
// never points aria-labelledby at it, so the sheet would
|
// never points aria-labelledby at it, so the drawer would
|
||||||
// otherwise be announced unnamed -- the same fix, and the same
|
// otherwise be announced unnamed -- the same fix, and the same
|
||||||
// reason, as every wa-dialog in the app. A drawer's shadow root
|
// reason, as every wa-dialog in the app. A drawer's shadow root
|
||||||
// has the same shape, so the helper needs no change; under
|
// has the same shape, so the helper needs no change.
|
||||||
// `without-header` there is no heading to point at, which is
|
|
||||||
// that helper's documented `aria-label` path.
|
|
||||||
nameDialog(this.drawer);
|
nameDialog(this.drawer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onGlobalNavigate = () => {
|
private onGlobalNavigate = () => {
|
||||||
// A navigation from inside the sheet is the sheet's job done.
|
// A navigation from inside the drawer is the drawer's job done.
|
||||||
// The highlight is not this listener's business any more.
|
// The highlight is not this listener's business any more.
|
||||||
this.drawerOpen = false;
|
this.drawerOpen = false;
|
||||||
};
|
};
|
||||||
@@ -328,14 +263,12 @@ export class BottomNav extends LitElement {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<wa-drawer
|
<wa-drawer
|
||||||
placement="bottom"
|
placement="start"
|
||||||
without-header
|
|
||||||
label="All views"
|
label="All views"
|
||||||
data-testid="nav-drawer"
|
data-testid="nav-drawer"
|
||||||
?open=${this.drawerOpen}
|
?open=${this.drawerOpen}
|
||||||
@wa-after-hide=${this.onDrawerHide}
|
@wa-after-hide=${this.onDrawerHide}
|
||||||
>
|
>
|
||||||
<div class="grip"></div>
|
|
||||||
${this.drawerOpen
|
${this.drawerOpen
|
||||||
? html`<app-sidebar expanded></app-sidebar>`
|
? html`<app-sidebar expanded></app-sidebar>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -155,10 +155,55 @@ export class MenuSurface extends LitElement {
|
|||||||
bottom was at y=452 on a 439px screen -- the one row a
|
bottom was at y=452 on a 439px screen -- the one row a
|
||||||
destructive action is most likely to be. The cap has to stay
|
destructive action is most likely to be. The cap has to stay
|
||||||
(a sheet covering the whole screen is a page, not a sheet),
|
(a sheet covering the whole screen is a page, not a sheet),
|
||||||
so the body is what gives. */
|
so the body is what gives.
|
||||||
|
|
||||||
|
**And a body that scrolls says so** (#207). Scrolling was the
|
||||||
|
whole of the fix above, which left the last item reachable
|
||||||
|
and nothing on screen admitting it was there -- measured at
|
||||||
|
424x439, eight items ending at y=470 with the fold at 439,
|
||||||
|
and worse when the cut lands on a row boundary, where the
|
||||||
|
sheet ends in a clean edge that reads as the end of the list.
|
||||||
|
|
||||||
|
Two layers, and the *order* is what asks the question: a
|
||||||
|
shadow pinned to the bottom of the box (attachment scroll),
|
||||||
|
and over it a cover of the sheet's own colour painted at the
|
||||||
|
end of the *content* (attachment local), which therefore
|
||||||
|
scrolls up over the shadow and hides it exactly when there is
|
||||||
|
nothing more to see. So the affordance is absent on a menu
|
||||||
|
that fits, present the moment one does not, and gone again at
|
||||||
|
the end of the list -- with no scroll listener, no
|
||||||
|
measurement, and nothing reaching into wa-dialog's shadow
|
||||||
|
root for the scroller. background-attachment is Chrome 4;
|
||||||
|
the reference device is Chrome 113.
|
||||||
|
|
||||||
|
**The curve is steep because the rows under it stay live.**
|
||||||
|
A scrim over a menu item is that item's text surface, and
|
||||||
|
this app's rule is that text clears 4.5:1 on every surface it
|
||||||
|
can sit on -- which the light ramp, whose bgElevated is
|
||||||
|
#e9ecef, is what makes non-theoretical. A row is 48px with
|
||||||
|
its label centred, so 32px of scrim that is already down to
|
||||||
|
a quarter strength at 14px reaches y-centre at about 0.06 and
|
||||||
|
spends its weight on the strip below the last legible label.
|
||||||
|
Measured on the dark ramp at x=300, flat 52,58,64 throughout
|
||||||
|
before: 50,56,62 at y=330, 33,37,40 at y=350 and 22,24,27 at
|
||||||
|
the bottom edge, and flat again at the end of the list. The
|
||||||
|
light ramp puts 9.9:1 on the last label. */
|
||||||
wa-dialog::part(body) {
|
wa-dialog::part(body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
var(--yj-bg-elevated, #343a40),
|
||||||
|
var(--yj-bg-elevated, #343a40)
|
||||||
|
)
|
||||||
|
bottom / 100% 32px no-repeat local,
|
||||||
|
linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(0, 0, 0, 0.6) 0%,
|
||||||
|
rgba(0, 0, 0, 0.25) 45%,
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
)
|
||||||
|
bottom / 100% 32px no-repeat scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A sheet is dragged at with a thumb, so it says where its top
|
/* A sheet is dragged at with a thumb, so it says where its top
|
||||||
|
|||||||
@@ -42,26 +42,6 @@ export class AppSidebar extends LitElement {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A host that has made room owns the box, not just the labels
|
|
||||||
(#71). The bottom-nav sheet is the width of the screen and
|
|
||||||
provides the one scroll container it needs; left to itself
|
|
||||||
the sidebar is a 200px column with a second scroller inside
|
|
||||||
it, which is what a nested scroll region feels like under a
|
|
||||||
thumb -- part of the surface moves and part of it does not. */
|
|
||||||
:host([expanded]) {
|
|
||||||
max-width: none;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* And the width is not draggable there. It is a mouse
|
|
||||||
affordance (mousedown, col-resize) sitting on the right edge
|
|
||||||
of a touch surface, where the compatibility mouse events a
|
|
||||||
tap synthesises can start a resize nobody asked for. */
|
|
||||||
:host([expanded]) .resize-handle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -165,25 +145,6 @@ export class AppSidebar extends LitElement {
|
|||||||
:host(.collapsed) li button wa-icon {
|
:host(.collapsed) li button wa-icon {
|
||||||
font-size: var(--yj-icon-md);
|
font-size: var(--yj-icon-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Below 600px the only place this renders is the bottom-nav
|
|
||||||
sheet -- the shell's own copy is display: none there -- so
|
|
||||||
the rows are sized for the thumb that opened it: 48px, which
|
|
||||||
is #186's floor and the height every row in #60's context
|
|
||||||
sheet already has. A media query inside a shadow root is
|
|
||||||
answered by the viewport, so the component states this
|
|
||||||
itself rather than the sheet reaching in. */
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
ul {
|
|
||||||
padding: 4px 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li button {
|
|
||||||
min-height: 48px;
|
|
||||||
padding: 12px 10px;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`];
|
`];
|
||||||
|
|
||||||
/** Delay in ms before a drag-hover triggers navigation. */
|
/** Delay in ms before a drag-hover triggers navigation. */
|
||||||
@@ -212,14 +173,11 @@ export class AppSidebar extends LitElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep the labels regardless of the viewport, for a host that has
|
* Keep the labels regardless of the viewport, for a host that has
|
||||||
* made room for them -- `bottom-nav`'s sheet, which is the whole
|
* made room for them -- `bottom-nav`'s drawer, which is the whole
|
||||||
* screen wide on the phone where this would otherwise auto-collapse
|
* screen wide on the phone where this would otherwise auto-collapse
|
||||||
* to icons. The auto-collapse is a *width* response to a narrow
|
* to icons. The auto-collapse is a *width* response to a narrow
|
||||||
* shell, and inside a sheet the shell is not what the sidebar is
|
* shell, and inside a drawer the shell is not what the sidebar is
|
||||||
* sharing space with.
|
* sharing space with.
|
||||||
*
|
|
||||||
* It says the host owns the *box*, not only the labels: the width,
|
|
||||||
* the scrolling and the resize handle all follow it (#71).
|
|
||||||
*/
|
*/
|
||||||
@property({ type: Boolean, reflect: true })
|
@property({ type: Boolean, reflect: true })
|
||||||
expanded = false;
|
expanded = false;
|
||||||
@@ -392,18 +350,9 @@ export class AppSidebar extends LitElement {
|
|||||||
* be a media query in the stylesheet.
|
* be a media query in the stylesheet.
|
||||||
*/
|
*/
|
||||||
private applyViewportWidth() {
|
private applyViewportWidth() {
|
||||||
// A host that made room decides how much: `bottom-nav`'s sheet
|
const narrow =
|
||||||
// is the whole screen wide, and the inline width below -- which
|
!this.expanded &&
|
||||||
// beats any rule the host could write -- would draw the old
|
(this.narrowViewport?.matches ?? false);
|
||||||
// 200px side drawer inside it.
|
|
||||||
if (this.expanded) {
|
|
||||||
this.style.width = '100%';
|
|
||||||
this.collapsed = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const narrow = this.narrowViewport?.matches ?? false;
|
|
||||||
const width = narrow
|
const width = narrow
|
||||||
? MIN_WIDTH
|
? MIN_WIDTH
|
||||||
: this.userWidth;
|
: this.userWidth;
|
||||||
|
|||||||
@@ -193,119 +193,4 @@ describe('bottom-nav', () => {
|
|||||||
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
|
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws "More" as a sheet rising from the bottom', async () => {
|
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
|
||||||
const drawer = shadow<HTMLElement>(el, 'wa-drawer');
|
|
||||||
|
|
||||||
// #71. A side drawer is a desktop shape: it opened away from the
|
|
||||||
// thumb that asked for it and drew a 200px column of a 424px
|
|
||||||
// screen. `placement` is the whole of the change to *where* it
|
|
||||||
// comes from, and `without-header` is what makes it the same sheet
|
|
||||||
// `menu-surface` draws rather than a second pattern with a title
|
|
||||||
// bar and a close button.
|
|
||||||
expect(drawer?.getAttribute('placement')).toBe('bottom');
|
|
||||||
expect(drawer?.hasAttribute('without-header')).toBe(true);
|
|
||||||
|
|
||||||
// Named all the same: `nameDialog`'s documented aria-label path,
|
|
||||||
// since without-header renders no heading to point at.
|
|
||||||
expect(drawer?.getAttribute('label')).toBe('All views');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves exactly one scroll container, and it is the sheet body', async () => {
|
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
|
||||||
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
|
|
||||||
|
|
||||||
if (!drawer) throw new Error('no drawer');
|
|
||||||
|
|
||||||
const shown = once(drawer, 'wa-after-show');
|
|
||||||
|
|
||||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
|
||||||
await shown;
|
|
||||||
await update(el, {});
|
|
||||||
|
|
||||||
// The report is "only part of the screen scrolls under my finger",
|
|
||||||
// and the cause is three boxes that each scroll: the dialog, its
|
|
||||||
// body, and the sidebar's own overflow-y host. Which one a drag
|
|
||||||
// moves depends on where the finger landed.
|
|
||||||
const dialog = drawer.shadowRoot?.querySelector('[part~="dialog"]');
|
|
||||||
const body = drawer.shadowRoot?.querySelector('[part~="body"]');
|
|
||||||
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
|
|
||||||
|
|
||||||
if (!dialog || !body || !sidebar) throw new Error('no sheet');
|
|
||||||
|
|
||||||
expect(getComputedStyle(dialog).overflowY).toBe('hidden');
|
|
||||||
expect(getComputedStyle(body).overflowY).toBe('auto');
|
|
||||||
expect(getComputedStyle(sidebar).overflowY).toBe('visible');
|
|
||||||
|
|
||||||
// And the one that does scroll keeps it to itself, or reaching the
|
|
||||||
// end of the destinations scrolls the page behind the sheet.
|
|
||||||
expect(getComputedStyle(body).overscrollBehaviorY).toBe('contain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives the sheet the whole width, which the sidebar does not take', async () => {
|
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
|
||||||
|
|
||||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
|
||||||
await update(el, {});
|
|
||||||
|
|
||||||
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
|
|
||||||
|
|
||||||
if (!sidebar) throw new Error('no sidebar');
|
|
||||||
|
|
||||||
// `app-sidebar` writes an *inline* width and caps itself at 400px,
|
|
||||||
// which beats any rule this host could write — so "the host owns
|
|
||||||
// the box" has to be part of what `expanded` means, or the sheet
|
|
||||||
// draws the old 200px column inside a full-width surface.
|
|
||||||
expect(sidebar.style.width).toBe('100%');
|
|
||||||
expect(getComputedStyle(sidebar).maxWidth).toBe('none');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sizes the sheet rows for a thumb, below the phone breakpoint', async () => {
|
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
|
||||||
|
|
||||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
|
||||||
await update(el, {});
|
|
||||||
|
|
||||||
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
|
|
||||||
const sheets = sidebar?.shadowRoot?.adoptedStyleSheets ?? [];
|
|
||||||
const phoneRules: string[] = [];
|
|
||||||
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
for (const rule of Array.from(sheet.cssRules)) {
|
|
||||||
if (!(rule instanceof CSSMediaRule)) continue;
|
|
||||||
|
|
||||||
if (!/max-width:\s*599px/.test(rule.conditionText)) continue;
|
|
||||||
|
|
||||||
for (const inner of Array.from(rule.cssRules)) {
|
|
||||||
phoneRules.push(inner.cssText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asserted against the parsed stylesheet, like
|
|
||||||
// `hover-affordance.test.ts` and for the same reason: this tier's
|
|
||||||
// iframe is not 599px wide, so the rule cannot be *rendered* here —
|
|
||||||
// but the regression worth catching is someone moving it out of the
|
|
||||||
// query, which nothing on a desktop draws differently.
|
|
||||||
expect(phoneRules.length).toBeGreaterThan(0);
|
|
||||||
expect(phoneRules.some((r) => /min-height:\s*48px/.test(r))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('takes the resize handle out of the sheet', async () => {
|
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
|
||||||
|
|
||||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
|
||||||
await update(el, {});
|
|
||||||
|
|
||||||
const handle = shadow<HTMLElement>(el, 'app-sidebar')
|
|
||||||
?.shadowRoot?.querySelector('.resize-handle');
|
|
||||||
|
|
||||||
if (!handle) throw new Error('no resize handle');
|
|
||||||
|
|
||||||
// A col-resize strip on the right edge of a touch surface: the
|
|
||||||
// compatibility mouse events a tap synthesises reach its
|
|
||||||
// `mousedown`, so it can start a resize nobody asked for.
|
|
||||||
expect(getComputedStyle(handle).display).toBe('none');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -206,6 +206,56 @@ describe('menu-surface', () => {
|
|||||||
expect(dismissed, 'no menu-dismiss reached the document').toBe(1);
|
expect(dismissed, 'no menu-dismiss reached the document').toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll affordance (#207), and this is the mechanism again
|
||||||
|
* rather than the symptom.
|
||||||
|
*
|
||||||
|
* The sheet's body has scrolled since #60 and said nothing about
|
||||||
|
* it: measured at 424x439, eight items ended at y=470 with the
|
||||||
|
* fold at 439, and where the cut lands on a row boundary the sheet
|
||||||
|
* ends in a clean edge that reads as the end of the list.
|
||||||
|
*
|
||||||
|
* What makes the fade *conditional* — absent on a menu that fits,
|
||||||
|
* present the moment one does not, gone again at the end of the
|
||||||
|
* list — is `background-attachment`, not a scroll listener: a cover
|
||||||
|
* of the sheet's own colour is painted at the end of the content
|
||||||
|
* and attached `local`, over a shadow pinned to the box and
|
||||||
|
* attached `scroll`. So the pair of attachments *is* the feature,
|
||||||
|
* and it is what this asserts. The rendered result was measured in
|
||||||
|
* the harness (dark ramp 52,58,64 flat before; 52,57,63 at the last
|
||||||
|
* label and 22,24,27 at the bottom edge with more below; flat again
|
||||||
|
* at the end of the list) and is on the PR.
|
||||||
|
*/
|
||||||
|
it('paints the fade only while there is more below', async () => {
|
||||||
|
const el = await surfaceWithPanel();
|
||||||
|
|
||||||
|
const wrapper = el.shadowRoot?.querySelector('wa-dialog');
|
||||||
|
|
||||||
|
await (wrapper as HTMLElement & { updateComplete: Promise<unknown> })
|
||||||
|
.updateComplete;
|
||||||
|
|
||||||
|
const body = wrapper?.shadowRoot?.querySelector('[part~="body"]');
|
||||||
|
|
||||||
|
expect(body, 'no body part to scroll').not.toBeNull();
|
||||||
|
|
||||||
|
const style = getComputedStyle(body as Element);
|
||||||
|
|
||||||
|
expect(style.overflowY, 'the body is what gives, not the cap').toBe(
|
||||||
|
'auto',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The cover scrolls with the content; the shadow does not. Either
|
||||||
|
// one alone is a fade that is always there or never there.
|
||||||
|
expect(
|
||||||
|
style.backgroundAttachment,
|
||||||
|
'the cover must be local and the shadow must not',
|
||||||
|
).toBe('local, scroll');
|
||||||
|
|
||||||
|
// Both sit at the bottom, or the cover hides nothing.
|
||||||
|
expect(style.backgroundPosition).toBe('50% 100%, 50% 100%');
|
||||||
|
expect(style.backgroundSize).toBe('100% 32px, 100% 32px');
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A dialog with no accessible name is what `utils/name-dialog.ts`
|
* A dialog with no accessible name is what `utils/name-dialog.ts`
|
||||||
* exists for; here the name is already written on the panel, so no
|
* exists for; here the name is already written on the panel, so no
|
||||||
|
|||||||
Reference in new issue
Block a user