web: pin Server/Client Settings modal header while body scrolls (#1753)
* web: pin Server/Client Settings modal header while body scrolls (#1698) Expanding enough accordion sections grew the Server Settings modal past the viewport, so the whole modal scrolled and carried the header (title + close) out of view. The header was a custom Group inside the modal body, and the modal used no `scrollAreaComponent`, so overflow was not confined to the body. Switch both the Server Settings and Client Settings modals from the default `<Modal>` (which always wraps children in `Modal.Body`) to the compound `Modal.Root`/`Modal.Content`/`Modal.Header`/`Modal.Body` API, moving the toggle/title/close row into `Modal.Header` (sticky by design) and setting `scrollAreaComponent={ScrollArea.Autosize}` so only the body scrolls. `Modal.Root` keeps the close-on-escape / close-on-click-outside / focus-trap / scroll-lock / portal defaults; `transitionProps` restores the fade-down animation the `<Modal>` wrapper otherwise supplies. Client Settings does not yet have enough content to scroll but is kept consistent per the issue. Closes #1698 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs(pr-screenshots): Server Settings modal sticky-header proof (#1698) Two frames from the ServerSettingsModal story (all sections expanded, short viewport): header pinned at the top of the scroll and again scrolled down, showing it stays put while only the body scrolls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * web: move Modal.Root fade-down transition into the theme (#1698 review) Addresses the review note on PR #1753: the `transitionProps={{ transition: "fade-down", duration: 200 }}` literal was duplicated in both settings modals to restore the animation `<Modal>` defaults to but `Modal.Root` doesn't inherit. Move it to a `ThemeModalRoot` (`Modal.Root.extend`) registered under the `ModalRoot` theme key, mirroring the existing `ThemeScrollAreaAutosize` precedent. Both call sites drop the literal; any future compound `Modal.Root` now animates consistently with every plain `<Modal>` from one source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2b866110d
commit
092165476e
8 changed files with 136 additions and 74 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, Stack } from "@mantine/core";
|
||||
import { CloseButton, Group, Modal, ScrollArea } from "@mantine/core";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import {
|
||||
ClientSettingsForm,
|
||||
|
|
@ -61,36 +61,46 @@ export function ClientSettingsModal({
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// Compound Modal so the header lives in `Modal.Header` (sticky by design)
|
||||
// while `scrollAreaComponent` confines overflow to `Modal.Body` (#1698 —
|
||||
// kept consistent with ServerSettingsModal for when this grows enough to
|
||||
// scroll). The fade-down transition `<Modal>` defaults to (but `Modal.Root`
|
||||
// doesn't inherit) is supplied app-wide by `ThemeModalRoot`.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Group justify="space-between" wrap="nowrap" w="100%">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
/>
|
||||
{/* `Modal.Title` names the dialog (wires `aria-labelledby`). */}
|
||||
<Modal.Title ta="center" flex={1}>
|
||||
Client Settings
|
||||
</Modal.Title>
|
||||
<CloseButton aria-label="Close" onClick={handleClose} />
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<ClientSettingsForm
|
||||
settings={settings}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
revealErrors={revealErrors}
|
||||
/>
|
||||
{/* `Modal.Title` names the dialog (wires `aria-labelledby`). */}
|
||||
<Modal.Title ta="center" flex={1}>
|
||||
Client Settings
|
||||
</Modal.Title>
|
||||
<CloseButton aria-label="Close" onClick={handleClose} />
|
||||
</Group>
|
||||
<ClientSettingsForm
|
||||
settings={settings}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
revealErrors={revealErrors}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, Stack } from "@mantine/core";
|
||||
import { CloseButton, Group, Modal, ScrollArea } from "@mantine/core";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
|
|
@ -232,57 +232,68 @@ export function ServerSettingsModal({
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// Compound Modal so the header lives in `Modal.Header` (sticky by design)
|
||||
// while `scrollAreaComponent` confines overflow to `Modal.Body` — otherwise
|
||||
// expanding enough accordion sections grows the whole modal past the
|
||||
// viewport and scrolls the header out of view (#1698). The fade-down
|
||||
// transition `<Modal>` defaults to (but `Modal.Root` doesn't inherit) is
|
||||
// supplied app-wide by `ThemeModalRoot`.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Group justify="space-between" wrap="nowrap" w="100%">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
/>
|
||||
{/* `Modal.Title` names the dialog (wires `aria-labelledby`). */}
|
||||
<Modal.Title ta="center" flex={1}>
|
||||
Server Settings
|
||||
</Modal.Title>
|
||||
<CloseButton aria-label="Close" onClick={onClose} />
|
||||
</Group>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<ServerSettingsForm
|
||||
settings={settings}
|
||||
serverType={serverType}
|
||||
isStdio={isStdio}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onAddHeader={handleAddHeader}
|
||||
onRemoveHeader={handleRemoveHeader}
|
||||
onHeaderChange={handleHeaderChange}
|
||||
onAddEnv={handleAddEnv}
|
||||
onRemoveEnv={handleRemoveEnv}
|
||||
onEnvChange={handleEnvChange}
|
||||
onCwdChange={handleCwdChange}
|
||||
onAddMetadata={handleAddMetadata}
|
||||
onRemoveMetadata={handleRemoveMetadata}
|
||||
onMetadataChange={handleMetadataChange}
|
||||
onTimeoutChange={handleTimeoutChange}
|
||||
onAutoRefreshChange={handleAutoRefreshChange}
|
||||
onPaginatedListsChange={handlePaginatedListsChange}
|
||||
onAdvertisedExtensionChange={handleAdvertisedExtensionChange}
|
||||
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
|
||||
onProtocolEraChange={handleProtocolEraChange}
|
||||
onModernLogLevelChange={handleModernLogLevelChange}
|
||||
negotiatedEra={negotiatedEra}
|
||||
onOAuthChange={handleOAuthChange}
|
||||
onClearStoredOAuth={onClearStoredOAuth}
|
||||
onAddRoot={handleAddRoot}
|
||||
onRemoveRoot={handleRemoveRoot}
|
||||
onRootChange={handleRootChange}
|
||||
/>
|
||||
{/* `Modal.Title` names the dialog (wires `aria-labelledby`). */}
|
||||
<Modal.Title ta="center" flex={1}>
|
||||
Server Settings
|
||||
</Modal.Title>
|
||||
<CloseButton aria-label="Close" onClick={onClose} />
|
||||
</Group>
|
||||
<ServerSettingsForm
|
||||
settings={settings}
|
||||
serverType={serverType}
|
||||
isStdio={isStdio}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onAddHeader={handleAddHeader}
|
||||
onRemoveHeader={handleRemoveHeader}
|
||||
onHeaderChange={handleHeaderChange}
|
||||
onAddEnv={handleAddEnv}
|
||||
onRemoveEnv={handleRemoveEnv}
|
||||
onEnvChange={handleEnvChange}
|
||||
onCwdChange={handleCwdChange}
|
||||
onAddMetadata={handleAddMetadata}
|
||||
onRemoveMetadata={handleRemoveMetadata}
|
||||
onMetadataChange={handleMetadataChange}
|
||||
onTimeoutChange={handleTimeoutChange}
|
||||
onAutoRefreshChange={handleAutoRefreshChange}
|
||||
onPaginatedListsChange={handlePaginatedListsChange}
|
||||
onAdvertisedExtensionChange={handleAdvertisedExtensionChange}
|
||||
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
|
||||
onProtocolEraChange={handleProtocolEraChange}
|
||||
onModernLogLevelChange={handleModernLogLevelChange}
|
||||
negotiatedEra={negotiatedEra}
|
||||
onOAuthChange={handleOAuthChange}
|
||||
onClearStoredOAuth={onClearStoredOAuth}
|
||||
onAddRoot={handleAddRoot}
|
||||
onRemoveRoot={handleRemoveRoot}
|
||||
onRootChange={handleRootChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,3 +12,18 @@ export const ThemeModal = Modal.extend({
|
|||
closeButtonProps: { "aria-label": "Close" },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Restore the fade-down open/close animation on the compound `Modal.Root` API.
|
||||
* The default `<Modal>` wrapper hardcodes `transitionProps: { transition:
|
||||
* "fade-down", duration: 200 }`, but `Modal.Root` does not inherit it — so
|
||||
* modals built from `Modal.Root` (e.g. Server/Client Settings, which need a
|
||||
* sticky `Modal.Header` + scrollable `Modal.Body`, see #1698) would otherwise
|
||||
* animate differently from every plain `<Modal>`. Setting it here keeps them
|
||||
* consistent app-wide without repeating the literal at each call site.
|
||||
*/
|
||||
export const ThemeModalRoot = Modal.Root.extend({
|
||||
defaultProps: {
|
||||
transitionProps: { transition: "fade-down", duration: 200 },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export { ThemeCode } from "./Code";
|
|||
export { ThemeFlex } from "./Flex";
|
||||
export { ThemeGroup } from "./Group";
|
||||
export { ThemeInput } from "./Input";
|
||||
export { ThemeModal } from "./Modal";
|
||||
export { ThemeModal, ThemeModalRoot } from "./Modal";
|
||||
export { ThemePaper } from "./Paper";
|
||||
export { ThemeScrollArea, ThemeScrollAreaAutosize } from "./ScrollArea";
|
||||
export { ThemeSelect } from "./Select";
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
ThemeGroup,
|
||||
ThemeInput,
|
||||
ThemeModal,
|
||||
ThemeModalRoot,
|
||||
ThemePaper,
|
||||
ThemeScrollArea,
|
||||
ThemeScrollAreaAutosize,
|
||||
|
|
@ -194,6 +195,7 @@ export const theme = createTheme({
|
|||
Group: ThemeGroup,
|
||||
Input: ThemeInput,
|
||||
Modal: ThemeModal,
|
||||
ModalRoot: ThemeModalRoot,
|
||||
Paper: ThemePaper,
|
||||
ScrollArea: ThemeScrollArea,
|
||||
ScrollAreaAutosize: ThemeScrollAreaAutosize,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,27 @@
|
|||
# Server/Client Settings modal: sticky header while body scrolls (#1698) — proof screenshots
|
||||
|
||||
Expanding enough accordion sections grew the Server Settings modal past the
|
||||
viewport, scrolling the whole modal and taking the header (title + close) out of
|
||||
view. The fix moves the header into a real `Modal.Header` (sticky by design) and
|
||||
adds `scrollAreaComponent={ScrollArea.Autosize}` so only `Modal.Body` scrolls.
|
||||
Captured from the `ServerSettingsModal` Storybook story (`FullyConfigured`, all
|
||||
sections expanded, ~680px viewport) via Playwright.
|
||||
|
||||

|
||||
|
||||
At the top of the scroll: the **Server Settings** header (collapse-all toggle,
|
||||
title, close) sits above the first section.
|
||||
|
||||

|
||||
|
||||
Scrolled down through the body (now showing Advertised Extensions → Custom
|
||||
Headers → Request Metadata): the **exact same header stays pinned** at the top.
|
||||
Measured in-page: the body scroll container is `scrollHeight` 2253 ×
|
||||
`clientHeight` 612 (scrolls internally), and the header is `position: sticky`
|
||||
with its top flush to the dialog top after scrolling.
|
||||
|
||||
---
|
||||
|
||||
# TUI array-of-enum renders as a select on `items.enum` alone (#1751) — proof screenshots
|
||||
|
||||
Follow-up to #1691. The TUI `schemaToForm` array-of-enum branch was nested under
|
||||
|
|
|
|||
BIN
pr-screenshots/ssm-1698-header-pinned-scrolled.png
Normal file
BIN
pr-screenshots/ssm-1698-header-pinned-scrolled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
pr-screenshots/ssm-1698-header-pinned-top.png
Normal file
BIN
pr-screenshots/ssm-1698-header-pinned-top.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Loading…
Reference in a new issue