mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 00:59:28 -04:00
fix(ui): keep the Models page fixed and give its tabs and filters room (#12899)
The document scrolled again on the gallery. The screen-reader spans in the table rows are position: absolute, and nothing above them was positioned, so they resolved against the viewport, escaped the table scroller and the pinned shell, and stretched the document to about twice the window height. Make the table scroller and the full-height page the containing block, so the spans are clipped with the rows. The tabs and filters were also packed tight. Raise the view tabs to 40px with an accent underline on the current one, space the facet chips and toggles apart, give toggles, chips, density and state segments a hit area of at least 36px (40 to 44px on phones), and leave more air between the filter rows. Existing --dk-* tokens only. Add a spec that checks the document height equals the window height while the table still overflows. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
6e3222922e
commit
f7011f1870
3 files changed
+83
-1
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
import { mockLedger } from './ledger-fixtures.js'
|
||||
|
||||
// The split view is meant to scroll inside itself. It is easy to regress into
|
||||
// scrolling the document instead, because the shell's height rules are floors
|
||||
@@ -64,6 +65,26 @@ test.describe('Models Explore - the view scrolls, not the page', () => {
|
||||
expect(paneOverflows).toBe('auto')
|
||||
})
|
||||
|
||||
test('the document itself does not scroll while the table does', async ({ page }) => {
|
||||
// Screen-reader spans inside the table are position: absolute. Without a
|
||||
// positioned scroller above them they escape every clip and stretch the
|
||||
// document, so the whole window scrolls instead of the table.
|
||||
// The shared fixture has a gallery long enough to scroll its table.
|
||||
await page.unroute('**/api/models*')
|
||||
await mockLedger(page)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.goto('/app/models')
|
||||
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const doc = document.scrollingElement
|
||||
const wrap = document.querySelector('.ledger-wrap')
|
||||
return { docScroll: doc.scrollHeight, docClient: doc.clientHeight, wrapScroll: wrap.scrollHeight, wrapClient: wrap.clientHeight }
|
||||
})
|
||||
expect(m.docScroll).toBe(m.docClient)
|
||||
expect(m.wrapScroll).toBeGreaterThan(m.wrapClient)
|
||||
})
|
||||
|
||||
test('stacked below the breakpoint it scrolls with the document again', async ({ page }) => {
|
||||
// Pinning the height when the columns stack would trap both halves in short
|
||||
// scrollers, so the constraint is lifted there on purpose.
|
||||
|
||||
@@ -9229,6 +9229,11 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); }
|
||||
/* The page scrolls inside the pinned shell; the rail and pane keep their own
|
||||
inner scrollers for long lists and long details. */
|
||||
overflow-y: auto;
|
||||
/* Containing block for absolutely positioned descendants. The screen-reader
|
||||
spans inside long tables (.dk-sr-only is position: absolute) otherwise
|
||||
resolve against the viewport, escape every scroller below, and give the
|
||||
document its own scroll height, so the whole window scrolls again. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The header, fused. A slim row that reads as the top of the view rather than
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ledger-wrap { flex: 1; min-height: 160px; }
|
||||
/* The table's scroller is also the containing block for its screen-reader
|
||||
spans, so they are clipped with the rows instead of stretching the page. */
|
||||
.ledger-wrap { flex: 1; min-height: 160px; position: relative; }
|
||||
|
||||
.ledger__pane {
|
||||
min-width: 0;
|
||||
@@ -657,6 +659,60 @@ tr[data-selected="true"] .ledger-mark {
|
||||
.ledger-confirm__ok { display: flex; gap: var(--dk-space-2); align-items: center; color: var(--dk-ok-text); font-weight: var(--dk-weight-semibold); }
|
||||
.ledger-confirm__warn { color: var(--dk-warn-text); }
|
||||
|
||||
/* --- Room for the controls ------------------------------------------------
|
||||
Tabs choose the view, so they are the larger and more strongly marked
|
||||
control; filters narrow what the view shows and stay a step smaller. Every
|
||||
control here has a hit area of at least 36px, tabs 40px. */
|
||||
|
||||
.models-page .models-bar { row-gap: var(--dk-space-3); padding-bottom: var(--dk-space-4); }
|
||||
.models-page .models-bar .view-bar__count { margin-inline-start: var(--dk-space-1); }
|
||||
|
||||
.models-page .models-tabs { gap: var(--dk-space-1); padding: 4px; margin-inline-start: var(--dk-space-3); }
|
||||
.models-page .models-tabs .dk-hubtab { height: 40px; padding: 0 var(--dk-space-4); gap: var(--dk-space-2); }
|
||||
/* The current view lifts onto a card and carries an accent underline, so it
|
||||
reads as selected without relying on the surface step alone. */
|
||||
.models-page .models-tabs .dk-hubtab[aria-current="page"] {
|
||||
box-shadow: var(--dk-shadow-rest), inset 0 0 0 1px var(--dk-edge), inset 0 -2px 0 var(--dk-accent);
|
||||
}
|
||||
.models-page .models-tabs .dk-hubtab:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
|
||||
|
||||
.models-page .cat-bar { margin-bottom: var(--dk-space-3); }
|
||||
|
||||
.models-page .ledger-controls { gap: var(--dk-space-4); margin-bottom: var(--dk-space-4); }
|
||||
.models-page .ledger-controls__row { gap: var(--dk-space-3) var(--dk-space-4); }
|
||||
|
||||
/* Toggles: the label is part of the target. */
|
||||
.models-page .ledger-refine { gap: var(--dk-space-2) var(--dk-space-5); }
|
||||
.models-page .ledger-refine .filter-bar-group__toggle { min-height: 40px; gap: var(--dk-space-3); }
|
||||
|
||||
/* Facet chips: taller, and spaced so neighbours are not hit by mistake. */
|
||||
.models-page .ledger-facets { gap: var(--dk-space-2); padding: 4px 32px 8px 4px; }
|
||||
.models-page .ledger-facets .dk-chip { height: 36px; padding: 0 var(--dk-space-4); }
|
||||
.models-page .ledger-facets .dk-chip[aria-pressed="true"] {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dk-accent-text) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Density and state segments share one height. */
|
||||
.models-page .ledger-density { padding: 4px; }
|
||||
.models-page .ledger-density .dk-seg { width: 40px; height: 36px; }
|
||||
.models-page .ledger-states { padding: 4px; gap: var(--dk-space-1); }
|
||||
.models-page .ledger-states .dk-seg { height: 36px; padding: 0 var(--dk-space-4); gap: var(--dk-space-2); }
|
||||
.models-page .ledger-states .dk-seg[aria-selected="true"] {
|
||||
box-shadow: var(--dk-shadow-rest), inset 0 0 0 1px var(--dk-edge), inset 0 -2px 0 var(--dk-accent);
|
||||
}
|
||||
|
||||
.models-page .ledger-bar { margin-bottom: var(--dk-space-3); min-height: 32px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Thumb-sized targets, and the tabs take the full width as two halves. */
|
||||
.models-page .models-tabs { width: 100%; margin-inline-start: 0; }
|
||||
.models-page .models-tabs .dk-hubtab { flex: 1 1 0; justify-content: center; height: 44px; }
|
||||
.models-page .ledger-refine .filter-bar-group__toggle { min-height: 44px; }
|
||||
.models-page .ledger-facets .dk-chip { height: 40px; }
|
||||
.models-page .ledger-density .dk-seg { width: 44px; height: 40px; }
|
||||
.models-page .ledger-states .dk-seg { height: 40px; }
|
||||
}
|
||||
|
||||
/* --- Narrow ---------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
Reference in new issue
Block a user