diff --git a/Documentation/Filter/index.md b/Documentation/Filter/index.md
index 3de144ef..2c11984b 100644
--- a/Documentation/Filter/index.md
+++ b/Documentation/Filter/index.md
@@ -315,7 +315,7 @@ The first filter group starts expanded. The hook re-syncs its state when the set
## Accessibility and keyboard
-- The panel portals into the `CratisComponentsProvider` [overlay container](../Common/cratis-components-provider.md#choose-an-overlay-container), or `document.body` without one. It stays anchored at a fixed position below `anchorRef` and follows the anchor on scroll and resize. If a configured container is temporarily unavailable (`getContainer()` returns `null`), the panel waits rather than rendering into the body; a later render can mount it when the container is available. Inside a Cratis modal `Dialog`, a shared container outside the modal would hide the panel from assistive technology and pull focus back into the modal. In that case the panel mounts inside the dialog's modal root instead. If the dialog has a persistent fixed containing block (for example, a `pt.root` transform), the panel stays within the dialog's visible bounds and its contents scroll to keep options reachable. A container already inside the modal remains the destination.
+- The panel portals into the `CratisComponentsProvider` [overlay container](../Common/cratis-components-provider.md#choose-an-overlay-container), or `document.body` without one. It stays anchored at a fixed position below `anchorRef` and follows the anchor on scroll and resize. If a configured container is temporarily unavailable (`getContainer()` returns `null`), the panel waits rather than rendering into the body; a later render can mount it when the container is available. Inside a Cratis modal `Dialog`, a shared container outside the modal would hide the panel from assistive technology and pull focus back into the modal. In that case the panel mounts inside the dialog's modal root instead. If the dialog has a persistent fixed containing block (for example, a `pt.root` transform), the panel stays within the dialog's visible bounds and its contents scroll to keep options reachable. A container already inside the modal remains the destination. Translated and scaled overlay containers are supported; rotated or skewed overlay containers are not.
- The panel renders as a `
` with `role="dialog"`, so it is exposed as a named, non-modal dialog, not an `
` complementary landmark. Override its English default name with `aria-label` for one panel, or set `messages.filter.label` on `CratisComponentsProvider` for all panels. An empty dialog label is retained, as with other provider label groups. Opening the panel moves focus into the dialog; if an expanded group's `autoFocus` search is available, it receives focus instead. Focus is not trapped. Set `aria-expanded` on your trigger, as in the Quick Start.
- Escape closes the panel and returns focus to `anchorRef` if focus is inside the panel or on its anchor. Escape with focus elsewhere does nothing. Outside mousedown closes without moving focus.
- Each group header is a button with `aria-expanded`. The clear button is named by `clearFilterAriaLabel` or `clearRangeAriaLabel`.
diff --git a/Source/Filter/FilterPanel.tsx b/Source/Filter/FilterPanel.tsx
index 6f7ad199..88fe3365 100644
--- a/Source/Filter/FilterPanel.tsx
+++ b/Source/Filter/FilterPanel.tsx
@@ -332,7 +332,13 @@ export function FilterPanel({
// Preserve the viewport gutter even when a containing block scales the dropdown.
const scaleX = origin.width || 1;
const scaleY = origin.height || 1;
- const viewport = { width: window.innerWidth, height: window.innerHeight };
+ // Layoutless renderers report zero; browser layout uses the scrollbar-free viewport.
+ // In quirks mode the viewport's client size is reported on the body, not the root.
+ const viewportElement = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
+ const viewport = {
+ width: viewportElement.clientWidth || window.innerWidth,
+ height: viewportElement.clientHeight || window.innerHeight,
+ };
const rootRect = modalRoot && modalRoot.contains(portalContainer)
? modalRoot.getBoundingClientRect() : null;
const rootEdges = rootRect && (rootProbe ? measureProbe(rootProbe) :
diff --git a/Source/Filter/for_FilterPanel/when_measuring_a_quirks_mode_viewport.tsx b/Source/Filter/for_FilterPanel/when_measuring_a_quirks_mode_viewport.tsx
new file mode 100644
index 00000000..c0a25580
--- /dev/null
+++ b/Source/Filter/for_FilterPanel/when_measuring_a_quirks_mode_viewport.tsx
@@ -0,0 +1,60 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import { act, createRef } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { expect } from 'chai';
+import { afterEach, beforeEach, describe, it, vi } from 'vitest';
+import { FilterPanel } from '../FilterPanel';
+
+const anchorRef = createRef();
+const noOp = () => undefined;
+let host: HTMLDivElement;
+let root: Root;
+
+const panelMaxHeight = () => parseFloat(document.querySelector('.pv-filter-dropdown')!.style.maxHeight);
+
+const renderOpenPanel = async () => {
+ await act(async () => root.render(<>
+ Filters
+
+ >));
+};
+
+describe('when measuring the viewport of a page in quirks mode', () => {
+ beforeEach(async () => {
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ // In quirks mode the root element's client size is its own box, which can exceed the
+ // viewport; the viewport's client size is reported on the body instead.
+ vi.spyOn(document, 'compatMode', 'get').mockReturnValue('BackCompat');
+ vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1024);
+ vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(5000);
+ vi.spyOn(document.body, 'clientWidth', 'get').mockReturnValue(1024);
+ vi.spyOn(document.body, 'clientHeight', 'get').mockReturnValue(600);
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ if (this === anchorRef.current) {
+ return { left: 100, top: 56, right: 140, bottom: 80, width: 40, height: 24 } as DOMRect;
+ }
+ return { left: 0, top: 0, right: 1, bottom: 0, width: 1, height: 1 } as DOMRect;
+ });
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ await renderOpenPanel();
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ vi.restoreAllMocks();
+ });
+
+ it('should keep the panel within the viewport height the body reports', () => {
+ expect(panelMaxHeight()).to.be.greaterThan(0);
+ expect(panelMaxHeight()).to.be.lessThan(600);
+ });
+});
diff --git a/Source/Filter/scrollbars.browser.test.tsx b/Source/Filter/scrollbars.browser.test.tsx
index 8ce760f3..3bad4b96 100644
--- a/Source/Filter/scrollbars.browser.test.tsx
+++ b/Source/Filter/scrollbars.browser.test.tsx
@@ -1,7 +1,10 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+import { useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
+import { CratisComponentsProvider } from '../Common/CratisComponentsProvider';
+import { FilterPanel } from './FilterPanel';
import { composeStory } from '@storybook/react';
import { page, userEvent } from 'vitest/browser';
import { afterEach, beforeEach, expect, it } from 'vitest';
@@ -14,6 +17,23 @@ import '../Dialogs/Dialog.css';
import './FilterPanel.css';
const Story = composeStory(SideDialogWithClassicScrollbars, meta);
+
+const EdgePanelWithClassicScrollbars = () => {
+ const anchorRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ return
+
+
+ setIsOpen(true)}>Edge filter trigger
+ setIsOpen(false)}
+ onFilterToggle={() => undefined} onFilterClear={() => undefined}
+ onRangeChange={() => undefined} onExpandedFilterChange={() => undefined} />
+ ;
+};
let host: HTMLDivElement;
let root: ReturnType;
@@ -46,3 +66,12 @@ it('does not clip a fixed filter panel to the Dialog root when classic scrollbar
expect(bounds.right).toBeGreaterThan(clip.right + 80);
expect(panel.contains(document.elementFromPoint(clip.right + 16, bounds.top + 20))).toBe(true);
});
+
+it('keeps an edge-anchored filter inside the layout viewport on pages with classic scrollbars', async () => {
+ root.render( );
+ await waitFor(() => expect(window.innerWidth - document.documentElement.clientWidth).toBeGreaterThan(0));
+ await userEvent.click(within(host).getByRole('button', { name: 'Edge filter trigger' }));
+ const panel = await within(document.body).findByRole('dialog', { name: 'Edge filter choices' });
+ await waitFor(() => expect(getComputedStyle(panel).opacity).toBe('1'), { timeout: 5000 });
+ expect(panel.getBoundingClientRect().right).toBeLessThanOrEqual(document.documentElement.clientWidth - 16 + 1);
+});
diff --git a/Source/Toolbar/Toolbar.css b/Source/Toolbar/Toolbar.css
index 0d70342c..4014e0f5 100644
--- a/Source/Toolbar/Toolbar.css
+++ b/Source/Toolbar/Toolbar.css
@@ -34,6 +34,12 @@
color: var(--cratis-action-text);
}
+.toolbar-button :where([data-cratis-part='icon']:not([hidden])) {
+ display: flex;
+ align-items: center;
+ line-height: 1;
+}
+
.toolbar-button--draggable {
cursor: grab;
}
@@ -68,7 +74,8 @@
height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
-.toolbar-section--transitioning {
+.toolbar-section--transitioning,
+.toolbar-slot-section--transitioning {
overflow: hidden;
}
@@ -183,18 +190,7 @@
pointer-events: auto;
}
-/*
- * Reveal complete. The clip-path did its job — the wipe cannot run without it,
- * because `clip-path: none` is a discrete value that snaps instead of
- * interpolating. But clip-path also clips every descendant, so leaving a zero
- * inset in place slices off the hover tooltips of the outermost buttons, which
- * paint outside the panel by design.
- *
- * ToolbarFanOutItem therefore adds this class once the reveal transition ends,
- * and takes it off again (restoring an interpolable inset) before the close
- * transition starts — so both wipes keep their exact timing and a settled panel
- * clips nothing.
- */
+/* A settled panel must stop clipping tooltips; restore the inset before the close wipe. */
.toolbar-fanout-panel--settled {
clip-path: none;
}
@@ -288,10 +284,6 @@
/* ToolbarGroup slots use the section transition above. */
-.toolbar-slot-section--transitioning {
- overflow: hidden;
-}
-
@keyframes toolbar-slot-fade-in {
from {
opacity: 0;
diff --git a/Source/Toolbar/Toolbar.stories.tsx b/Source/Toolbar/Toolbar.stories.tsx
index 2e86e3ab..ace8e0de 100644
--- a/Source/Toolbar/Toolbar.stories.tsx
+++ b/Source/Toolbar/Toolbar.stories.tsx
@@ -164,6 +164,21 @@ const ToolGlyph = ({ name }: { name: string }) => {
return ;
};
+/** Native hidden on the icon part must beat the toolbar's icon layout rule. */
+export const WithHiddenIconPart: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const icon = canvas.getByRole('button', { name: 'Hidden icon' }).querySelector('[data-cratis-part="icon"]');
+ await expect(icon).toHaveAttribute('hidden');
+ await expect(getComputedStyle(icon!).display).toBe('none');
+ },
+ render: () => (
+
+ } pt={{ icon: { hidden: true } }} />
+
+ ),
+};
+
const folderIcons: string[] = [
'exclamation-circle',
'eye',
@@ -1228,3 +1243,32 @@ export const LayoutWithGlobalAndEditorRegions: Story = {
return ;
},
};
+
+/** Icon font and SVG glyphs stay vertically centered even when a host sets a taller line height. */
+export const IconsWithInheritedLineHeight: Story = {
+ render: () => (
+
+
+
+
+ } />
+
+
+ } />
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button', { name: 'List icons' }));
+ for (const title of ['Regular font icon', 'Regular SVG icon', 'List font icon', 'List SVG icon']) {
+ const button = canvas.getByRole('button', { name: title });
+ const icon = button.querySelector('i, svg');
+ if (!icon) throw new Error(`Missing icon for ${title}`);
+ const buttonBounds = button.getBoundingClientRect();
+ const iconBounds = icon.getBoundingClientRect();
+ await expect(Math.abs((iconBounds.top + iconBounds.bottom - buttonBounds.top - buttonBounds.bottom) / 2), title).toBeLessThanOrEqual(1);
+ }
+ },
+};
diff --git a/Storybook/scripts/storybook-inventory.json b/Storybook/scripts/storybook-inventory.json
index a2dd3ea4..91e20cae 100644
--- a/Storybook/scripts/storybook-inventory.json
+++ b/Storybook/scripts/storybook-inventory.json
@@ -612,6 +612,7 @@
"toolbar-toolbar--default",
"toolbar-toolbar--drag-and-drop",
"toolbar-toolbar--folder-grid-vs-list",
+ "toolbar-toolbar--icons-with-inherited-line-height",
"toolbar-toolbar--layout-for-editor-modules",
"toolbar-toolbar--layout-with-global-and-editor-regions",
"toolbar-toolbar--layout-with-smooth-editor-transitions",
@@ -625,6 +626,7 @@
"toolbar-toolbar--with-folder-one-button",
"toolbar-toolbar--with-folder-twenty-buttons",
"toolbar-toolbar--with-groups",
+ "toolbar-toolbar--with-hidden-icon-part",
"toolbar-toolbar--with-multiple-slot-contributors",
"toolbar-toolbar--with-react-node-icons",
"toolbar-toolbar--with-separators",