mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
fix(frontend): harden inline resource drawers
This commit is contained in:
@@ -4301,8 +4301,10 @@ workload, and infrastructure inline drawers; callers may pass row-specific
|
||||
`data-*` attributes, colspan, and content classes, but they must not recreate
|
||||
the surface-alt detail row shell locally. The content shell must clip
|
||||
horizontal paint below the large breakpoint without becoming a scroll
|
||||
container, then restore visible overflow for the static desktop layout, so
|
||||
long operator-state copy cannot escape the shared row border.
|
||||
container, reset the parent table's `whitespace-nowrap` inheritance, and allow
|
||||
its descendants to shrink, then restore visible overflow for the static
|
||||
desktop layout. Long operator-state copy must wrap inside the shared row border
|
||||
instead of painting beneath adjacent controls or disappearing at the clip edge.
|
||||
Inline detail section content is registry-backed separately from the row shell.
|
||||
`DetailSectionTable`, `InlineDetailPanel`, and `detailSectionModel.ts` own
|
||||
detail row compaction, section-table rendering, value-tone classes, and the
|
||||
@@ -4593,7 +4595,10 @@ When those workspace tabs need an embedded control-bar treatment, they should
|
||||
still stay on the one canonical `frontend-modern/src/components/shared/Subtabs.tsx`
|
||||
primitive and reuse the established shell, list, and button class pattern
|
||||
already proven on owning surfaces like operations rather than introducing new
|
||||
variant APIs on the primitive.
|
||||
variant APIs on the primitive. When that rail overflows on phone widths,
|
||||
`Subtabs` owns visible, accessible edge-scroll controls and keeps them in sync
|
||||
with native scrolling and rail resize; callers must not add drawer-local arrow
|
||||
overlays or leave clipped tab labels as the only overflow cue.
|
||||
The search-input enhancement surfaces now follow that same owner split.
|
||||
`frontend-modern/src/components/shared/SearchInputEnhancements.tsx` stays the
|
||||
render shell, `frontend-modern/src/components/shared/useSearchInputEnhancements.ts`
|
||||
|
||||
@@ -1943,7 +1943,11 @@ owners preserve native scrollbar geometry with measured spacers, project wheel
|
||||
and vertical touch movement before native scroll advances, and retain a
|
||||
directional runway around the viewport. A feature may choose a smaller bounded
|
||||
budget, but it must not restore an unbounded `<For>` over estate-sized data or
|
||||
show a blank viewport while the keyed window catches up.
|
||||
show a blank viewport while the keyed window catches up. `PlatformWindowedRows`
|
||||
also reconciles unique string/number resource IDs into stable row proxies, so
|
||||
sparse live snapshots update fields without remounting an open inline drawer
|
||||
or discarding its tab and form state. Rows without a unique logical ID retain
|
||||
reference-keyed rendering rather than paying for guessed index identity.
|
||||
The workload guest-row path now follows the same pattern: the render shell
|
||||
stays in `frontend-modern/src/components/Workloads/GuestRow.tsx`, tooltip-backed
|
||||
cell presentation lives in `frontend-modern/src/components/Workloads/GuestRowCells.tsx`,
|
||||
@@ -2119,7 +2123,10 @@ it may retain total capacity for explanatory copy, but it emits no numeric
|
||||
memory segments, percentage, or fit calculation that could make unknown usage
|
||||
look like zero. Platform tables and resource-detail mappers consume the same
|
||||
canonical unavailable state instead of recomputing `total - free` in render
|
||||
hot paths.
|
||||
hot paths. The shell keeps its fitted percentage/capacity label on a
|
||||
semi-opaque surface scrim with base-content contrast so segment colors cannot
|
||||
make the small readout illegible; this remains CSS presentation only and adds
|
||||
no measurement or per-row runtime work.
|
||||
The dashboard metric bar now follows that same pattern: the shell stays in
|
||||
`frontend-modern/src/components/Workloads/MetricBar.tsx`, while width,
|
||||
show-label, sublabel-fit, and threshold-color derivation live in
|
||||
|
||||
@@ -2222,7 +2222,9 @@ capability-gated. By default the resource follows the tenant Patrol mode.
|
||||
Enabling the optional limit requires selecting exact capabilities and may add a
|
||||
recurring daily time/timezone window; copy must state that the control narrows
|
||||
the tenant mode, while `NeverAutoRemediate` disables automatic action for the
|
||||
resource.
|
||||
resource. At phone widths, the collapsed maintenance-window action stacks
|
||||
below its explanatory copy so the button cannot squeeze policy text into a
|
||||
narrow side column; desktop retains the compact side-by-side layout.
|
||||
The UI must never infer eligibility from capability names, severity, or the
|
||||
human approval floor.
|
||||
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "f18f15bf7b1fbdf1078bf412754c059694c8b996",
|
||||
"verified_at": "2026-08-25T15:58:23Z",
|
||||
"base_sha": "f5eb87437a396048959a851584d3104326e0d671",
|
||||
"verified_at": "2026-08-25T16:41:20Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/UpdateBanner.tsx",
|
||||
"frontend-modern/src/components/whatsNewModel.ts"
|
||||
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx",
|
||||
"frontend-modern/src/components/Workloads/StackedMemoryBar.tsx",
|
||||
"frontend-modern/src/components/shared/InlineDetailTableRow.tsx",
|
||||
"frontend-modern/src/components/shared/Subtabs.tsx",
|
||||
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/UpdateBanner.tsx": "455a8546b5b98c4541993ea7f894ad352c87a29392ede0da2a562e05f85b09de",
|
||||
"frontend-modern/src/components/whatsNewModel.ts": "d81c8ae4fcc59cff06a63b4a7407a97848acd934f6b6e04535059dc89f652471"
|
||||
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "1a01f6e6c8db585c32b37afae33ed7b6671dc76e40e8f49c364ee78659e8a961",
|
||||
"frontend-modern/src/components/Workloads/StackedMemoryBar.tsx": "3266fa75221643199d41b7a87f8e07181e643171c744ac0e32e2ac9c6773e3bf",
|
||||
"frontend-modern/src/components/shared/InlineDetailTableRow.tsx": "0e169eb088c0bb0754dda5936b52baecc102f1a50853263517d76931729a1673",
|
||||
"frontend-modern/src/components/shared/Subtabs.tsx": "5106f14ec387c72bd2d3014200678f342e28b04d5ff372aa95410695fe0dc544",
|
||||
"frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx": "3f5d02c126379a44803bea3efcc803965d872eb188ec7e0305c973a82fa80924"
|
||||
},
|
||||
"routes": ["/release-notes-verification.html"],
|
||||
"routes": [
|
||||
"/truenas/overview",
|
||||
"/kubernetes/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -23,13 +32,16 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"expanded update banner with a v6.4.0-rc.2 customer-facing What's improved section",
|
||||
"desktop banner rendered both improvement bullets and excluded the Fixes section from the preview",
|
||||
"narrow banner retained the full preview at 390px with document scroll width equal to client width"
|
||||
"TrueNAS system summary with memory-capacity label scrim",
|
||||
"TrueNAS system drawer Manage tab at desktop width",
|
||||
"TrueNAS system drawer Manage tab at phone width",
|
||||
"Kubernetes cluster drawer with five-tab overflow rail before and after scrolling"
|
||||
],
|
||||
"interactions": [
|
||||
"opened the isolated current-tree frontend fixture against the local mock backend",
|
||||
"expanded the update banner with Show more",
|
||||
"inspected rendered text, layout geometry, and horizontal overflow at desktop and narrow widths"
|
||||
"opened the TrueNAS system inline drawer and selected Manage",
|
||||
"verified Manage remained selected through repeated realtime refresh samples and after the final main rebase",
|
||||
"inspected wrapped operator-policy help text and the stacked phone maintenance action",
|
||||
"opened a Kubernetes cluster inline drawer at 390px and used its right-edge tab scroll control",
|
||||
"verified the left-edge control appeared after scrolling and restored the default viewport"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -559,7 +559,7 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
opening it pre-fills with sensible defaults (start = now, end
|
||||
= +1h) or with the persisted window when one exists. */}
|
||||
<Show when={!schedulerOpen()}>
|
||||
<div class="flex items-center justify-between gap-3 pt-2 border-t border-border-subtle">
|
||||
<div class="flex flex-col items-stretch justify-between gap-3 border-t border-border-subtle pt-2 sm:flex-row sm:items-center">
|
||||
<div class="min-w-0 flex-1">
|
||||
<label class="text-sm font-medium text-base-content">Maintenance window</label>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
@@ -574,13 +574,13 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
|
||||
type="button"
|
||||
onClick={handleOpenScheduler}
|
||||
disabled={saving()}
|
||||
class="min-h-11 px-2.5 py-1 text-xs font-medium text-base-content border border-border rounded hover:bg-surface-hover disabled:opacity-50 sm:min-h-0"
|
||||
class="min-h-11 self-start rounded border border-border px-2.5 py-1 text-xs font-medium text-base-content hover:bg-surface-hover disabled:opacity-50 sm:min-h-0"
|
||||
>
|
||||
Schedule window
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 self-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenScheduler}
|
||||
|
||||
+4
-1
@@ -184,7 +184,10 @@ describe('ResourceOperatorStateSection', () => {
|
||||
expect(sectionSource).toContain('<FormSelect');
|
||||
expect(sectionSource).toContain('<FormTextarea');
|
||||
expect(sectionSource).toContain('min-h-11 min-w-11 px-1.5 py-0.5');
|
||||
expect(sectionSource).toContain('min-h-11 px-2.5 py-1 text-xs font-medium text-base-content');
|
||||
expect(sectionSource).toContain(
|
||||
'flex flex-col items-stretch justify-between gap-3 border-t border-border-subtle pt-2 sm:flex-row sm:items-center',
|
||||
);
|
||||
expect(sectionSource).toContain('min-h-11 self-start rounded border border-border');
|
||||
expect(sectionSource).toContain(
|
||||
'min-h-11 px-2.5 py-1 text-xs font-medium text-white bg-blue-600',
|
||||
);
|
||||
|
||||
@@ -230,6 +230,8 @@ describe('StackedMemoryBar', () => {
|
||||
// Should show sublabel in parentheses with bytes format
|
||||
const sublabel = screen.getByText(/4\.00 GB/);
|
||||
expect(sublabel).toBeInTheDocument();
|
||||
expect(sublabel).toHaveClass('text-base-content/80');
|
||||
expect(sublabel.parentElement).toHaveClass('bg-surface/80', 'shadow-sm');
|
||||
});
|
||||
|
||||
it('hides sublabel when container is too narrow', () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
|
||||
</svg>
|
||||
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-base-content leading-none pointer-events-none min-w-0 overflow-hidden">
|
||||
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">
|
||||
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis rounded-sm bg-surface/80 px-1 text-center shadow-sm">
|
||||
<span>
|
||||
<Show
|
||||
when={!presentation().unavailable}
|
||||
@@ -80,7 +80,7 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={presentation().showSublabel}>
|
||||
<span class="metric-sublabel font-normal text-muted">
|
||||
<span class="metric-sublabel font-normal text-base-content/80">
|
||||
{' '}
|
||||
({presentation().displaySublabel})
|
||||
</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ import toggleSource from './Toggle.tsx?raw';
|
||||
import toggleModelSource from './toggleModel.ts?raw';
|
||||
import toggleStateSource from './useToggleState.ts?raw';
|
||||
import filterToolbarSource from './FilterToolbar.tsx?raw';
|
||||
import subtabsSource from './Subtabs.tsx?raw';
|
||||
|
||||
describe('FilterHeader', () => {
|
||||
afterEach(() => {
|
||||
@@ -35,6 +36,15 @@ describe('FilterHeader', () => {
|
||||
expect(container.querySelector('.flex.w-full.items-center.gap-2')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps clipped in-page tab controls visibly scrollable on phones', () => {
|
||||
expect(subtabsSource).toContain('overflow-x-auto');
|
||||
expect(subtabsSource).toContain('canScrollLeft');
|
||||
expect(subtabsSource).toContain('canScrollRight');
|
||||
expect(subtabsSource).toContain('scrollTabs(-1)');
|
||||
expect(subtabsSource).toContain('scrollTabs(1)');
|
||||
expect(subtabsSource).toContain('sm:hidden');
|
||||
});
|
||||
|
||||
it('keeps segmented controls on value callbacks while forwarding div attributes', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(() => (
|
||||
|
||||
@@ -7,7 +7,7 @@ export const INLINE_DETAIL_TABLE_CELL_CLASS = 'p-0 border-b border-border bg-sur
|
||||
// border below the lg breakpoint (issue #1622) without creating a scroll
|
||||
// container the way overflow-hidden would.
|
||||
export const INLINE_DETAIL_TABLE_CONTENT_CLASS =
|
||||
'sticky left-0 max-w-[calc(100vw-3.5rem)] overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
|
||||
'sticky left-0 min-w-0 max-w-[calc(100vw-3.5rem)] whitespace-normal overflow-x-clip px-2 py-3 sm:px-4 sm:py-4 lg:static lg:max-w-none lg:overflow-x-visible';
|
||||
|
||||
export interface InlineDetailTableRowProps extends TableRowProps {
|
||||
cellId?: string;
|
||||
|
||||
@@ -1225,6 +1225,9 @@ describe('shared primitive guardrails', () => {
|
||||
expect(subtabsSource).toContain('subtabsShellClass');
|
||||
expect(subtabsSource).toContain('subtabsListClass');
|
||||
expect(subtabsSource).toContain('subtabButtonClass');
|
||||
expect(subtabsSource).toContain('canScrollLeft');
|
||||
expect(subtabsSource).toContain('canScrollRight');
|
||||
expect(subtabsSource).toContain('sm:hidden');
|
||||
});
|
||||
|
||||
it('keeps object detail history behind the canonical Overview and History subtabs', () => {
|
||||
@@ -7662,6 +7665,8 @@ describe('shared primitive guardrails', () => {
|
||||
expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CELL_CLASS');
|
||||
expect(inlineDetailTableRowSource).toContain('INLINE_DETAIL_TABLE_CONTENT_CLASS');
|
||||
expect(inlineDetailTableRowSource).toContain('overflow-x-clip');
|
||||
expect(inlineDetailTableRowSource).toContain('whitespace-normal');
|
||||
expect(inlineDetailTableRowSource).toContain('min-w-0');
|
||||
expect(inlineDetailTableRowSource).toContain('lg:overflow-x-visible');
|
||||
expect(inlineDetailTableRowSource).toContain('event.stopPropagation()');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Component, createEffect, For, JSX, splitProps } from 'solid-js';
|
||||
import ChevronLeftIcon from 'lucide-solid/icons/chevron-left';
|
||||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
splitProps,
|
||||
} from 'solid-js';
|
||||
|
||||
export interface SubtabOption {
|
||||
value: string;
|
||||
@@ -25,6 +37,7 @@ interface SubtabsProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onChang
|
||||
export const subtabsShellClass = 'border-b border-border';
|
||||
export const subtabsListClass =
|
||||
'flex min-w-0 items-center gap-3 overflow-x-auto scrollbar-hide sm:gap-6';
|
||||
export const subtabsRailClass = 'relative min-w-0 flex-1';
|
||||
export const subtabsTrailingRowClass = 'flex flex-wrap items-center justify-between gap-3';
|
||||
export const subtabButtonClass =
|
||||
'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors sm:min-h-10 sm:py-2 sm:text-sm';
|
||||
@@ -32,6 +45,9 @@ export const subtabButtonActiveClass = 'border-blue-600 text-base-content';
|
||||
export const subtabButtonInactiveClass = 'border-transparent text-muted hover:text-base-content';
|
||||
export const Subtabs: Component<SubtabsProps> = (props) => {
|
||||
let tablistRef: HTMLDivElement | undefined;
|
||||
const [hasOverflow, setHasOverflow] = createSignal(false);
|
||||
const [canScrollLeft, setCanScrollLeft] = createSignal(false);
|
||||
const [canScrollRight, setCanScrollRight] = createSignal(false);
|
||||
const [local, divProps] = splitProps(props, [
|
||||
'value',
|
||||
'onChange',
|
||||
@@ -51,36 +67,99 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
|
||||
tablistRef
|
||||
?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
||||
?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
updateScrollControls();
|
||||
});
|
||||
});
|
||||
|
||||
const updateScrollControls = () => {
|
||||
const rail = tablistRef;
|
||||
if (!rail) return;
|
||||
const maxScrollLeft = Math.max(0, rail.scrollWidth - rail.clientWidth);
|
||||
setHasOverflow(maxScrollLeft > 1);
|
||||
setCanScrollLeft(rail.scrollLeft > 1);
|
||||
setCanScrollRight(rail.scrollLeft < maxScrollLeft - 1);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const rail = tablistRef;
|
||||
if (!rail) return;
|
||||
|
||||
rail.addEventListener('scroll', updateScrollControls, { passive: true });
|
||||
window.addEventListener('resize', updateScrollControls, { passive: true });
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === 'function' ? new ResizeObserver(updateScrollControls) : undefined;
|
||||
resizeObserver?.observe(rail);
|
||||
updateScrollControls();
|
||||
|
||||
onCleanup(() => {
|
||||
rail.removeEventListener('scroll', updateScrollControls);
|
||||
window.removeEventListener('resize', updateScrollControls);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
const scrollTabs = (direction: -1 | 1) => {
|
||||
const rail = tablistRef;
|
||||
if (!rail) return;
|
||||
rail.scrollBy({
|
||||
left: direction * Math.max(120, Math.round(rail.clientWidth * 0.7)),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const tablist = () => (
|
||||
<div
|
||||
ref={tablistRef}
|
||||
role="tablist"
|
||||
aria-label={local.ariaLabel}
|
||||
class={`${subtabsListClass} ${local.listClass ?? ''}`.trim()}
|
||||
>
|
||||
<For each={local.tabs}>
|
||||
{(tab) => {
|
||||
const selected = () => local.value === tab.value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected()}
|
||||
tabIndex={selected() ? 0 : -1}
|
||||
disabled={tab.disabled}
|
||||
onClick={() => local.onChange(tab.value)}
|
||||
class={`${subtabButtonClass} ${
|
||||
selected() ? subtabButtonActiveClass : subtabButtonInactiveClass
|
||||
} ${local.tabClass ?? ''}`.trim()}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<div class={subtabsRailClass}>
|
||||
<div
|
||||
ref={tablistRef}
|
||||
role="tablist"
|
||||
aria-label={local.ariaLabel}
|
||||
class={`${subtabsListClass} ${hasOverflow() ? 'pr-10' : ''} ${local.listClass ?? ''}`.trim()}
|
||||
>
|
||||
<For each={local.tabs}>
|
||||
{(tab) => {
|
||||
const selected = () => local.value === tab.value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected()}
|
||||
tabIndex={selected() ? 0 : -1}
|
||||
disabled={tab.disabled}
|
||||
onClick={() => local.onChange(tab.value)}
|
||||
class={`${subtabButtonClass} ${
|
||||
selected() ? subtabButtonActiveClass : subtabButtonInactiveClass
|
||||
} ${local.tabClass ?? ''}`.trim()}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={canScrollLeft()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 left-0 z-10 flex w-10 items-center justify-start bg-gradient-to-r from-surface via-surface to-transparent pl-1 text-muted hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:hidden"
|
||||
onClick={() => scrollTabs(-1)}
|
||||
aria-label={`${local.ariaLabel}: scroll left`}
|
||||
>
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-full border border-border bg-surface shadow-sm">
|
||||
<ChevronLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={canScrollRight()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 z-10 flex w-10 items-center justify-end bg-gradient-to-l from-surface via-surface to-transparent pr-1 text-muted hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:hidden"
|
||||
onClick={() => scrollTabs(1)}
|
||||
aria-label={`${local.ariaLabel}: scroll right`}
|
||||
>
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-full border border-border bg-surface shadow-sm">
|
||||
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ describe('InlineDetailTableRow', () => {
|
||||
expect(detail.parentElement).toHaveClass('sm:px-4');
|
||||
expect(detail.parentElement).toHaveClass('sticky');
|
||||
expect(detail.parentElement).toHaveClass('left-0');
|
||||
expect(detail.parentElement).toHaveClass('min-w-0');
|
||||
expect(detail.parentElement).toHaveClass('whitespace-normal');
|
||||
expect(detail.parentElement).toHaveClass('max-w-[calc(100vw-3.5rem)]');
|
||||
expect(detail.parentElement).toHaveClass('lg:static');
|
||||
expect(detail.parentElement).toHaveClass('lg:max-w-none');
|
||||
|
||||
@@ -76,4 +76,45 @@ describe('Subtabs', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('shows phone scroll affordances when the tab rail is clipped', async () => {
|
||||
render(() => (
|
||||
<Subtabs
|
||||
value="overview"
|
||||
onChange={vi.fn()}
|
||||
ariaLabel="Resource detail sections"
|
||||
tabs={[
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'history', label: 'History' },
|
||||
{ value: 'manage', label: 'Manage' },
|
||||
{ value: 'deployments', label: 'Deployments' },
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
const tablist = screen.getByRole('tablist', { name: 'Resource detail sections' });
|
||||
const scrollBy = vi.fn();
|
||||
Object.defineProperties(tablist, {
|
||||
clientWidth: { configurable: true, value: 180 },
|
||||
scrollWidth: { configurable: true, value: 420 },
|
||||
scrollLeft: { configurable: true, writable: true, value: 0 },
|
||||
scrollBy: { configurable: true, value: scrollBy },
|
||||
});
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
const scrollRight = await screen.findByRole('button', {
|
||||
name: 'Resource detail sections: scroll right',
|
||||
});
|
||||
expect(scrollRight).toHaveClass('sm:hidden');
|
||||
expect(screen.queryByRole('button', { name: /scroll left/i })).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(scrollRight);
|
||||
expect(scrollBy).toHaveBeenCalledWith({ left: 126, behavior: 'smooth' });
|
||||
|
||||
tablist.scrollLeft = 120;
|
||||
tablist.dispatchEvent(new Event('scroll'));
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Resource detail sections: scroll left' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,54 @@
|
||||
import { For, Show, type Accessor, type JSX } from 'solid-js';
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from 'solid-js';
|
||||
import { createStore, reconcile } from 'solid-js/store';
|
||||
|
||||
import { usePlatformWindowedItems } from './usePlatformWindowedItems';
|
||||
|
||||
export interface PlatformWindowedRowsProps<Row> {
|
||||
items: Accessor<readonly Row[]>;
|
||||
children: (item: Row, index: Accessor<number>) => JSX.Element;
|
||||
/**
|
||||
* Stable logical identity for rows rebuilt from live resource snapshots.
|
||||
* When omitted, rows with unique string/number `id` fields are stabilized
|
||||
* automatically; non-resource rows retain reference-keyed rendering.
|
||||
*/
|
||||
keyExtractor?: (item: Row) => string | number;
|
||||
colSpan?: number;
|
||||
estimatedRowHeight?: number;
|
||||
enableThreshold?: number;
|
||||
windowSize?: number;
|
||||
}
|
||||
|
||||
type StablePlatformRow<Row> = {
|
||||
__platformWindowKey: string | number;
|
||||
value: Row;
|
||||
};
|
||||
|
||||
const defaultRowKey = <Row,>(item: Row): string | number | undefined => {
|
||||
if (typeof item !== 'object' || item === null || !('id' in item)) return undefined;
|
||||
const id = (item as { id?: unknown }).id;
|
||||
return typeof id === 'string' || typeof id === 'number' ? id : undefined;
|
||||
};
|
||||
|
||||
const buildStableRows = <Row,>(
|
||||
items: readonly Row[],
|
||||
keyExtractor?: (item: Row) => string | number,
|
||||
): StablePlatformRow<Row>[] | undefined => {
|
||||
const keys = items.map((item) => keyExtractor?.(item) ?? defaultRowKey(item));
|
||||
if (keys.some((key) => key === undefined) || new Set(keys).size !== keys.length) return undefined;
|
||||
return items.map((value, index) => ({
|
||||
__platformWindowKey: keys[index]!,
|
||||
value,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical bounded renderer for ordinary platform table rows.
|
||||
*
|
||||
@@ -19,8 +57,27 @@ export interface PlatformWindowedRowsProps<Row> {
|
||||
* prewarms the runway while touch input remains compositor-native.
|
||||
*/
|
||||
export function PlatformWindowedRows<Row>(props: PlatformWindowedRowsProps<Row>) {
|
||||
const initialStableRows = buildStableRows(props.items(), props.keyExtractor);
|
||||
const [stableRows, setStableRows] = createStore<StablePlatformRow<Row>[]>(
|
||||
initialStableRows ?? [],
|
||||
);
|
||||
const [usesStableRows, setUsesStableRows] = createSignal(initialStableRows !== undefined);
|
||||
|
||||
createEffect(() => {
|
||||
const next = buildStableRows(props.items(), props.keyExtractor);
|
||||
if (!next) {
|
||||
setUsesStableRows(false);
|
||||
return;
|
||||
}
|
||||
setStableRows(reconcile(next, { key: '__platformWindowKey' }));
|
||||
setUsesStableRows(true);
|
||||
});
|
||||
|
||||
const renderItems = createMemo<readonly Row[]>(() =>
|
||||
usesStableRows() ? stableRows.map((stableRow) => stableRow.value) : props.items(),
|
||||
);
|
||||
const windowing = usePlatformWindowedItems({
|
||||
items: props.items,
|
||||
items: renderItems,
|
||||
estimatedItemHeight: props.estimatedRowHeight,
|
||||
enableThreshold: props.enableThreshold,
|
||||
windowSize: props.windowSize,
|
||||
@@ -31,7 +88,7 @@ export function PlatformWindowedRows<Row>(props: PlatformWindowedRowsProps<Row>)
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={windowing.isWindowed()} fallback={renderRows(props.items(), 0)}>
|
||||
<Show when={windowing.isWindowed()} fallback={renderRows(renderItems(), 0)}>
|
||||
<tr
|
||||
ref={windowing.setAnchorRef}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, render } from '@solidjs/testing-library';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal, onCleanup } from 'solid-js';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { PlatformWindowedRows } from '../PlatformWindowedRows';
|
||||
@@ -45,6 +46,49 @@ describe('PlatformWindowedRows', () => {
|
||||
expect(container.querySelectorAll('[data-platform-window-spacer]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves keyed row component state when live snapshots replace row objects', async () => {
|
||||
let mounts = 0;
|
||||
let disposals = 0;
|
||||
const [items, setItems] = createSignal([{ id: 'node-a', label: 'First snapshot' }]);
|
||||
|
||||
const StatefulRow = (props: { item: { id: string; label: string } }) => {
|
||||
mounts += 1;
|
||||
onCleanup(() => {
|
||||
disposals += 1;
|
||||
});
|
||||
return (
|
||||
<tr>
|
||||
<td>{props.item.label}</td>
|
||||
<td>
|
||||
<input aria-label="Row-local state" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<PlatformWindowedRows items={items}>
|
||||
{(item) => <StatefulRow item={item} />}
|
||||
</PlatformWindowedRows>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
const input = screen.getByRole('textbox', {
|
||||
name: 'Row-local state',
|
||||
}) as HTMLInputElement;
|
||||
input.value = 'still editing';
|
||||
setItems([{ id: 'node-a', label: 'Refreshed snapshot' }]);
|
||||
|
||||
expect(await screen.findByText('Refreshed snapshot')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Row-local state' })).toBe(input);
|
||||
expect(input).toHaveValue('still editing');
|
||||
expect(mounts).toBe(1);
|
||||
expect(disposals).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps estate-sized card lists within their configured mounted-item budget', () => {
|
||||
const items = () => Array.from({ length: 1_000 }, (_, index) => index);
|
||||
const { container } = render(() => (
|
||||
|
||||
Reference in New Issue
Block a user