Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f586a6a
Fix stale table results when query arguments change
woksin Sep 28, 2026
5d82077
Keep filter panels inside classic-scrollbar viewports
woksin Sep 28, 2026
8694051
Merge origin/main into fix/release-4191
woksin Sep 28, 2026
f225f92
Center ToolbarButton icons under inherited line height
woksin Sep 28, 2026
f2fce16
Share query argument serializer across Chat and DataTables
woksin Sep 28, 2026
174b24b
Document first-page reset for changed table arguments
woksin Sep 28, 2026
43b67dd
Merge remote-tracking branch 'origin/main' into fix/release-4191
woksin Sep 28, 2026
3129eb0
Record the subscribed query without aliasing this
woksin Sep 28, 2026
eaf44d7
Document query reruns for optional argument changes
woksin Sep 28, 2026
a1533e3
Hide cached pages that do not match a reset subscriber
woksin Sep 28, 2026
11814a4
Test observable page requests instead of mutated query paging
woksin Sep 28, 2026
e2213df
Keep table paginators mounted during argument transitions
woksin Sep 28, 2026
8ca3845
Preserve native hidden on toolbar icon parts
woksin Sep 28, 2026
595de45
Show the delivered page for retained observable subscriptions
woksin Sep 28, 2026
a9c2997
Treat results without a readiness flag as ready in bound tables
woksin Sep 28, 2026
267fa29
Record the hidden icon story in the Storybook inventory
woksin Sep 28, 2026
a4cb810
Withdraw the bound-table argument fix from this patch
woksin Sep 28, 2026
47921af
Measure quirks-mode viewports and place the transform note with overl…
woksin Sep 28, 2026
6bf91df
Cover the quirks-mode viewport measurement
woksin Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Documentation/Filter/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>` with `role="dialog"`, so it is exposed as a named, non-modal dialog, not an `<aside>` 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`.
Expand Down
8 changes: 7 additions & 1 deletion Source/Filter/FilterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) :
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>();
const noOp = () => undefined;
let host: HTMLDivElement;
let root: Root;

const panelMaxHeight = () => parseFloat(document.querySelector<HTMLElement>('.pv-filter-dropdown')!.style.maxHeight);

const renderOpenPanel = async () => {
await act(async () => root.render(<>
<button ref={anchorRef} type='button'>Filters</button>
<FilterPanel isOpen filters={[]} filterValues={{}} rangeValues={{}}
anchorRef={anchorRef} onClose={noOp} onFilterToggle={noOp}
onFilterClear={noOp} onRangeChange={noOp} onExpandedFilterChange={noOp} />
</>));
};

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);
});
});
29 changes: 29 additions & 0 deletions Source/Filter/scrollbars.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +17,23 @@ import '../Dialogs/Dialog.css';
import './FilterPanel.css';

const Story = composeStory(SideDialogWithClassicScrollbars, meta);

const EdgePanelWithClassicScrollbars = () => {
const anchorRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
return <CratisComponentsProvider>
<style>{'html { overflow-y: scroll !important; } html::-webkit-scrollbar { width: 15px; background: #ccc; }'}</style>
<div style={{ minHeight: '200vh' }} />
<button ref={anchorRef} style={{ position: 'fixed', right: 4, top: 120 }}
onClick={() => setIsOpen(true)}>Edge filter trigger</button>
<FilterPanel isOpen={isOpen} filters={[{ key: 'status', label: 'Status',
options: [{ key: 'active', label: 'Active', value: 'active' }] }]}
filterValues={{}} rangeValues={{}} anchorRef={anchorRef}
aria-label='Edge filter choices' onClose={() => setIsOpen(false)}
onFilterToggle={() => undefined} onFilterClear={() => undefined}
onRangeChange={() => undefined} onExpandedFilterChange={() => undefined} />
</CratisComponentsProvider>;
};
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

Expand Down Expand Up @@ -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(<EdgePanelWithClassicScrollbars />);
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);
});
26 changes: 9 additions & 17 deletions Source/Toolbar/Toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions Source/Toolbar/Toolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ const ToolGlyph = ({ name }: { name: string }) => {
return <Icon aria-hidden='true' />;
};

/** 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<HTMLElement>('[data-cratis-part="icon"]');
await expect(icon).toHaveAttribute('hidden');
await expect(getComputedStyle(icon!).display).toBe('none');
},
render: () => (
<Toolbar>
<ToolbarButton title='Hidden icon' icon={<ToolGlyph name='star' />} pt={{ icon: { hidden: true } }} />
</Toolbar>
),
};

const folderIcons: string[] = [
'exclamation-circle',
'eye',
Expand Down Expand Up @@ -1228,3 +1243,32 @@ export const LayoutWithGlobalAndEditorRegions: Story = {
return <LayoutWithGlobalAndEditorRegionsDemo />;
},
};

/** Icon font and SVG glyphs stay vertically centered even when a host sets a taller line height. */
export const IconsWithInheritedLineHeight: Story = {
render: () => (
<div style={{ lineHeight: '24px' }}>
<style>{'.sample-toolbar-glyph::before { content: "◆"; font-style: normal; }'}</style>
<Toolbar orientation='horizontal'>
<ToolbarButton title='Regular font icon' icon='sample-toolbar-glyph' />
<ToolbarButton title='Regular SVG icon' icon={<svg width='20' height='20' viewBox='0 0 20 20' aria-hidden='true'><circle cx='10' cy='10' r='8' /></svg>} />
<ToolbarFolder title='List icons' icon='sample-toolbar-glyph' mode='list'>
<ToolbarButton title='List font icon' icon='sample-toolbar-glyph' />
<ToolbarButton title='List SVG icon' icon={<svg width='20' height='20' viewBox='0 0 20 20' aria-hidden='true'><circle cx='10' cy='10' r='8' /></svg>} />
</ToolbarFolder>
</Toolbar>
</div>
),
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);
}
},
};
2 changes: 2 additions & 0 deletions Storybook/scripts/storybook-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading