diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a232cae..4491052 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,8 @@ jobs:
- run: npm run lint
- run: npm run typecheck
- run: npm test
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:browser
- run: npm run build
- run: npm run pack:check
- run: npm run verify:docs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7973526..318d9e6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,7 +23,7 @@ jobs:
fetch-depth: 0
persist-credentials: true
- - name: Prepare version tag
+ - name: Resolve release version
id: release
shell: bash
run: |
@@ -35,16 +35,16 @@ jobs:
echo "::error::Tag ${GITHUB_REF_NAME} does not match package version ${package_version}."
exit 1
fi
+ echo "publish=true" >> "$GITHUB_OUTPUT"
elif git ls-remote --exit-code --tags origin "refs/tags/${expected_tag}" >/dev/null 2>&1; then
echo "::notice::${expected_tag} already exists; no release is needed."
- echo "publish=false" >> "$GITHUB_OUTPUT"
+ echo "validate=false" >> "$GITHUB_OUTPUT"
exit 0
else
- git tag "${expected_tag}" "${GITHUB_SHA}"
- git push origin "refs/tags/${expected_tag}"
+ echo "publish=false" >> "$GITHUB_OUTPUT"
fi
- echo "publish=true" >> "$GITHUB_OUTPUT"
+ echo "validate=true" >> "$GITHUB_OUTPUT"
echo "tag=${expected_tag}" >> "$GITHUB_OUTPUT"
- name: Verify tag is on main
@@ -61,30 +61,45 @@ jobs:
fi
- uses: actions/setup-node@v7
- if: steps.release.outputs.publish == 'true'
+ if: steps.release.outputs.validate == 'true'
with:
node-version: 24
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Ensure Trusted Publishing-capable npm
- if: steps.release.outputs.publish == 'true'
+ if: steps.release.outputs.validate == 'true'
run: npm install --global "npm@^11.15.0"
- - if: steps.release.outputs.publish == 'true'
+ - if: steps.release.outputs.validate == 'true'
run: npm ci
- - if: steps.release.outputs.publish == 'true'
+ - if: steps.release.outputs.validate == 'true'
run: npm run check
- - if: steps.release.outputs.publish == 'true'
+ - if: steps.release.outputs.validate == 'true'
+ run: npx playwright install --with-deps chromium
+ - if: steps.release.outputs.validate == 'true'
+ run: npm run test:browser
+ - if: steps.release.outputs.validate == 'true'
run: npm run build
- - if: steps.release.outputs.publish == 'true'
+ - if: steps.release.outputs.validate == 'true'
run: npm run pack:check
+ - if: steps.release.outputs.validate == 'true'
+ run: npm run verify:docs
- name: Verify package is publishable
- if: steps.release.outputs.publish == 'true'
+ if: steps.release.outputs.validate == 'true'
run: |
node -e 'const p = require("./package.json"); if (p.private) { console.error("package.json still has private:true; remove it before the npm release."); process.exit(1); }'
+ - name: Create validated release tag
+ if: steps.release.outputs.validate == 'true' && steps.release.outputs.publish != 'true'
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ steps.release.outputs.tag }}
+ run: |
+ git tag "${RELEASE_TAG}" "${GITHUB_SHA}"
+ git push origin "refs/tags/${RELEASE_TAG}"
+
- name: Publish to npm
if: steps.release.outputs.publish == 'true'
run: npm publish --provenance --access public
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0079d9..fb47e86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
## Unreleased
+## 0.6.0 — 2026-09-01
+
+- Add container-aware Window chrome with `chrome="auto | floating | stacked"` and explicit
+ `layout="auto | inline | stacked"` overrides for in-body headers and field action rows.
+- Give the Window frame one geometry owner so title, menu, content, and status rows share aligned
+ edges across active, inactive, collapsed, floating, and stacked states.
+- Give `Window.MenuBar` Base UI menubar semantics, including coordinated keyboard focus and
+ open-menu handoff, and add Menu link, checkbox, radio, group, and submenu parts.
+- Match Select, Combobox, and Autocomplete popups to their anchor by default, expose positioning
+ overrides, and add explicit item text/indicator geometry.
+- Add container-width regression coverage and make documentation grids adapt to their containing
+ section instead of viewport-only breakpoints.
+- Validate release commits before creating their version tags, then publish once from the validated
+ tag workflow.
+
## 0.5.0 — 2026-09-01
- Scope window chrome under `Window` (`Window.Widget`, `Window.MenuBar`, and `Window.StatusBar.*`) and remove the standalone pre-1.0 runtime exports.
diff --git a/README.md b/README.md
index 906ab21..e0996fe 100644
--- a/README.md
+++ b/README.md
@@ -33,13 +33,21 @@ Both forms use the same build graph. CI checks that representative root imports
- Simple controls accept native props; compound controls use `Root` plus named parts and Base UI behavior. Use the [Base UI reference](https://base-ui.com/react/components) for exhaustive primitive props.
- Consumers provide labels and accessible names. Wrap overlay-heavy apps in `Layer.Provider`.
+- `Window` adapts to its own inline size with `chrome="auto"`. Use `chrome="floating"` or
+ `chrome="stacked"` when geometry must remain fixed. The deprecated `responsive` adapter remains
+ available throughout 0.6.x and is scheduled for removal no earlier than 0.7.0.
## Common distinctions
- `Select` is fixed-list; `Combobox` searches listed values; `Autocomplete` keeps free-form text valid.
+- Form popups match their anchor by default. Use `Select popupWidth="content"` or
+ `Combobox.Popup`/`Autocomplete.Popup width="content"` for longer lists; all three accept
+ `positionerProps`.
- `GroupBox` is visual grouping; `Fieldset` adds form semantics. Use `Fieldset.Root variant="plain"` with an accessible name when surrounding chrome supplies the visual boundary.
- `Button` emphasis (`variant="primary"`), default action (`defaultAction`), selection (`aria-pressed`), and keyboard focus are independent states.
-- `Field.ActionRow` bottom-aligns labeled controls such as `Select` with adjacent buttons.
+- `Field.ActionRow` bottom-aligns labeled controls such as `Select` with adjacent buttons. Its
+ `layout="auto"` default follows its own container; `"inline"` and `"stacked"` are explicit
+ overrides.
## Use locally
@@ -66,7 +74,19 @@ npm install /path/to/greyUI
- Feedback and content: Banner, Breadcrumbs, Empty, Loader, Pagination, Progress, Meter, SegmentedMeter, Toast, ScrollArea, Table, Badge, GroupBox, Separator
- Window chrome: Window (`Window.Widget`, `Window.MenuBar`, `Window.StatusBar.*`)
-`Window` supports controlled/uncontrolled collapse and `responsive="stacked"` or `"floating"`. Use `Window.Content` for standard body rails and compose `Window.Header`, `Window.Description`, and `Window.Actions` for responsive in-body headers. `Popover.Popup.positionerProps` accepts Base UI positioning options such as virtual anchors.
+`Window` supports controlled/uncontrolled collapse and container-aware `chrome="auto"` behavior;
+use `"floating"` or `"stacked"` to override its chrome geometry. Use `Window.Content` for standard
+body rails and compose `Window.Header`, `Window.Description`, and `Window.Actions` for in-body
+headers. `Window.Header` also accepts `layout="auto" | "inline" | "stacked"`. The legacy
+`responsive` prop remains as a deprecated adapter. `Popover.Popup.positionerProps` accepts Base UI
+positioning options such as virtual anchors.
+
+`Window.MenuBar` coordinates sibling `Menu.Root` components with menubar semantics, including arrow-key traversal and open-menu handoff. `Menu` includes item, link, checkbox, radio, group, and submenu primitives; `Menu.Popup.positionerProps` exposes Base UI positioning options for edge cases and nested menus.
+
+For 0.5 migrations, replace `responsive="stacked"` with `chrome="auto"` and
+`responsive="floating"` with `chrome="floating"`. If both props are present, `chrome` wins.
+`Window.Header` and `Field.ActionRow` become container-aware by default without markup changes;
+set their `layout` prop only when an explicit inline or stacked arrangement is required.
`Layer.Provider` routes overlays into stable top-level hosts; `Layer.Portal` exposes the same contract for custom content.
diff --git a/docs/0.6.0-release.md b/docs/0.6.0-release.md
new file mode 100644
index 0000000..58b8eea
--- /dev/null
+++ b/docs/0.6.0-release.md
@@ -0,0 +1,89 @@
+# greyUI 0.6.0 release plan
+
+This document is the merge gate for the 0.6.0 release. Keep it in draft until every implementation
+track has landed in the release pull request and its final public API is reflected in the docs.
+
+## Documentation gate
+
+- [ ] Replace 0.5 examples and prose that describe viewport breakpoints with the final
+ container-aware Window and field-layout API.
+- [ ] Show automatic, inline, and stacked layout behavior using the exact prop names and defaults
+ exported by the final build.
+- [ ] Document the coordinated `Window.MenuBar` keyboard model, menu handoff, submenu, checkbox,
+ radio, group, and link-item composition supported by 0.6.0.
+- [ ] Document popup width policy and positioning overrides for Menu, Select, Combobox, and
+ Autocomplete, including the default for form controls.
+- [ ] Keep permanent narrow-container examples for Window headers/actions and docs component grids;
+ confirm the examples do not depend on viewport width.
+- [ ] Update README API conventions and component inventory after the public exports are final.
+- [ ] Replace provisional CHANGELOG wording with the exact shipped behavior if implementation names
+ or compatibility decisions change during review.
+
+## Migration notes
+
+### Window chrome and content layout
+
+- Replace `responsive="stacked"` with `chrome="auto"`. Both choose stacked chrome in narrow
+ containers, but `chrome="auto"` responds to the Window's own inline size instead of the viewport.
+- Replace `responsive="floating"` with `chrome="floating"`.
+- When both props are supplied, `chrome` takes precedence. The deprecated `responsive` adapter will
+ remain for the complete 0.6.x line and will be removed no earlier than 0.7.0.
+- Existing `Window.Header` and `Field.ActionRow` markup needs no change. Both now default to
+ container-aware `layout="auto"`; use `layout="inline"` or `layout="stacked"` to force a layout.
+
+### Menus
+
+Keep each menu in its existing `Menu.Root`; coordinated behavior only requires those roots to be
+siblings inside `Window.MenuBar`:
+
+```tsx
+
+ ...
+ ...
+
+```
+
+This adds Left/Right Arrow traversal, open-menu handoff, disabled-trigger skipping, submenu keyboard
+navigation, and Escape focus restoration. Standalone `Menu.Root` composition remains supported.
+
+### Popup sizing and item parts
+
+- Select, Combobox, and Autocomplete popups match their anchor width by default and clamp to Base
+ UI's available collision area. Use `popupWidth="content"` on Select or `width="content"` on
+ `Combobox.Popup` and `Autocomplete.Popup` to preserve content-sized lists. All expose
+ `positionerProps` for placement overrides.
+- Combobox items should compose `Combobox.ItemText` and optional `Combobox.ItemIndicator`.
+- Autocomplete items must compose `Autocomplete.ItemText`; add `Autocomplete.ItemIndicator` when
+ selection needs a visible marker. The implicit wrapper previously supplied around item children is
+ no longer injected.
+
+## Release gate
+
+- [ ] `package.json` and the root package in `package-lock.json` both report `0.6.0`.
+- [ ] `CHANGELOG.md` has a dated 0.6.0 entry and an empty `Unreleased` heading.
+- [ ] `npm run check` passes.
+- [ ] `npm run build` passes.
+- [ ] `npm run build:docs` and `npm run verify:docs` pass.
+- [ ] `npm run pack:check` passes and the tarball contains the expected declarations, ESM, and CSS.
+- [ ] Rendered docs have been reviewed at 1280, 768, 390, and 320 px, with narrow component
+ containers inside the 1280 px viewport.
+- [ ] Window states, menubar keyboard behavior, and open overlays pass the planned regression matrix.
+- [ ] A prerelease build has been exercised in the WorkbenchOS/bikeOS-style consumer examples.
+- [ ] The release pull request contains no unrelated changes and all required checks are green.
+
+## Tagging and publishing
+
+Do not create `v0.6.0` manually. The release workflow owns the tag:
+
+1. Merge the validated release pull request, including the `0.6.0` package version, to `main`.
+2. The `main` release run resolves `v0.6.0`, installs dependencies, and runs the full package checks.
+3. Only after those checks pass, the workflow creates and pushes `v0.6.0` at the merge commit.
+4. The tag-triggered run verifies that the tag is on `main`, reruns the package checks, and publishes
+ `greyui@0.6.0` with npm Trusted Publishing provenance.
+5. Deploy the docs with `npm run deploy:docs`, unless the connected Cloudflare project has already
+ deployed the `main` commit.
+6. Verify the tag commit, npm version, provenance, and deployed docs before closing the release.
+
+If validation fails before tag creation, fix the release pull request and merge the correction. If a
+tag-triggered publish fails after the validated tag exists, rerun or repair that workflow; never move
+or replace the published version tag.
diff --git a/docs/src/dense-window-example.tsx b/docs/src/dense-window-example.tsx
index b91a9a4..50e76c2 100644
--- a/docs/src/dense-window-example.tsx
+++ b/docs/src/dense-window-example.tsx
@@ -16,7 +16,7 @@ const gears = [
export function DenseWindowExample() {
return (
-
+
@@ -87,7 +87,7 @@ export function DenseWindowExample() {
);
}
-export const denseWindowCode = `
+export const denseWindowCode = `
Configure the transmission.
diff --git a/docs/src/docs.css b/docs/src/docs.css
index fd9ef0e..2b7c839 100644
--- a/docs/src/docs.css
+++ b/docs/src/docs.css
@@ -7,6 +7,7 @@
}
html {
scroll-behavior: smooth;
+ scrollbar-gutter: stable;
}
body {
margin: 0;
@@ -270,6 +271,7 @@ pre {
}
.docs-section {
+ container: docs-section / inline-size;
scroll-margin-top: 18px;
margin: 0 0 38px;
}
@@ -292,10 +294,14 @@ pre {
text-underline-offset: 2px;
}
.docs-principles,
-.docs-grid-2,
.docs-state-grid {
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
+ gap: 14px;
+}
+.docs-grid-2 {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(23rem, 100%), 1fr));
gap: 14px;
}
.docs-principles ul {
@@ -356,6 +362,7 @@ pre {
}
.docs-demo {
+ container: docs-demo / inline-size;
min-width: 0;
border: 1px solid var(--greyui-border-dark);
background: var(--greyui-panel);
@@ -463,7 +470,7 @@ pre {
.docs-dense-field-grid {
display: grid;
min-width: 0;
- grid-template-columns: repeat(6, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(min(6.5rem, 100%), 1fr));
gap: 8px;
}
.docs-dense-window .greyui-table th:not(:first-child),
@@ -473,7 +480,7 @@ pre {
.docs-selection-grid {
display: grid;
min-width: 0;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
gap: 26px;
}
.docs-dialog-actions {
@@ -515,7 +522,7 @@ pre {
.docs-window-pair {
display: grid;
min-width: 0;
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
gap: 28px;
align-items: start;
}
@@ -528,6 +535,40 @@ pre {
padding: 14px;
}
+.docs-window-regression-grid {
+ display: grid;
+ width: 100%;
+ max-width: 100%;
+ min-width: 0;
+ padding-inline-end: 4px;
+ gap: 18px;
+ justify-items: start;
+}
+
+.docs-window-regression-case {
+ width: min(var(--docs-regression-width), 100%);
+ max-width: 100%;
+ min-width: 0;
+ margin: 0;
+}
+
+.docs-window-regression-case > figcaption {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ margin-bottom: 6px;
+ color: var(--greyui-text-muted);
+ font-size: var(--greyui-font-size-small);
+}
+
+.docs-window-regression-case > figcaption strong {
+ color: var(--greyui-text);
+}
+
+.docs-window-regression-edge-trigger {
+ margin-inline-start: auto;
+}
+
.docs-empty-folder {
position: relative;
display: block;
@@ -553,7 +594,7 @@ pre {
.docs-token-grid {
display: grid;
min-width: 0;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
border: 1px solid var(--greyui-border-dark);
background: #fff;
box-shadow: var(--greyui-bevel-inset);
@@ -614,27 +655,7 @@ pre {
}
}
-@media (max-width: 768px) {
- .docs-window-pair {
- grid-template-columns: 1fr;
- }
- .docs-dense-field-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
-}
-
@media (max-width: 620px) {
- .docs-grid-2,
- .docs-principles,
- .docs-selection-grid,
- .docs-state-grid,
- .docs-window-pair,
- .docs-token-grid {
- grid-template-columns: 1fr;
- }
- .docs-token:nth-child(odd) {
- border-right: 0;
- }
.docs-deskbar nav {
grid-template-columns: repeat(2, 1fr);
}
@@ -647,20 +668,12 @@ pre {
.docs-demo-canvas {
padding: 12px;
}
- .docs-grid-2 > *,
- .docs-principles > *,
- .docs-selection-grid > *,
- .docs-state-grid > *,
- .docs-window-pair > *,
- .docs-token-grid > * {
- min-width: 0;
- }
.docs-dialog-actions > .greyui-button {
flex: 1 1 auto;
}
- .docs-dense-field-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
+}
+
+@container docs-demo (max-width: 36rem) {
.docs-dense-fieldset-heading {
align-items: stretch;
flex-direction: column;
@@ -670,7 +683,7 @@ pre {
.docs-high-value-grid {
display: grid;
min-width: 0;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
gap: 8px;
}
@@ -712,8 +725,8 @@ pre {
color: var(--greyui-text-muted);
}
-@media (max-width: 767px) {
- .docs-high-value-grid {
- grid-template-columns: minmax(0, 1fr);
+@container docs-section (max-width: 40rem) {
+ .docs-token:nth-child(odd) {
+ border-right: 0;
}
}
diff --git a/docs/src/high-value-components.tsx b/docs/src/high-value-components.tsx
index 676232b..14d93c2 100644
--- a/docs/src/high-value-components.tsx
+++ b/docs/src/high-value-components.tsx
@@ -35,12 +35,13 @@ export function HighValueComponentDemos() {
-
+
No matching suggestion
{(item: string) => (
- {item}
+ {item}
+
)}
diff --git a/docs/src/main.tsx b/docs/src/main.tsx
index b80438a..cea5183 100644
--- a/docs/src/main.tsx
+++ b/docs/src/main.tsx
@@ -34,6 +34,7 @@ import {
KumoPatternDemos,
} from "./next-components";
import { GREYUI_VERSION } from "./version";
+import { WindowRegressionFixtures } from "./window-regression-fixtures";
import "./docs.css";
const WORKBENCH_URL = "https://workbench.questionable.services/";
@@ -360,6 +361,12 @@ function App() {
Select is fixed-list; Combobox searches and selects
listed values; Autocomplete keeps free-form text valid.
+
+ Form popups match their trigger width and clamp to the available screen space by
+ default. Use popupWidth="content" on Select or{" "}
+ width="content" on a compound popup when long options should widen
+ the list; positionerProps forwards Base UI placement options.
+
@@ -378,9 +385,11 @@ function App() {
@@ -547,7 +556,7 @@ function App() {
AlertDialog for confirmation decisions; context menus should not be
the only path to important actions.
+
+ In Window.MenuBar, Left and Right Arrow move between enabled menu
+ triggers. When a menu is open, that movement hands the open popup to the next
+ trigger; Escape closes it and restores focus. Submenus use the standard forward
+ and back arrow keys, and disabled triggers are skipped. Menu popups remain
+ content-sized but clamp to the available collision area.
+
@@ -687,7 +703,7 @@ function App() {
@@ -712,7 +728,7 @@ function App() {
Active window
Ready
-
+
Tracker
@@ -720,7 +736,22 @@ function App() {
- Compound window API
+
+
+ File
+
+ Open Tracker
+ Close
+
+
+
+ View
+
+ Refresh
+
+
+
+ Inactive compound window
2 items
@@ -732,14 +763,35 @@ function App() {
Window.Header, Window.Description, and
Window.Actions for a shared content rail; keep menu and status bars
outside so their chrome remains full width. Field.ActionRow aligns a
- labeled Select with adjacent buttons at the control edge. The header stacks below
- 520 px, while responsive="stacked" flattens the title chrome at the
- standard responsive breakpoint.
+ labeled Select with adjacent buttons at the control edge. Their default
+ layout="auto" behavior follows each component container rather than
+ the page viewport. Use layout="inline" or
+ layout="stacked" for an explicit override, and choose
+ chrome="floating" or chrome="stacked" when the window
+ should not adapt automatically.
+
+
+ The root owns sizing while its frame owns the outer border, panel, and shadow.
+ Menu, content, and status rows share the same inset edge without compensating
+ negative margins, so switching between active and inactive states never changes
+ the window geometry.
+
+
+ These fixed-width windows keep container behavior visible inside a wide docs
+ viewport. They include expanded and collapsed small windows, explicit Header and
+ ActionRow layouts, stacked chrome, and automatic chrome above the breakpoint. Open
+ the right-aligned Window menu in the 360 px case to check popup collision handling
+ at the inline edge.
+
+
+
+
+
-
+
No matches
{(theme: string) => (
- {theme}
+ {theme}
)}
@@ -375,6 +375,8 @@ function VirtualAnchorDemo() {
export function IntegrationDemos() {
const [date, setDate] = useState("2026-09-05");
+ const [showToolbar, setShowToolbar] = useState(true);
+ const [viewMode, setViewMode] = useState("icons");
const routeSegments = useMemo(
() => [
{ label: "Paved", value: 22, color: "var(--greyui-selection)" },
@@ -385,22 +387,45 @@ export function IntegrationDemos() {
return (
-
-
+
+
File
- Open…
- Save
+
+ Document
+ Open…
+ Save
+
- Close
+ Window documentation
- Edit
+ View
- Undo
- Redo
+
+
+ Toolbar
+
+
+
+
+
+ Icons
+
+
+
+ List
+
+
+
+ Sort by
+
+ Name
+ Date
+
+
@@ -444,7 +469,7 @@ export function IntegrationDemos() {
-
+
Map overlays keep their window geometry.
diff --git a/docs/src/window-regression-fixtures.tsx b/docs/src/window-regression-fixtures.tsx
new file mode 100644
index 0000000..fa31d59
--- /dev/null
+++ b/docs/src/window-regression-fixtures.tsx
@@ -0,0 +1,294 @@
+import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
+import { Button, Field, Menu, Select, Window } from "../../src";
+
+export const WINDOW_REGRESSION_WIDTHS = [280, 320, 360, 520, 640, 760, 820] as const;
+
+const themes = [
+ { value: "beos", label: "BeOS R5" },
+ { value: "workbench", label: "Workbench with a deliberately long option label" },
+];
+
+interface RegressionCaseProps {
+ children: ReactNode;
+ label: string;
+ state: "active" | "inactive" | "collapsed";
+ width: (typeof WINDOW_REGRESSION_WIDTHS)[number];
+}
+
+function RegressionCase({ children, label, state, width }: RegressionCaseProps) {
+ const style: CSSProperties & { "--docs-regression-width": string } = {
+ "--docs-regression-width": `${width}px`,
+ };
+
+ return (
+
+
+ {width}px container
+ {label}
+
+ {children}
+
+ );
+}
+
+interface RegressionContentProps {
+ actionLayout?: "auto" | "inline" | "stacked";
+ compact?: boolean;
+ headerLayout?: "auto" | "inline" | "stacked";
+}
+
+function RegressionContent({
+ actionLayout = "auto",
+ compact = false,
+ headerLayout = "auto",
+}: RegressionContentProps) {
+ return (
+
+
+
+ Resize the containing panel without changing the browser viewport.
+
+
+
+
+ Apply
+
+
+
+
+ );
+}
+
+export function WindowRegressionFixtures() {
+ const suiteRef = useRef(null);
+
+ useEffect(() => {
+ const suite = suiteRef.current;
+ if (suite === null) return;
+
+ const runProbe = () => {
+ const failures = collectWindowRegressionGeometryFailures(suite);
+ suite.dataset.regressionGeometry = failures.length === 0 ? "passed" : "failed";
+ if (failures.length === 0) {
+ delete suite.dataset.regressionFailures;
+ } else {
+ suite.dataset.regressionFailures = failures.join("\n");
+ }
+ };
+ runProbe();
+
+ const ResizeObserverClass = window.ResizeObserver;
+ const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(runProbe) : null;
+ resizeObserver?.observe(suite);
+ for (const frame of suite.querySelectorAll("[data-regression-frame='window']")) {
+ resizeObserver?.observe(frame);
+ }
+
+ const menuLayer = suite.ownerDocument.querySelector("[data-greyui-layer='menu']");
+ const mutationObserver = menuLayer === null ? null : new MutationObserver(runProbe);
+ if (mutationObserver !== null && menuLayer !== null) {
+ mutationObserver.observe(menuLayer, { childList: true, subtree: true });
+ }
+ window.addEventListener("resize", runProbe);
+
+ return () => {
+ resizeObserver?.disconnect();
+ mutationObserver?.disconnect();
+ window.removeEventListener("resize", runProbe);
+ };
+ }, []);
+
+ return (
+
+
+
+
+ Ready
+
+
+
+
+
+
+ Collapsed utility
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ File
+
+ Open a deliberately long recent route from the left edge
+ Close
+
+
+
+ Window
+
+ Move this window to the opposite edge
+ Minimize
+
+
+
+
+
+ Ready
+
+
+
+
+
+
+
+
+ Changes saved
+
+
+
+
+
+
+ Floating layout
+
+
+
+
+
+
+
+ File
+
+ New document
+ Close
+
+
+
+ Edit
+
+ Undo
+ Redo
+
+
+
+
+
+ 2 items
+
+
+
+
+
+
+
+
+ Wide layout
+
+
+
+ );
+}
+
+interface GeometryOptions {
+ documentClientWidth?: number;
+ documentScrollWidth?: number;
+ viewportHeight?: number;
+ viewportWidth?: number;
+}
+
+/**
+ * Browser-level regression probe for the fixture above. Tests can supply document metrics because
+ * jsdom has no layout engine; rendered checks should use the defaults.
+ */
+export function collectWindowRegressionGeometryFailures(
+ root: HTMLElement,
+ options: GeometryOptions = {},
+) {
+ const failures: string[] = [];
+ const documentElement = root.ownerDocument.documentElement;
+ const documentClientWidth = options.documentClientWidth ?? documentElement.clientWidth;
+ const documentScrollWidth = options.documentScrollWidth ?? documentElement.scrollWidth;
+ const viewportWidth = options.viewportWidth ?? documentClientWidth;
+ const viewportHeight = options.viewportHeight ?? window.innerHeight;
+
+ if (documentScrollWidth > documentClientWidth) {
+ failures.push(
+ `document overflow: scroll width ${documentScrollWidth}px exceeds ${documentClientWidth}px`,
+ );
+ }
+
+ for (const fixture of root.querySelectorAll("[data-regression-case='window']")) {
+ const frame = fixture.querySelector("[data-regression-frame='window']");
+ if (frame === null) {
+ failures.push(`missing frame for ${fixture.dataset.regressionWidth ?? "unknown"}px fixture`);
+ continue;
+ }
+
+ const frameRect = frame.getBoundingClientRect();
+ for (const child of frame.querySelectorAll(WINDOW_CONTAINMENT_SELECTOR)) {
+ if (child.hidden || child.closest("[hidden]") !== null) continue;
+ const childRect = child.getBoundingClientRect();
+ if (childRect.left < frameRect.left - 1 || childRect.right > frameRect.right + 1) {
+ failures.push(
+ `${fixture.dataset.regressionWidth ?? "unknown"}px fixture: ${describeNode(child)} escapes the frame`,
+ );
+ }
+ }
+ }
+
+ for (const overlay of root.ownerDocument.querySelectorAll(
+ "[data-regression-overlay]",
+ )) {
+ const overlayRect = overlay.getBoundingClientRect();
+ if (
+ overlayRect.left < 0 ||
+ overlayRect.right > viewportWidth ||
+ overlayRect.top < 0 ||
+ overlayRect.bottom > viewportHeight
+ ) {
+ failures.push(`${describeNode(overlay)} escapes the viewport`);
+ }
+ }
+
+ return failures;
+}
+
+const WINDOW_CONTAINMENT_SELECTOR = [
+ "[data-regression-contained]",
+ ".greyui-window-tab",
+ ".greyui-window-body",
+ ".greyui-menubar",
+ ".greyui-window-content",
+ ".greyui-window-header",
+ ".greyui-window-actions",
+ ".greyui-field-action-row",
+ ".greyui-statusbar",
+].join(",");
+
+function describeNode(node: HTMLElement) {
+ const component = node.getAttribute("data-greyui-component");
+ const firstClass = node.className.split(" ")[0];
+ return component ?? (firstClass || node.tagName);
+}
diff --git a/package-lock.json b/package-lock.json
index da916b2..fa6ed67 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,19 @@
{
"name": "greyui",
- "version": "0.5.0",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "greyui",
- "version": "0.5.0",
+ "version": "0.6.0",
"license": "UNLICENSED",
"dependencies": {
"@base-ui/react": "^1.7.0"
},
"devDependencies": {
"@oxlint/plugins": "^1.80.0",
+ "@playwright/test": "^1.62.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.20.1",
@@ -2174,6 +2175,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -3970,6 +3987,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
diff --git a/package.json b/package.json
index e869a6d..9940c62 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "greyui",
- "version": "0.5.0",
+ "version": "0.6.0",
"description": "BeOS R5 / Haiku-inspired React component library extracted from WorkbenchOS.",
"license": "UNLICENSED",
"repository": {
@@ -40,6 +40,7 @@
"lint:fix": "oxlint --fix --deny-warnings",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
+ "test:browser": "playwright test",
"check": "npm run format:check && npm run lint && npm run typecheck && npm test",
"perf:package": "node scripts/package-audit.mjs",
"pack:check": "node scripts/package-audit.mjs && npm pack --dry-run",
@@ -51,6 +52,7 @@
},
"devDependencies": {
"@oxlint/plugins": "^1.80.0",
+ "@playwright/test": "^1.62.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.20.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..2da48da
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests/browser",
+ fullyParallel: true,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? "github" : "line",
+ use: {
+ baseURL: "http://127.0.0.1:4173",
+ browserName: "chromium",
+ launchOptions: process.env.PLAYWRIGHT_EXECUTABLE_PATH
+ ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH }
+ : {},
+ trace: "retain-on-failure",
+ },
+ webServer: {
+ command: "npm run dev:docs -- --host 127.0.0.1 --port 4173",
+ url: "http://127.0.0.1:4173",
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/scripts/package-audit.mjs b/scripts/package-audit.mjs
index edd3648..de31e88 100644
--- a/scripts/package-audit.mjs
+++ b/scripts/package-audit.mjs
@@ -18,6 +18,7 @@ const packageJson = JSON.parse(readFileSync(resolve(rootDir, "package.json"), "u
const failures = [];
const LIGHT_COMPONENT_GZIP_BUDGET = 2 * 1024;
+const COMPOSITE_COMPONENT_GZIP_BUDGET = 8 * 1024;
const lightComponents = new Set([
"badge",
"button",
@@ -25,14 +26,15 @@ const lightComponents = new Set([
"input",
"table",
"toggle-button",
- "window",
]);
+const compositeComponents = new Set(["window"]);
const consumerCases = [
{ name: "button", exportName: "Button" },
{ name: "input", exportName: "Input" },
{ name: "select", exportName: "Select" },
{ name: "menu", exportName: "Menu" },
{ name: "combobox", exportName: "Combobox" },
+ { name: "window", exportName: "Window" },
];
const allowedRuntimeExternal = /^(?:react(?:\/.*)?|react-dom(?:\/.*)?)$/;
const rootEntryImport = /(?:from\s*|import\s*(?:\(\s*)?)["'](?:\.\.\/)+index\.js["']/;
@@ -223,6 +225,11 @@ if (failures.length === 0) {
`components/${name} exceeds lightweight ${formatBytes(LIGHT_COMPONENT_GZIP_BUDGET)} gzip budget: ${formatBytes(metrics.gzip)}`,
);
}
+ if (compositeComponents.has(name) && metrics.gzip > COMPOSITE_COMPONENT_GZIP_BUDGET) {
+ fail(
+ `components/${name} exceeds composite ${formatBytes(COMPOSITE_COMPONENT_GZIP_BUDGET)} gzip budget: ${formatBytes(metrics.gzip)}`,
+ );
+ }
console.log(
`${`components/${name}`.padEnd(26)} ${String(metrics.files).padStart(5)} ${formatBytes(metrics.raw).padStart(10)} ${formatBytes(metrics.gzip).padStart(10)}`,
);
diff --git a/src/components-v2.css b/src/components-v2.css
index 3e9d3c2..86a8652 100644
--- a/src/components-v2.css
+++ b/src/components-v2.css
@@ -716,9 +716,9 @@
}
.greyui-combobox-popup {
- min-width: var(--anchor-width, 150px);
- width: max-content;
- max-width: min(28rem, calc(100vw - 2rem));
+ width: min(var(--anchor-width, 150px), var(--available-width, calc(100vw - 1rem)));
+ min-width: 0;
+ max-width: var(--available-width, calc(100vw - 1rem));
max-height: min(320px, var(--available-height, 320px));
box-sizing: border-box;
overflow: auto;
@@ -728,6 +728,12 @@
box-shadow: var(--greyui-bevel-outset), var(--greyui-popup-shadow);
}
+.greyui-combobox-popup[data-greyui-popup-width="content"] {
+ width: max-content;
+ min-width: min(var(--anchor-width, 150px), var(--available-width, calc(100vw - 1rem)));
+ max-width: min(28rem, var(--available-width, calc(100vw - 1rem)));
+}
+
.greyui-combobox-list {
padding: 0;
}
@@ -761,8 +767,12 @@
text-align: center;
}
-.greyui-combobox-item > :not(.greyui-combobox-item-indicator) {
+.greyui-combobox-item-text {
grid-column: 2;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.greyui-combobox-empty {
@@ -1426,6 +1436,15 @@
}
.greyui-field-action-row {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ container-type: inline-size;
+ container-name: greyui-field-action-row;
+}
+
+.greyui-field-action-row-layout {
display: flex;
min-width: 0;
flex-wrap: nowrap;
@@ -1433,17 +1452,37 @@
gap: 8px;
}
-@media (max-width: 350px) {
- .greyui-field-action-row {
- flex-wrap: wrap;
- }
+.greyui-field-action-row[data-layout="stacked"] > .greyui-field-action-row-layout {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.greyui-field-action-row[data-layout="stacked"]
+ > .greyui-field-action-row-layout
+ > :where(.greyui-field, .greyui-select-field) {
+ width: 100%;
+ flex-basis: auto;
}
-.greyui-field-action-row > :where(.greyui-field, .greyui-select-field) {
+.greyui-field-action-row-layout > :where(.greyui-field, .greyui-select-field) {
min-width: min(100%, 10rem);
flex: 1 1 12rem;
}
+@container greyui-field-action-row (max-width: 350px) {
+ .greyui-field-action-row[data-layout="auto"] > .greyui-field-action-row-layout {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .greyui-field-action-row[data-layout="auto"]
+ > .greyui-field-action-row-layout
+ > :where(.greyui-field, .greyui-select-field) {
+ width: 100%;
+ flex-basis: auto;
+ }
+}
+
.greyui-accordion {
display: grid;
min-width: 0;
@@ -1530,14 +1569,12 @@
grid-template-columns: minmax(0, 1fr) auto;
}
-.greyui-autocomplete-item-label {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.greyui-autocomplete-item-text {
+ grid-column: 1;
}
.greyui-autocomplete-item-indicator {
+ grid-column: 2;
visibility: hidden;
}
diff --git a/src/components/autocomplete.tsx b/src/components/autocomplete.tsx
index 5e1af0c..c60f837 100644
--- a/src/components/autocomplete.tsx
+++ b/src/components/autocomplete.tsx
@@ -18,6 +18,17 @@ export function AutocompleteRoot({
}
type WithClassName = Omit & { className?: string };
+type PopupWidth = "anchor" | "content";
+type AutocompletePositionerProps = Omit<
+ ComponentProps,
+ "children" | "className"
+>;
+type AutocompletePopupProps = WithClassName> & {
+ /** Controls whether the popup matches its anchor or expands to fit its items. */
+ width?: PopupWidth;
+ /** Forwards Base UI positioning options while preserving greyUI's layer host. */
+ positionerProps?: AutocompletePositionerProps;
+};
export function AutocompleteInputGroup({
className = "",
@@ -80,8 +91,10 @@ export function AutocompleteClear({
export function AutocompletePopup({
className = "",
+ positionerProps,
+ width = "anchor",
...props
-}: WithClassName>) {
+}: AutocompletePopupProps) {
const container = useLayerContainer("menu");
return (
@@ -89,8 +102,11 @@ export function AutocompletePopup({
className="greyui-combobox-positioner greyui-autocomplete-positioner"
align="start"
sideOffset={2}
+ collisionPadding={8}
+ {...positionerProps}
>
@@ -113,22 +129,41 @@ export function AutocompleteList({
export function AutocompleteItem({
className = "",
- children,
...props
}: WithClassName>) {
return (
+ );
+}
+
+export function AutocompleteItemText({
+ className = "",
+ ...props
+}: WithClassName>) {
+ return (
+
+ );
+}
+
+export function AutocompleteItemIndicator({
+ className = "",
+ children = "✓",
+ ...props
+}: WithClassName>) {
+ return (
+
- {children}
-
- ✓
-
-
+ {children}
+
);
}
@@ -196,6 +231,8 @@ export const Autocomplete = {
Popup: AutocompletePopup,
List: AutocompleteList,
Item: AutocompleteItem,
+ ItemText: AutocompleteItemText,
+ ItemIndicator: AutocompleteItemIndicator,
Empty: AutocompleteEmpty,
Group: AutocompleteGroup,
GroupLabel: AutocompleteGroupLabel,
diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx
index a553b89..a6f8afe 100644
--- a/src/components/combobox.tsx
+++ b/src/components/combobox.tsx
@@ -5,6 +5,17 @@ import { useLayerContainer } from "./layer";
export const ComboboxRoot = ComboboxPrimitive.Root;
type WithClassName = Omit & { className?: string };
+type PopupWidth = "anchor" | "content";
+type ComboboxPositionerProps = Omit<
+ ComponentProps,
+ "children" | "className"
+>;
+type ComboboxPopupProps = WithClassName> & {
+ /** Controls whether the popup matches its anchor or expands to fit its items. */
+ width?: PopupWidth;
+ /** Forwards Base UI positioning options while preserving greyUI's layer host. */
+ positionerProps?: ComboboxPositionerProps;
+};
export function ComboboxInputGroup({
className = "",
@@ -64,8 +75,10 @@ export function ComboboxClear({
export function ComboboxPopup({
className = "",
+ positionerProps,
+ width = "anchor",
...props
-}: WithClassName>) {
+}: ComboboxPopupProps) {
const container = useLayerContainer("menu");
return (
@@ -73,8 +86,11 @@ export function ComboboxPopup({
className="greyui-combobox-positioner"
align="start"
sideOffset={2}
+ collisionPadding={8}
+ {...positionerProps}
>
@@ -118,6 +134,13 @@ export function ComboboxItemIndicator({
);
}
+export function ComboboxItemText({
+ className = "",
+ ...props
+}: WithClassName>) {
+ return ;
+}
+
export function ComboboxEmpty({
className = "",
...props
@@ -153,6 +176,7 @@ export const Combobox = {
List: ComboboxList,
Item: ComboboxItem,
ItemIndicator: ComboboxItemIndicator,
+ ItemText: ComboboxItemText,
Empty: ComboboxEmpty,
Separator: ComboboxSeparator,
Group: ComboboxGroup,
diff --git a/src/components/field.tsx b/src/components/field.tsx
index c3577fb..b89789b 100644
--- a/src/components/field.tsx
+++ b/src/components/field.tsx
@@ -56,15 +56,27 @@ export function FieldItem({
return ;
}
-export type FieldActionRowProps = ComponentPropsWithoutRef<"div">;
+export type FieldActionRowLayout = "auto" | "inline" | "stacked";
-export function FieldActionRow({ className = "", ...props }: FieldActionRowProps) {
+export interface FieldActionRowProps extends ComponentPropsWithoutRef<"div"> {
+ layout?: FieldActionRowLayout;
+}
+
+export function FieldActionRow({
+ className = "",
+ layout = "auto",
+ children,
+ ...props
+}: FieldActionRowProps) {
return (
+ >
+ {children}
+
);
}
diff --git a/src/components/menu.tsx b/src/components/menu.tsx
index a4cba23..6968ee0 100644
--- a/src/components/menu.tsx
+++ b/src/components/menu.tsx
@@ -4,34 +4,157 @@ import { useLayerContainer } from "./layer";
export const MenuRoot = MenuPrimitive.Root;
-type TriggerProps = Omit, "className"> & {
+type WithStringClassName = Omit & {
className?: string;
};
-export function MenuTrigger({ className = "", ...props }: TriggerProps) {
+
+export type MenuTriggerProps = WithStringClassName>;
+export function MenuTrigger({ className = "", ...props }: MenuTriggerProps) {
return ;
}
-type ItemProps = Omit, "className"> & {
- className?: string;
-};
-export function MenuItem({ className = "", ...props }: ItemProps) {
+export type MenuItemProps = WithStringClassName>;
+export function MenuItem({ className = "", ...props }: MenuItemProps) {
return ;
}
-export function MenuSeparator(
- props: Omit, "className">,
-) {
- return ;
+export type MenuLinkItemProps = WithStringClassName>;
+export function MenuLinkItem({ className = "", ...props }: MenuLinkItemProps) {
+ return ;
+}
+
+export type MenuCheckboxItemProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuCheckboxItem({ className = "", ...props }: MenuCheckboxItemProps) {
+ return (
+
+ );
+}
+
+export type MenuCheckboxItemIndicatorProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuCheckboxItemIndicator({
+ className = "",
+ ...props
+}: MenuCheckboxItemIndicatorProps) {
+ return (
+
+ );
+}
+
+export type MenuRadioGroupProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuRadioGroup({ className = "", ...props }: MenuRadioGroupProps) {
+ return (
+
+ );
+}
+
+export type MenuRadioItemProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuRadioItem({ className = "", ...props }: MenuRadioItemProps) {
+ return (
+
+ );
+}
+
+export type MenuRadioItemIndicatorProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuRadioItemIndicator({ className = "", ...props }: MenuRadioItemIndicatorProps) {
+ return (
+
+ );
+}
+
+export type MenuGroupProps = WithStringClassName>;
+export function MenuGroup({ className = "", ...props }: MenuGroupProps) {
+ return ;
+}
+
+export type MenuGroupLabelProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuGroupLabel({ className = "", ...props }: MenuGroupLabelProps) {
+ return (
+
+ );
+}
+
+export const MenuSubmenuRoot = MenuPrimitive.SubmenuRoot;
+
+export type MenuSubmenuTriggerProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuSubmenuTrigger({ className = "", ...props }: MenuSubmenuTriggerProps) {
+ return (
+
+ );
}
+export type MenuSeparatorProps = WithStringClassName<
+ ComponentProps
+>;
+export function MenuSeparator({ className = "", ...props }: MenuSeparatorProps) {
+ return (
+
+ );
+}
+
+export type MenuPositionerProps = WithStringClassName<
+ Omit, "children">
+>;
+
+export type MenuPopupProps = WithStringClassName> & {
+ positionerProps?: MenuPositionerProps;
+};
+
+const EMPTY_MENU_POSITIONER_PROPS: MenuPositionerProps = {};
+
export function MenuPopup({
className = "",
+ positionerProps: {
+ className: positionerClassName = "",
+ ...positionerProps
+ } = EMPTY_MENU_POSITIONER_PROPS,
...props
-}: Omit, "className"> & { className?: string }) {
+}: MenuPopupProps) {
const container = useLayerContainer("menu");
return (
-
+
@@ -43,5 +166,15 @@ export const Menu = {
Trigger: MenuTrigger,
Popup: MenuPopup,
Item: MenuItem,
+ LinkItem: MenuLinkItem,
+ CheckboxItem: MenuCheckboxItem,
+ CheckboxItemIndicator: MenuCheckboxItemIndicator,
+ RadioGroup: MenuRadioGroup,
+ RadioItem: MenuRadioItem,
+ RadioItemIndicator: MenuRadioItemIndicator,
+ Group: MenuGroup,
+ GroupLabel: MenuGroupLabel,
+ SubmenuRoot: MenuSubmenuRoot,
+ SubmenuTrigger: MenuSubmenuTrigger,
Separator: MenuSeparator,
};
diff --git a/src/components/select.tsx b/src/components/select.tsx
index 9ceb697..ae60a29 100644
--- a/src/components/select.tsx
+++ b/src/components/select.tsx
@@ -13,11 +13,20 @@ type TriggerProps = Omit<
ComponentProps,
"aria-label" | "aria-labelledby" | "children" | "className"
>;
+type PopupWidth = "anchor" | "content";
+type PositionerProps = Omit<
+ ComponentProps,
+ "children" | "className"
+>;
interface SelectCommonProps {
className?: string;
options: readonly SelectOption[];
placeholder?: ReactNode;
+ /** Controls whether the popup matches its trigger or expands to fit its options. */
+ popupWidth?: PopupWidth;
+ /** Forwards Base UI positioning options while preserving greyUI's layer host. */
+ positionerProps?: PositionerProps;
triggerProps?: TriggerProps;
}
@@ -47,6 +56,8 @@ export function Select({
label,
options,
placeholder = "Choose…",
+ popupWidth = "anchor",
+ positionerProps,
triggerProps,
...rootProps
}: SelectProps) {
@@ -80,8 +91,13 @@ export function Select({
className="greyui-select-positioner"
alignItemWithTrigger={false}
sideOffset={2}
+ collisionPadding={8}
+ {...positionerProps}
>
-
+
{options.map((option) => (
;
export interface WindowRootProps extends ComponentPropsWithoutRef<"div"> {
active?: boolean;
@@ -19,6 +23,11 @@ export interface WindowRootProps extends ComponentPropsWithoutRef<"div"> {
collapsed?: boolean;
defaultCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
+ chrome?: WindowChromeMode;
+ /**
+ * @deprecated Use `chrome`. `responsive="stacked"` maps to `chrome="auto"` and
+ * `responsive="floating"` maps to `chrome="floating"`.
+ */
responsive?: WindowResponsiveMode;
bodyId?: string | undefined;
}
@@ -43,7 +52,8 @@ export function WindowRoot({
collapsed: collapsedProp,
defaultCollapsed = false,
onCollapsedChange,
- responsive = "stacked",
+ chrome: chromeProp,
+ responsive,
bodyId: bodyIdProp,
className = "",
children,
@@ -53,6 +63,8 @@ export function WindowRoot({
const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(defaultCollapsed);
const collapsed = collapsedProp ?? uncontrolledCollapsed;
const bodyId = bodyIdProp ?? generatedBodyId;
+ const chrome = chromeProp ?? (responsive === "floating" ? "floating" : "auto");
+ const legacyResponsive = chrome === "floating" ? "floating" : "stacked";
const setCollapsed = useCallback(
(nextCollapsed: boolean) => {
@@ -72,11 +84,14 @@ export function WindowRoot({
data-greyui-component="window"
data-active={active ? "true" : "false"}
data-collapsed={collapsed ? "true" : "false"}
- data-responsive={responsive}
+ data-chrome={chrome}
+ data-responsive={legacyResponsive}
className={`greyui-window ${className}`.trim()}
{...props}
>
- {children}
+
+ {children}
+
);
@@ -129,14 +144,27 @@ export function WindowContent({
);
}
-export type WindowHeaderProps = ComponentPropsWithoutRef<"div">;
-export function WindowHeader({ className = "", ...props }: WindowHeaderProps) {
+export type WindowHeaderLayout = "auto" | "inline" | "stacked";
+
+export interface WindowHeaderProps extends ComponentPropsWithoutRef<"div"> {
+ layout?: WindowHeaderLayout;
+}
+
+export function WindowHeader({
+ className = "",
+ layout = "auto",
+ children,
+ ...props
+}: WindowHeaderProps) {
return (
+ >
+ {children}
+
);
}
@@ -248,10 +276,15 @@ function WindowWidgetControl({
);
}
-export type WindowMenuBarProps = ComponentPropsWithoutRef<"div">;
+export type WindowMenuBarProps = Omit<
+ ComponentPropsWithoutRef,
+ "className"
+> & {
+ className?: string;
+};
function WindowMenuBar({ className = "", ...props }: WindowMenuBarProps) {
return (
- .greyui-window-header-layout {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 8px;
+}
+
+.greyui-window-header[data-layout="stacked"] .greyui-window-actions {
+ width: 100%;
+ justify-content: flex-start;
+ justify-self: stretch;
+}
+
.greyui-window-description {
max-width: 68ch;
margin: 0;
@@ -1311,7 +1413,7 @@
overflow-y: hidden;
overscroll-behavior-inline: contain;
scrollbar-width: none;
- margin: -1px -1px 0;
+ margin: 0;
padding: 0 0.18rem;
border: 1px solid var(--greyui-border-dark);
background: linear-gradient(180deg, var(--greyui-panel-light) 0%, var(--greyui-panel) 100%);
@@ -1332,19 +1434,23 @@
}
.greyui-menubar .greyui-menu-trigger[data-popup-open],
-.greyui-menubar .greyui-menu-trigger:hover,
+.greyui-menubar .greyui-menu-trigger:hover:not([data-disabled]),
.greyui-menubar .greyui-menu-trigger:focus-visible {
background: var(--greyui-selection);
color: var(--greyui-selection-text);
outline: none;
}
+.greyui-menubar .greyui-menu-trigger[data-disabled] {
+ color: var(--greyui-text-muted);
+}
+
.greyui-statusbar {
display: flex;
align-items: center;
min-width: 0;
min-height: 23px;
- margin: 0 -1px -1px;
+ margin: 0;
padding: 0.2rem 0.45rem;
border: 1px solid var(--greyui-border-dark);
background: var(--greyui-panel);
@@ -1437,8 +1543,82 @@
}
}
-@media (max-width: 768px), (pointer: coarse) and (max-width: 1024px) {
- .greyui-window[data-responsive="stacked"] {
+.greyui-window[data-chrome="stacked"] .greyui-window-frame {
+ width: 100%;
+ padding: 0;
+ border: 1px solid var(--greyui-border-dark);
+ background: var(--greyui-panel);
+ box-shadow: var(--greyui-bevel-outset);
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-menubar {
+ border-top: 0;
+ border-right: 0;
+ border-left: 0;
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-statusbar {
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-window-frame::before {
+ display: none;
+}
+
+.greyui-window[data-chrome="stacked"][data-active="false"] .greyui-window-frame {
+ box-shadow: var(--greyui-bevel-outset);
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-window-tab {
+ position: static;
+ width: 100%;
+ max-width: none;
+ min-height: 0;
+ align-items: stretch;
+ border: 0;
+ border-bottom: 1px solid var(--greyui-tab-active-dark);
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.greyui-window[data-chrome="stacked"][data-collapsed="true"] .greyui-window-tab {
+ border-bottom: 0;
+}
+
+.greyui-window[data-chrome="stacked"][data-active="false"] .greyui-window-tab {
+ border-color: var(--greyui-tab-inactive-dark);
+ box-shadow: none;
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-window-title {
+ order: 0;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: none;
+ padding: 0.45rem 0.65rem;
+ font-size: 0.82rem;
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-window-widget {
+ order: 1;
+ width: 24px;
+ min-height: 0;
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-window-widget[data-kind="close"] {
+ order: 1;
+ border-right: 0;
+ border-left: 1px solid rgb(0 0 0 / 28%);
+}
+
+.greyui-window[data-chrome="stacked"] .greyui-window-widget[data-kind="zoom"] {
+ display: none;
+}
+
+@container greyui-window (max-width: 768px) {
+ .greyui-window[data-chrome="auto"] .greyui-window-frame {
width: 100%;
padding: 0;
border: 1px solid var(--greyui-border-dark);
@@ -1446,15 +1626,27 @@
box-shadow: var(--greyui-bevel-outset);
}
- .greyui-window[data-responsive="stacked"]::before {
+ .greyui-window[data-chrome="auto"] .greyui-menubar {
+ border-top: 0;
+ border-right: 0;
+ border-left: 0;
+ }
+
+ .greyui-window[data-chrome="auto"] .greyui-statusbar {
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+ }
+
+ .greyui-window[data-chrome="auto"] .greyui-window-frame::before {
display: none;
}
- .greyui-window[data-responsive="stacked"][data-active="false"] {
+ .greyui-window[data-chrome="auto"][data-active="false"] .greyui-window-frame {
box-shadow: var(--greyui-bevel-outset);
}
- .greyui-window[data-responsive="stacked"] .greyui-window-tab {
+ .greyui-window[data-chrome="auto"] .greyui-window-tab {
position: static;
width: 100%;
max-width: none;
@@ -1466,12 +1658,16 @@
box-shadow: none;
}
- .greyui-window[data-responsive="stacked"][data-active="false"] .greyui-window-tab {
+ .greyui-window[data-chrome="auto"][data-collapsed="true"] .greyui-window-tab {
+ border-bottom: 0;
+ }
+
+ .greyui-window[data-chrome="auto"][data-active="false"] .greyui-window-tab {
border-color: var(--greyui-tab-inactive-dark);
box-shadow: none;
}
- .greyui-window[data-responsive="stacked"] .greyui-window-title {
+ .greyui-window[data-chrome="auto"] .greyui-window-title {
order: 0;
flex: 1 1 auto;
min-width: 0;
@@ -1480,41 +1676,24 @@
font-size: 0.82rem;
}
- .greyui-window[data-responsive="stacked"] .greyui-window-widget {
+ .greyui-window[data-chrome="auto"] .greyui-window-widget {
order: 1;
width: 24px;
min-height: 0;
}
- .greyui-window[data-responsive="stacked"] .greyui-window-widget[data-kind="close"] {
+ .greyui-window[data-chrome="auto"] .greyui-window-widget[data-kind="close"] {
order: 1;
border-right: 0;
border-left: 1px solid rgb(0 0 0 / 28%);
}
- .greyui-window[data-responsive="stacked"] .greyui-window-widget[data-kind="zoom"] {
+ .greyui-window[data-chrome="auto"] .greyui-window-widget[data-kind="zoom"] {
display: none;
}
+}
- .greyui-window[data-responsive="stacked"] .greyui-window-body {
- border: 0;
- box-shadow: none;
- }
-
- .greyui-window[data-responsive="stacked"] .greyui-window-body > .greyui-menubar {
- margin: 0;
- border-top: 0;
- border-right: 0;
- border-left: 0;
- }
-
- .greyui-window[data-responsive="stacked"] .greyui-window-body > .greyui-statusbar {
- margin: 0;
- border-right: 0;
- border-bottom: 0;
- border-left: 0;
- }
-
+@media (max-width: 768px), (pointer: coarse) and (max-width: 1024px) {
.greyui-dialog {
width: min(100%, 440px);
}
@@ -1560,13 +1739,13 @@
}
}
-@media (max-width: 520px) {
- .greyui-window-header {
+@container greyui-window-header (max-width: 520px) {
+ .greyui-window-header[data-layout="auto"] > .greyui-window-header-layout {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
- .greyui-window-actions {
+ .greyui-window-header[data-layout="auto"] .greyui-window-actions {
width: 100%;
justify-content: flex-start;
justify-self: stretch;
diff --git a/tests/browser/docs-geometry.spec.ts b/tests/browser/docs-geometry.spec.ts
new file mode 100644
index 0000000..b85d194
--- /dev/null
+++ b/tests/browser/docs-geometry.spec.ts
@@ -0,0 +1,233 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+const viewports = [
+ { width: 1280, height: 900 },
+ { width: 768, height: 900 },
+ { width: 390, height: 844 },
+ { width: 320, height: 720 },
+] as const;
+
+for (const viewport of viewports) {
+ test(`window fixtures stay contained at ${viewport.width}px`, async ({ page }) => {
+ await page.setViewportSize(viewport);
+ await page.goto("/");
+
+ const suite = page.locator("[data-regression-suite='window-containers']");
+ await suite.scrollIntoViewIfNeeded();
+ await expect(suite).toBeVisible();
+
+ const failures = await page.evaluate(() => {
+ const messages: string[] = [];
+ if (document.documentElement.scrollWidth > document.documentElement.clientWidth) {
+ const rightmost = Array.from(document.querySelectorAll
("body *"))
+ .map((element) => ({
+ element,
+ right: element.getBoundingClientRect().right,
+ }))
+ .filter(({ right }) => right > document.documentElement.clientWidth)
+ .sort((first, second) => second.right - first.right)
+ .slice(0, 3)
+ .map(({ element, right }) => `${element.tagName}.${element.className} at ${right}px`);
+ messages.push(
+ `document overflows horizontally (${document.documentElement.scrollWidth}px > ${document.documentElement.clientWidth}px): ${rightmost.join(", ")}`,
+ );
+ }
+
+ for (const fixture of document.querySelectorAll(
+ "[data-regression-case='window']",
+ )) {
+ const frame = fixture.querySelector("[data-regression-frame='window']");
+ if (frame === null) continue;
+ const frameRect = frame.getBoundingClientRect();
+ for (const child of frame.querySelectorAll(
+ "[data-regression-contained], .greyui-window-tab, .greyui-window-body, .greyui-menubar, .greyui-statusbar",
+ )) {
+ if (child.hidden || child.closest("[hidden]") !== null) continue;
+ const childRect = child.getBoundingClientRect();
+ if (childRect.left < frameRect.left - 1 || childRect.right > frameRect.right + 1) {
+ messages.push(`${fixture.dataset.regressionWidth}px fixture escapes its frame`);
+ }
+ }
+ }
+ return messages;
+ });
+
+ expect(failures).toEqual([]);
+
+ if (viewport.width === 1280) {
+ await expect(suite.locator("[data-regression-width='280'] .greyui-window-tab")).toHaveCSS(
+ "position",
+ "static",
+ );
+ await expect(suite.locator("[data-regression-width='320'] .greyui-window-tab")).toHaveCSS(
+ "border-bottom-width",
+ "0px",
+ );
+ await expect(suite.locator("[data-regression-width='640'] .greyui-window-tab")).toHaveCSS(
+ "position",
+ "absolute",
+ );
+ await expect(suite.locator("[data-regression-width='760'] .greyui-window-tab")).toHaveCSS(
+ "position",
+ "static",
+ );
+ await expect(suite.locator("[data-regression-width='820'] .greyui-window-tab")).toHaveCSS(
+ "position",
+ "absolute",
+ );
+ }
+ });
+}
+
+test("edge menu and form popups remain inside the viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/");
+
+ const suite = page.locator("[data-regression-suite='window-containers']");
+ await suite.scrollIntoViewIfNeeded();
+ await suite.getByRole("menuitem", { name: "Window" }).click();
+ await expectViewportContainment(page, page.locator(".greyui-menu-popup:visible"));
+ await page.keyboard.press("Escape");
+
+ const select = suite.getByRole("combobox").first();
+ await select.click();
+ const selectPopup = page.locator(".greyui-select-popup:visible");
+ await expectViewportContainment(page, selectPopup);
+ const [selectBox, selectPopupBox] = await Promise.all([
+ select.boundingBox(),
+ selectPopup.boundingBox(),
+ ]);
+ expect(selectBox).not.toBeNull();
+ expect(selectPopupBox).not.toBeNull();
+ expect(Math.abs(selectBox!.width - selectPopupBox!.width)).toBeLessThanOrEqual(1);
+ await page.keyboard.press("Escape");
+
+ const combobox = page.getByPlaceholder("Find a theme…");
+ await combobox.scrollIntoViewIfNeeded();
+ await combobox.click();
+ await expectViewportContainment(page, page.locator(".greyui-combobox-popup:visible"));
+ await page.keyboard.press("Escape");
+
+ const autocomplete = page.getByPlaceholder("Type any value…");
+ await autocomplete.scrollIntoViewIfNeeded();
+ await autocomplete.click();
+ await expectViewportContainment(page, page.locator(".greyui-autocomplete-popup:visible"));
+});
+
+test("menus clamp at desktop left and short-viewport bottom edges", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto("/");
+
+ const fixture = page.locator("[data-regression-width='360']");
+ const leftTrigger = fixture.getByRole("menuitem", { name: "File" });
+ await leftTrigger.evaluate((element) => element.scrollIntoView({ block: "start" }));
+ await leftTrigger.click();
+ const leftPopup = page.locator(".greyui-menu-popup:visible");
+ await expectViewportContainment(page, leftPopup);
+ const [leftTriggerBox, leftPopupBox] = await Promise.all([
+ leftTrigger.boundingBox(),
+ leftPopup.boundingBox(),
+ ]);
+ expect(leftTriggerBox).not.toBeNull();
+ expect(leftPopupBox).not.toBeNull();
+ expect(leftPopupBox!.width).toBeGreaterThan(leftTriggerBox!.x + leftTriggerBox!.width);
+ expect(leftPopupBox!.x).toBeGreaterThanOrEqual(0);
+ await page.keyboard.press("Escape");
+
+ await page.setViewportSize({ width: 390, height: 320 });
+ const bottomTrigger = fixture.getByRole("menuitem", { name: "Window" });
+ await bottomTrigger.evaluate((element) => element.scrollIntoView({ block: "end" }));
+ await bottomTrigger.click();
+ await expectViewportContainment(page, page.locator(".greyui-menu-popup:visible"));
+});
+
+test("anchored overlays do not shift the docs page", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto("/");
+
+ await expectNoPageShift(
+ page,
+ page.getByText("Right-click this Tracker row", { exact: true }),
+ async (trigger) => trigger.click({ button: "right" }),
+ page.locator(".greyui-context-menu-popup:visible"),
+ );
+ await expectNoPageShift(
+ page,
+ page.locator(".greyui-menu-trigger", { hasText: "Actions" }),
+ async (trigger) => trigger.click(),
+ page.locator(".greyui-menu-popup:visible"),
+ );
+ await expectNoPageShift(
+ page,
+ page.locator("[data-regression-suite='window-containers']").getByRole("combobox").first(),
+ async (trigger) => trigger.click(),
+ page.locator(".greyui-select-popup:visible"),
+ );
+ await expectNoPageShift(
+ page,
+ page.getByPlaceholder("Find a theme…"),
+ async (trigger) => trigger.click(),
+ page.locator(".greyui-combobox-popup:visible"),
+ );
+ await expectNoPageShift(
+ page,
+ page.getByPlaceholder("Type any value…"),
+ async (trigger) => trigger.click(),
+ page.locator(".greyui-autocomplete-popup:visible"),
+ );
+});
+
+interface PageGeometry {
+ clientWidth: number;
+ deskbarLeft: number;
+ mainLeft: number;
+ mainWidth: number;
+ scrollX: number;
+ scrollY: number;
+}
+
+async function expectNoPageShift(
+ page: Page,
+ trigger: Locator,
+ open: (trigger: Locator) => Promise,
+ popup: Locator,
+) {
+ await trigger.scrollIntoViewIfNeeded();
+ const before = await readPageGeometry(page);
+ await open(trigger);
+ await expect(popup).toBeVisible();
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve(null))));
+ expect(await readPageGeometry(page)).toEqual(before);
+ await page.keyboard.press("Escape");
+ await expect(popup).not.toBeVisible();
+}
+
+async function readPageGeometry(page: Page): Promise {
+ return page.evaluate(() => {
+ const main = document.querySelector(".docs-main")?.getBoundingClientRect();
+ const deskbar = document.querySelector(".docs-deskbar")?.getBoundingClientRect();
+ if (main === undefined || deskbar === undefined) {
+ throw new Error("Expected docs shell geometry");
+ }
+ return {
+ clientWidth: document.documentElement.clientWidth,
+ deskbarLeft: deskbar.left,
+ mainLeft: main.left,
+ mainWidth: main.width,
+ scrollX: window.scrollX,
+ scrollY: window.scrollY,
+ };
+ });
+}
+
+async function expectViewportContainment(page: Page, popup: Locator) {
+ await expect(popup).toBeVisible();
+ const box = await popup.boundingBox();
+ const viewport = page.viewportSize();
+ expect(box).not.toBeNull();
+ expect(viewport).not.toBeNull();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.y).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width);
+ expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height);
+}
diff --git a/tests/components.test.tsx b/tests/components.test.tsx
index 82983b9..de73e29 100644
--- a/tests/components.test.tsx
+++ b/tests/components.test.tsx
@@ -3,13 +3,13 @@ import { afterEach, describe, expect, it } from "vitest";
import { useState } from "react";
import {
AlertDialog,
+ Autocomplete,
Banner,
Breadcrumbs,
Button,
ButtonGroup,
- Collapsible,
+ Checkbox,
Combobox,
- ContextMenu,
createVirtualAnchor,
DatePicker,
Dialog,
@@ -20,18 +20,14 @@ import {
IconButton,
Layer,
Loader,
- Meter,
+ Menu,
NumberField,
Pagination,
- Progress,
ScrollArea,
SegmentedMeter,
Select,
- Separator,
- Slider,
Tabs,
Toast,
- Toolbar,
Window,
} from "../src";
import * as GreyUI from "../src";
@@ -53,68 +49,70 @@ describe("window compound API", () => {
expect(Window.StatusBar).toHaveProperty("Separator");
expect(Window.StatusBar).toHaveProperty("Light");
});
-});
-describe("native control safety", () => {
- it("keeps Button non-submitting by default", () => {
+ it("routes styled menu composition through its configured layer and positioner", () => {
render(
- ,
+
+
+ View
+
+
+ Panels
+
+
+ Toolbar
+
+
+ Window help
+
+
+ ,
);
- expect(screen.getByRole("button", { name: "Apply" }).getAttribute("type")).toBe("button");
- });
-
- it("marks a default action without leaking styling internals into call sites", () => {
- render(Apply );
-
- expect(screen.getByRole("button", { name: "Apply" }).getAttribute("data-default")).toBe("true");
- });
-
- it("keeps primary, default, and selected button states independently addressable", () => {
- render(
-
- Apply view
- ,
+ const popup = document.querySelector(".greyui-menu-popup");
+ expect(popup?.closest("[data-greyui-layer='menu']")).not.toBeNull();
+ expect(
+ document
+ .querySelector(".greyui-menu-positioner.custom-positioner")
+ ?.getAttribute("data-side"),
+ ).toBe("top");
+ expect(screen.getByRole("menuitem", { name: "Window help" }).classList).toContain(
+ "greyui-menu-item",
);
-
- const button = screen.getByRole("button", { name: "Apply view" });
- expect(button.getAttribute("data-variant")).toBe("primary");
- expect(button.getAttribute("data-default")).toBe("true");
- expect(button.getAttribute("aria-pressed")).toBe("true");
- });
-
- it("keeps Window.Widget non-submitting and labeled", () => {
- render( );
-
- const widget = screen.getByRole("button", { name: "Close" });
- expect(widget.getAttribute("type")).toBe("button");
- expect(widget.getAttribute("aria-label")).toBe("Close");
- expect(widget.getAttribute("data-kind")).toBe("close");
+ expect(
+ document.querySelector(".greyui-menu-checkbox-indicator")?.getAttribute("aria-hidden"),
+ ).toBe("true");
});
+});
- it("keeps InputGroup action buttons non-submitting", () => {
+describe("native control safety", () => {
+ it("applies non-submitting defaults and accessible labels across button wrappers", () => {
render(
- ,
);
- expect(screen.getByRole("button", { name: "Browse" }).getAttribute("type")).toBe("button");
- });
+ const button = screen.getByRole("button", { name: "Apply" });
+ expect(button.getAttribute("type")).toBe("button");
+ expect(button.getAttribute("data-variant")).toBe("primary");
+ expect(button.getAttribute("data-default")).toBe("true");
- it("labels compact icon buttons and groups them by orientation", () => {
- render(
-
- +
- −
- ,
- );
+ const widget = screen.getByRole("button", { name: "Close" });
+ expect(widget.getAttribute("type")).toBe("button");
+ expect(widget.getAttribute("data-kind")).toBe("close");
+ expect(screen.getByRole("button", { name: "Browse" }).getAttribute("type")).toBe("button");
expect(screen.getByRole("group", { name: "Map zoom" }).getAttribute("data-orientation")).toBe(
"vertical",
);
@@ -136,58 +134,79 @@ describe("native control safety", () => {
});
describe("accessibility contracts", () => {
- it("associates Select's visible label with its combobox", () => {
+ it("provides visible and aria-only labels for Select", () => {
render(
- ,
- );
-
- expect(screen.getByRole("combobox", { name: "Theme" })).not.toBeNull();
- });
-
- it("supports an aria-only Select label", () => {
- render(
- ,
+ <>
+
+
+ >,
);
expect(screen.getByRole("combobox", { name: "Theme" })).not.toBeNull();
+ expect(screen.getByRole("combobox", { name: "Workspace" })).not.toBeNull();
});
- it("renders one explicit Select arrow without Base UI fallback text", () => {
+ it("renders greyUI Select chrome and forwards popup layout options", () => {
const { container } = render(
,
);
const icon = container.querySelector(".greyui-select-icon");
expect(icon?.textContent).toBe("");
expect(icon?.querySelectorAll(".greyui-select-arrow")).toHaveLength(1);
+ const popup = document.querySelector(".greyui-select-popup");
+ expect(popup?.getAttribute("data-greyui-popup-width")).toBe("content");
+ expect(document.querySelector("#select-positioner")?.getAttribute("data-side")).toBe("top");
+ expect(
+ screen
+ .getByRole("option", { name: /deliberately long/ })
+ .querySelector(".greyui-select-item-text"),
+ ).not.toBeNull();
});
- it("associates Field labels with Field controls", () => {
+ it("applies Autocomplete defaults and popup composition owned by greyUI", () => {
render(
-
- Filename
-
- Output filename
- ,
+
+
+
+
+
+
+
+ {(item: string) => (
+
+ {item}
+
+
+ )}
+
+
+ ,
);
- expect(screen.getByRole("textbox", { name: "Filename" })).not.toBeNull();
- expect(screen.getByText("Output filename")).not.toBeNull();
+ const option = screen.getByRole("option", { name: /deliberately long/ });
+ expect(option.querySelector(".greyui-autocomplete-item-text")).not.toBeNull();
+ expect(option.querySelector(".greyui-autocomplete-item-indicator")).not.toBeNull();
+ expect(
+ document.querySelector(".greyui-autocomplete-popup")?.getAttribute("data-greyui-popup-width"),
+ ).toBe("content");
+ expect(document.querySelector("#auto-positioner")?.getAttribute("data-side")).toBe("top");
+ expect(document.querySelector(".greyui-autocomplete-trigger")?.getAttribute("aria-label")).toBe(
+ "Show suggestions",
+ );
});
it("supports borderless fieldset semantics without a visible legend", () => {
@@ -202,6 +221,20 @@ describe("accessibility contracts", () => {
expect(fieldset.querySelector("legend")).toBeNull();
});
+ it("propagates disabled Fieldset state to greyUI Checkbox controls", () => {
+ render(
+
+ Managed settings
+
+ ,
+ );
+
+ const checkbox = screen.getByRole("checkbox", { name: "Enforce policy" });
+ expect(checkbox.hasAttribute("data-disabled")).toBe(true);
+ fireEvent.click(checkbox);
+ expect(checkbox.getAttribute("aria-checked")).toBe("false");
+ });
+
it("provides a stable action row for labeled controls and adjacent buttons", () => {
render(
@@ -215,27 +248,11 @@ describe("accessibility contracts", () => {
const row = screen.getByText("Preset").closest('[data-greyui-component="field-action-row"]');
expect(row?.classList.contains("greyui-field-action-row")).toBe(true);
+ expect(row?.getAttribute("data-layout")).toBe("auto");
+ expect(row?.querySelector(".greyui-field-action-row-layout")?.children).toHaveLength(2);
expect(screen.getByRole("button", { name: "Apply" })).not.toBeNull();
});
- it("keeps NumberField editable and steps values", () => {
- render(
-
-
-
-
-
-
- ,
- );
-
- const input = screen.getByRole("textbox", { name: "Count" });
- expect(input.getAttribute("aria-roledescription")).toBe("Number field");
- expect(input.value).toBe("10");
- fireEvent.click(screen.getByRole("button", { name: "Increase" }));
- expect(input.value).toBe("11");
- });
-
it("renders matched NumberField step icons without font glyphs", () => {
const { container } = render(
@@ -261,46 +278,6 @@ describe("accessibility contracts", () => {
expect(container.querySelectorAll(".greyui-number-field-step-icon")).toHaveLength(2);
});
- it("labels Slider thumbs through Slider.Label", () => {
- render(
-
- Volume
-
-
-
-
-
-
- ,
- );
-
- expect(screen.getByRole("slider", { name: "Volume" }).getAttribute("aria-valuenow")).toBe("50");
- });
-
- it("exposes progress and meter semantics", () => {
- render(
- <>
-
- Write progress
-
-
-
-
-
- Storage
-
-
-
-
- >,
- );
-
- expect(
- screen.getByRole("progressbar", { name: "Write progress" }).getAttribute("aria-valuenow"),
- ).toBe("64");
- expect(screen.getByRole("meter", { name: "Storage" }).getAttribute("aria-valuenow")).toBe("72");
- });
-
it("exposes segmented meter totals and segment detail", () => {
render(
{
expect(meter.getAttribute("aria-valuetext")).toBe("Paved 12, Gravel 6");
});
- it("marks separators semantically", () => {
- render( );
-
- expect(screen.getByRole("separator").getAttribute("data-orientation")).toBe("vertical");
- });
-
- it("keeps Toolbar as one keyboard-navigation group", () => {
- render(
-
- New
- Open
- ,
- );
-
- expect(screen.getByRole("toolbar", { name: "Document toolbar" })).not.toBeNull();
- expect(screen.getAllByRole("button")).toHaveLength(2);
- });
-
- it("opens ContextMenu from a context-menu interaction", () => {
+ it("applies Combobox controls and forwards popup layout options", () => {
render(
-
- Tracker row
-
- Open
- Rename
-
- ,
- );
-
- fireEvent.contextMenu(screen.getByText("Tracker row"));
- expect(screen.getByRole("menuitem", { name: "Open" })).not.toBeNull();
- expect(screen.getByRole("menuitem", { name: "Rename" })).not.toBeNull();
- });
-
- it("filters and selects Combobox items", () => {
- render(
-
+
-
+
{(theme: string) => (
- {theme}
+ {theme}
+
)}
@@ -373,28 +321,18 @@ describe("accessibility contracts", () => {
,
);
- const input = screen.getByRole("combobox", { name: "Theme" });
- fireEvent.click(screen.getByRole("button", { name: "Show options" }));
- fireEvent.change(input, { target: { value: "Hai" } });
-
- expect(screen.queryByRole("option", { name: "BeOS R5" })).toBeNull();
- fireEvent.click(screen.getByRole("option", { name: "Haiku" }));
- expect(input.value).toBe("Haiku");
-
- fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
- expect(input.value).toBe("");
- });
-
- it("toggles Collapsible panels through the Base UI trigger", () => {
- render(
-
- Advanced
- Advanced options
- ,
+ const popup = document.querySelector(".greyui-combobox-popup");
+ expect(popup?.getAttribute("data-greyui-popup-width")).toBe("content");
+ expect(document.querySelector("#combo-positioner")?.getAttribute("data-side")).toBe("top");
+ const option = screen.getByRole("option", { name: /deliberately long/ });
+ expect(option.querySelector(".greyui-combobox-item-text")).not.toBeNull();
+ expect(option.querySelector(".greyui-combobox-item-indicator")).not.toBeNull();
+ expect(document.querySelector(".greyui-combobox-trigger")?.getAttribute("aria-label")).toBe(
+ "Show options",
+ );
+ expect(document.querySelector(".greyui-combobox-clear")?.getAttribute("aria-label")).toBe(
+ "Clear selection",
);
-
- fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
- expect(screen.getByText("Advanced options")).not.toBeNull();
});
it("creates toast notifications through the shared manager", () => {
@@ -422,25 +360,39 @@ describe("accessibility contracts", () => {
expect(screen.getByText("ROM saved")).not.toBeNull();
});
- it("switches tab panels without custom keyboard code", () => {
- render(
+ it("maps the Tabs shorthand API into styled tabs and panels", () => {
+ const { container } = render(
General content },
- { value: "advanced", label: "Advanced", content: Advanced content
},
+ {
+ value: "advanced",
+ label: "Advanced",
+ content: Advanced content
,
+ disabled: true,
+ },
]}
/>,
);
- fireEvent.click(screen.getByRole("tab", { name: "Advanced" }));
- expect(screen.getByText("Advanced content")).not.toBeNull();
+ expect(screen.getAllByRole("tab")).toHaveLength(2);
+ expect(screen.getByRole("tab", { name: "General" }).classList).toContain("greyui-tab");
+ expect(screen.getByRole("tab", { name: "Advanced" }).hasAttribute("data-disabled")).toBe(true);
+ expect(container.querySelector(".greyui-tabs")?.getAttribute("data-greyui-component")).toBe(
+ "tabs",
+ );
+ expect(container.querySelector(".greyui-tab-panel")).not.toBeNull();
+ expect(screen.getByText("General content")).not.toBeNull();
});
it("does not reuse the native HTML title attribute for window chrome", () => {
- render(Preferences}>Content );
+ const { container } = render(Preferences}>Content );
expect(screen.getByText("Preferences")).not.toBeNull();
+ expect(container.querySelector('[data-greyui-component="window"]')?.hasAttribute("title")).toBe(
+ false,
+ );
});
it("manages optional collapse state and keeps the body addressable", () => {
@@ -459,6 +411,7 @@ describe("accessibility contracts", () => {
const body = screen.getByLabelText("Route details");
const toggle = screen.getByRole("button", { name: "Minimize window" });
expect(body.closest("section")?.getAttribute("data-responsive")).toBe("floating");
+ expect(body.closest("section")?.getAttribute("data-chrome")).toBe("floating");
expect(toggle.getAttribute("aria-controls")).toBe("route-body");
fireEvent.click(toggle);
expect(body.hasAttribute("hidden")).toBe(true);
@@ -479,19 +432,51 @@ describe("accessibility contracts", () => {
);
const body = screen.getByLabelText("Tracker body");
+ const root = body.closest('[data-greyui-component="window"]');
+ const frame = body.closest('[data-greyui-component="window-frame"]');
+ expect(frame?.parentElement).toBe(root);
+ expect(frame?.querySelector(":scope > .greyui-window-tab")).not.toBeNull();
+ expect(frame?.querySelector(":scope > .greyui-window-body")).toBe(body);
expect(body.getAttribute("id")).toBe("compound-body");
fireEvent.click(screen.getByRole("button", { name: "Minimize window" }));
expect(body.hasAttribute("hidden")).toBe(true);
});
+ it("uses the same frame structure for shorthand and inactive windows", () => {
+ const { container, rerender } = render(Content );
+
+ const activeRoot = container.querySelector('[data-greyui-component="window"]');
+ const activeFrame = activeRoot?.querySelector(
+ ':scope > [data-greyui-component="window-frame"]',
+ );
+ expect(activeFrame?.children).toHaveLength(2);
+ expect(activeFrame?.querySelector(":scope > .greyui-window-tab")).not.toBeNull();
+ expect(activeFrame?.querySelector(":scope > .greyui-window-body")).not.toBeNull();
+
+ rerender(
+
+ Content
+ ,
+ );
+
+ const inactiveRoot = container.querySelector('[data-greyui-component="window"]');
+ const inactiveFrame = inactiveRoot?.querySelector(
+ ':scope > [data-greyui-component="window-frame"]',
+ );
+ expect(inactiveRoot?.getAttribute("data-active")).toBe("false");
+ expect(inactiveFrame?.children).toHaveLength(2);
+ });
+
it("provides stable content-rail, header, description, and action slots", () => {
render(
-
+
Configure the transmission.
- Copy link
+
+ Copy link
+
@@ -503,8 +488,39 @@ describe("accessibility contracts", () => {
.closest('[data-greyui-component="window-content"]');
expect(content?.getAttribute("data-density")).toBe("compact");
expect(content?.querySelector(".greyui-window-header")).not.toBeNull();
+ expect(content?.querySelector(".greyui-window-header")?.getAttribute("data-layout")).toBe(
+ "stacked",
+ );
+ expect(content?.querySelector(".greyui-window-header-layout")).not.toBeNull();
expect(content?.querySelector(".greyui-window-description")).not.toBeNull();
expect(content?.querySelector(".greyui-window-actions")).not.toBeNull();
+ expect(content?.querySelector(".greyui-field-action-row")?.getAttribute("data-layout")).toBe(
+ "inline",
+ );
+ expect(content?.querySelector(".greyui-field-action-row-layout")).not.toBeNull();
+ });
+
+ it("maps legacy responsive window modes to the container-aware chrome contract", () => {
+ const { rerender } = render(Content );
+ const root = screen.getByText("Legacy").closest('[data-greyui-component="window"]');
+
+ expect(root?.getAttribute("data-chrome")).toBe("auto");
+ expect(root?.getAttribute("data-responsive")).toBe("stacked");
+
+ rerender(
+
+ Content
+ ,
+ );
+ expect(root?.getAttribute("data-chrome")).toBe("auto");
+
+ rerender(
+
+ Content
+ ,
+ );
+ expect(root?.getAttribute("data-chrome")).toBe("stacked");
+ expect(root?.getAttribute("data-responsive")).toBe("stacked");
});
it("does not allow body props to expose a collapsed Window body", () => {
@@ -684,8 +700,8 @@ describe("accessibility contracts", () => {
);
});
- it("uses dedicated dialog chrome instead of a window tab and widget", () => {
- render(
+ it("uses dedicated chrome for dialogs and alert dialogs", () => {
+ const dialogRender = render(
Content
,
@@ -695,17 +711,16 @@ describe("accessibility contracts", () => {
expect(dialog.classList.contains("greyui-window")).toBe(false);
expect(dialog.querySelector(".greyui-dialog-tabbar")).not.toBeNull();
expect(dialog.querySelector(".greyui-window-widget")).toBeNull();
- });
+ dialogRender.unmount();
- it("uses the same dedicated chrome for alert dialogs", () => {
render(
Content
,
);
- const dialog = screen.getByRole("alertdialog", { name: "Discard changes?" });
- expect(dialog.classList.contains("greyui-window")).toBe(false);
- expect(dialog.querySelector(".greyui-dialog-tabbar")).not.toBeNull();
+ const alertDialog = screen.getByRole("alertdialog", { name: "Discard changes?" });
+ expect(alertDialog.classList.contains("greyui-window")).toBe(false);
+ expect(alertDialog.querySelector(".greyui-dialog-tabbar")).not.toBeNull();
});
});
diff --git a/tests/docs.test.tsx b/tests/docs.test.tsx
index 983f6a2..faba46c 100644
--- a/tests/docs.test.tsx
+++ b/tests/docs.test.tsx
@@ -1,14 +1,11 @@
-import { readFileSync } from "node:fs";
-import { resolve } from "node:path";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CopyCommand } from "../docs/src/CopyCommand";
-import { GREYUI_VERSION } from "../docs/src/version";
-
-// SAFETY: package.json is repository-owned and npm requires `version` to be a string.
-const { version: packageVersion } = JSON.parse(
- readFileSync(resolve(process.cwd(), "package.json"), "utf8"),
-) as { version: string };
+import {
+ collectWindowRegressionGeometryFailures,
+ WindowRegressionFixtures,
+} from "../docs/src/window-regression-fixtures";
+import { Layer } from "../src";
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, "execCommand");
@@ -39,26 +36,7 @@ describe("documentation copy command", () => {
expect(screen.getByRole("status").textContent).toBe("Copied npm install command");
});
- it("does not report success when clipboard copying fails", async () => {
- Object.defineProperty(navigator, "clipboard", {
- configurable: true,
- value: undefined,
- });
- Object.defineProperty(document, "execCommand", {
- configurable: true,
- value: vi.fn<(commandId: string) => boolean>().mockReturnValue(false),
- });
-
- render( );
- const button = screen.getByRole("button", { name: "Copy npm install command" });
-
- fireEvent.click(button);
-
- await waitFor(() => expect(document.execCommand).toHaveBeenCalledWith("copy"));
- expect(button.getAttribute("data-copied")).toBe("false");
- });
-
- it("cleans up the fallback field when legacy copying throws", async () => {
+ it("cleans up and leaves success unset when fallback copying fails", async () => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: undefined,
@@ -77,63 +55,96 @@ describe("documentation copy command", () => {
fireEvent.click(button);
await waitFor(() => expect(execCommand).toHaveBeenCalledTimes(1));
+ expect(execCommand).toHaveBeenCalledWith("copy");
expect(document.body.querySelector("textarea")).toBeNull();
expect(button.getAttribute("data-copied")).toBe("false");
});
});
-describe("documentation structure", () => {
- it("keeps imports in one explicit GroupBox guide under Application patterns", () => {
- const main = readFileSync(resolve(process.cwd(), "docs/src/main.tsx"), "utf8");
- const highValue = readFileSync(
- resolve(process.cwd(), "docs/src/high-value-components.tsx"),
- "utf8",
- );
- const nextComponents = readFileSync(
- resolve(process.cwd(), "docs/src/next-components.tsx"),
- "utf8",
- );
- const componentDocs = `${main}\n${highValue}\n${nextComponents}`;
- const denseWindow = readFileSync(
- resolve(process.cwd(), "docs/src/dense-window-example.tsx"),
- "utf8",
+describe("window container regression fixtures", () => {
+ it("reports frame, document, and viewport overflow through one browser geometry probe", () => {
+ const { container } = render(
+
+
+ ,
);
+ const suite = container.querySelector("[data-regression-suite]");
+ if (suite === null) throw new Error("Expected window regression suite");
- const principlesSection = main.slice(
- main.indexOf('id="principles"'),
- main.indexOf('id="buttons"'),
+ const activeFixture = suite.querySelector("[data-regression-state='active']");
+ if (activeFixture === null) throw new Error("Expected an active fixture");
+ const activeFrame = activeFixture.querySelector(
+ "[data-regression-frame='window']",
);
- const patternsSection = main.slice(
- main.indexOf('id="patterns"'),
- main.indexOf('id="integration"'),
+ const firstVisibleChild = activeFrame?.querySelector(
+ "[data-regression-contained]",
);
-
- expect(componentDocs).not.toContain("ComponentImport");
- expect(principlesSection).not.toContain('title="GroupBox component"');
- expect(principlesSection).toContain('title="API conventions"');
- expect(patternsSection).toContain('title="GroupBox component"');
- expect(patternsSection).toContain("Fieldset instead");
- expect(main).toContain('title="Choose the field"');
- expect(main).toContain('title="Composition choices"');
- expect(main).toContain('title="Choose feedback"');
- expect(main).toContain('title="Overlay contract"');
- expect(main).toContain('title="Theme overrides"');
- expect(main).toContain("href={BASE_UI_COMPONENTS_URL}");
- expect(main).toContain('import { Button, GroupBox, Select, Window } from "greyui";');
- expect(main).toContain('label="git clone command"');
- expect(main).toContain('title="Button state model"');
- expect(main).toContain('title="Complete dense application window"');
- expect(denseWindow).toContain("");
- expect(denseWindow).toContain(
- '',
+ if (activeFrame === null || firstVisibleChild === null || firstVisibleChild === undefined) {
+ throw new Error("Expected measurable fixture nodes");
+ }
+
+ vi.spyOn(activeFrame, "getBoundingClientRect").mockReturnValue(rect(0, 360));
+ vi.spyOn(firstVisibleChild, "getBoundingClientRect").mockReturnValue(rect(0, 362));
+
+ expect(
+ collectWindowRegressionGeometryFailures(suite, {
+ documentClientWidth: 1280,
+ documentScrollWidth: 1281,
+ viewportWidth: 1280,
+ }),
+ ).toEqual(
+ expect.arrayContaining([
+ "document overflow: scroll width 1281px exceeds 1280px",
+ expect.stringContaining("escapes the frame"),
+ ]),
);
- expect(denseWindow).toContain("");
+
+ const overlay = document.createElement("div");
+ overlay.dataset.regressionOverlay = "";
+ vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue(rect(1270, 20));
+ document.body.append(overlay);
+ expect(
+ collectWindowRegressionGeometryFailures(suite, {
+ documentClientWidth: 1280,
+ documentScrollWidth: 1280,
+ viewportWidth: 1280,
+ }),
+ ).toContain("DIV escapes the viewport");
+ overlay.remove();
});
-});
-describe("documentation version", () => {
- it("uses the package version injected by the build", () => {
- expect(GREYUI_VERSION).toBe(packageVersion);
+ it("accepts contained window rows and portaled overlays that only exceed the frame", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const suite = container.querySelector("[data-regression-suite]");
+ if (suite === null) throw new Error("Expected window regression suite");
+
+ for (const fixture of suite.querySelectorAll("[data-regression-case='window']")) {
+ const width = Number(fixture.dataset.regressionWidth);
+ const frame = fixture.querySelector("[data-regression-frame='window']");
+ if (frame === null) throw new Error(`Expected a frame for the ${width}px fixture`);
+ vi.spyOn(frame, "getBoundingClientRect").mockReturnValue(rect(0, width));
+ for (const child of frame.querySelectorAll("[data-regression-contained]")) {
+ vi.spyOn(child, "getBoundingClientRect").mockReturnValue(rect(0, width));
+ }
+ }
+
+ const overlay = document.createElement("div");
+ overlay.dataset.regressionOverlay = "";
+ vi.spyOn(overlay, "getBoundingClientRect").mockReturnValue(rect(300, 200));
+ document.body.append(overlay);
+
+ expect(
+ collectWindowRegressionGeometryFailures(suite, {
+ documentClientWidth: 1280,
+ documentScrollWidth: 1280,
+ viewportWidth: 1280,
+ }),
+ ).toEqual([]);
+ overlay.remove();
});
});
@@ -148,3 +159,17 @@ function restoreProperty(
Reflect.deleteProperty(target, property);
}
}
+
+function rect(left: number, width: number): DOMRect {
+ return {
+ bottom: 100,
+ height: 100,
+ left,
+ right: left + width,
+ top: 0,
+ width,
+ x: left,
+ y: 0,
+ toJSON: () => ({}),
+ };
+}
diff --git a/tests/high-value-components.test.tsx b/tests/high-value-components.test.tsx
deleted file mode 100644
index 74a199b..0000000
--- a/tests/high-value-components.test.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it } from "vitest";
-import { Accordion, Autocomplete, Checkbox, CheckboxGroup, Fieldset, ToggleGroup } from "../src";
-
-afterEach(cleanup);
-
-describe("high-value component behavior", () => {
- it("shares pressed state through ToggleGroup", () => {
- render(
-
- Grid
- Snap
- ,
- );
-
- const grid = screen.getByRole("button", { name: "Grid" });
- const snap = screen.getByRole("button", { name: "Snap" });
- expect(grid.getAttribute("aria-pressed")).toBe("true");
- fireEvent.click(snap);
- expect(snap.getAttribute("aria-pressed")).toBe("true");
- });
-
- it("keeps Autocomplete input free-form while filtering suggestions", () => {
- render(
-
-
-
-
-
-
-
-
- {(item: string) => (
-
- {item}
-
- )}
-
-
- ,
- );
-
- const input = screen.getByRole("combobox", { name: "Operating system" });
- fireEvent.focus(input);
- fireEvent.input(input, { target: { value: "Hai" }, inputType: "insertText" });
- expect(screen.getByRole("option", { name: "Haiku" })).not.toBeNull();
- expect(screen.queryByRole("option", { name: "BeOS R5" })).toBeNull();
-
- fireEvent.change(input, { target: { value: "Custom OS" } });
- fireEvent.blur(input);
- expect(input.value).toBe("Custom OS");
- });
-
- it("opens Accordion panels through their trigger", () => {
- render(
-
-
-
- Advanced
-
- Advanced settings
-
- ,
- );
-
- const trigger = screen.getByRole("button", { name: "Advanced" });
- expect(trigger.getAttribute("aria-expanded")).toBe("false");
- fireEvent.click(trigger);
- expect(trigger.getAttribute("aria-expanded")).toBe("true");
- expect(screen.getByText("Advanced settings")).not.toBeNull();
- });
-
- it("groups Checkbox values without polluting accessible names", () => {
- render(
-
-
-
- ,
- );
-
- const symbols = screen.getByRole("checkbox", { name: "Debug symbols" });
- const sourceMap = screen.getByRole("checkbox", { name: "Source map" });
- expect(symbols.getAttribute("aria-checked")).toBe("true");
- fireEvent.click(sourceMap);
- expect(sourceMap.getAttribute("aria-checked")).toBe("true");
- });
-
- it("propagates disabled Fieldset state to custom Checkbox controls", () => {
- render(
-
- Managed settings
-
-
-
- ,
- );
-
- const checkbox = screen.getByRole("checkbox", { name: "Enforce policy" });
- expect(checkbox.hasAttribute("data-disabled")).toBe(true);
- fireEvent.click(checkbox);
- expect(checkbox.getAttribute("aria-checked")).toBe("false");
- });
-});
diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts
new file mode 100644
index 0000000..5ce4a3e
--- /dev/null
+++ b/tests/release-workflow.test.ts
@@ -0,0 +1,30 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+describe("release workflow", () => {
+ it("creates the version tag only after package validation", () => {
+ const workflow = readFileSync(resolve(process.cwd(), ".github/workflows/release.yml"), "utf8");
+ const resolveVersion = workflow.indexOf("name: Resolve release version");
+ const verifyDocs = workflow.indexOf("run: npm run verify:docs");
+ const verifyPackage = workflow.indexOf("name: Verify package is publishable");
+ const createTag = workflow.indexOf("name: Create validated release tag");
+ const publish = workflow.indexOf("name: Publish to npm");
+
+ expect(resolveVersion).toBeGreaterThanOrEqual(0);
+ expect(verifyDocs).toBeGreaterThan(resolveVersion);
+ expect(verifyPackage).toBeGreaterThan(resolveVersion);
+ expect(verifyPackage).toBeGreaterThan(verifyDocs);
+ expect(createTag).toBeGreaterThan(verifyPackage);
+ expect(publish).toBeGreaterThan(createTag);
+
+ const versionResolution = workflow.slice(resolveVersion, verifyPackage);
+ expect(versionResolution).not.toContain('git tag "${expected_tag}"');
+ expect(workflow).toContain(
+ "if: steps.release.outputs.validate == 'true' && steps.release.outputs.publish != 'true'",
+ );
+ expect(workflow).toContain(
+ "if: steps.release.outputs.publish == 'true'\n run: npm publish",
+ );
+ });
+});
diff --git a/tests/styles.test.ts b/tests/styles.test.ts
index eb7b021..e8dbcee 100644
--- a/tests/styles.test.ts
+++ b/tests/styles.test.ts
@@ -28,23 +28,78 @@ describe("default theme contrast", () => {
expect(contrast(selection, text)).toBeGreaterThanOrEqual(4.5);
});
- it("limits mobile window flattening to the stacked responsive mode", () => {
+ it("names Window as an inline-size query container", () => {
const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8");
- const mobileRules = css.slice(css.indexOf("@media (max-width: 768px)"));
- expect(mobileRules).toContain('.greyui-window[data-responsive="stacked"]');
- expect(mobileRules).not.toContain('.greyui-window[data-responsive="floating"]');
+ expect(css).toMatch(
+ /\.greyui-window\[data-chrome="auto"\]\s*\{[\s\S]*?container-type:\s*inline-size;[\s\S]*?container-name:\s*greyui-window;/,
+ );
+ expect(css).toMatch(
+ /:where\(\.greyui-window\[data-chrome="auto"\], \.greyui-window\[data-chrome="stacked"\]\)\s*\{[\s\S]*?width:\s*100%/,
+ );
+ expect(css).toMatch(
+ /@container greyui-window \(max-width: 768px\)[\s\S]*?\.greyui-window\[data-chrome="auto"\] \.greyui-window-frame/,
+ );
+ expect(css).toMatch(
+ /\.greyui-window\[data-chrome="stacked"\] \.greyui-window-frame\s*\{[\s\S]*?padding:\s*0/,
+ );
+ expect(css).toMatch(
+ /\.greyui-window\[data-collapsed="true"\] \.greyui-window-frame\s*\{[\s\S]*?width:\s*max-content/,
+ );
+ expect(css).toMatch(
+ /\.greyui-window\[data-chrome="stacked"\] \.greyui-window-frame\s*\{[\s\S]*?width:\s*100%/,
+ );
+ expect(css).not.toMatch(
+ /@media \(max-width: 768px\)[\s\S]*?\.greyui-window\[data-(?:chrome|responsive)=/,
+ );
+ });
+
+ it("gives the inner window frame sole ownership of outer chrome", () => {
+ const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8");
+ const rootRule = css.slice(
+ css.indexOf(".greyui-window {"),
+ css.indexOf(".greyui-window-frame {"),
+ );
+ const frameRule = css.slice(
+ css.indexOf(".greyui-window-frame {"),
+ css.indexOf(".greyui-window-frame::before"),
+ );
+ const bodyRule = css.slice(
+ css.indexOf(".greyui-window-body {"),
+ css.indexOf(".greyui-window-content {"),
+ );
+
+ expect(rootRule).not.toContain("box-shadow:");
+ expect(frameRule).toContain("box-shadow:");
+ expect(css).toMatch(/\.greyui-window-frame::before\s*\{[\s\S]*?border:/);
+ expect(bodyRule).not.toContain("border:");
+ expect(bodyRule).not.toContain("box-shadow:");
+ expect(css).toMatch(/\.greyui-menubar\s*\{[\s\S]*?margin:\s*0;/);
+ expect(css).toMatch(/\.greyui-statusbar\s*\{[\s\S]*?margin:\s*0;/);
+ expect(css).toMatch(
+ /\.greyui-window\[data-chrome="stacked"\] \.greyui-menubar\s*\{[\s\S]*?border-top:\s*0;[\s\S]*?border-left:\s*0/,
+ );
+ expect(css).toMatch(
+ /\.greyui-window\[data-chrome="stacked"\] \.greyui-statusbar\s*\{[\s\S]*?border-bottom:\s*0;[\s\S]*?border-left:\s*0/,
+ );
+ expect(css).toMatch(
+ /\.greyui-window\[data-chrome="stacked"\]\[data-collapsed="true"\] \.greyui-window-tab\s*\{[\s\S]*?border-bottom:\s*0/,
+ );
+ expect(css).toMatch(
+ /@container greyui-window \(max-width: 768px\)[\s\S]*?\.greyui-window\[data-chrome="auto"\]\[data-collapsed="true"\] \.greyui-window-tab\s*\{[\s\S]*?border-bottom:\s*0/,
+ );
});
- it("keeps WorkbenchOS typography and window scale tokens aligned", () => {
+ it("keeps active and inactive window geometry identical", () => {
const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8");
+ const inactiveDeclarations = Array.from(
+ css.matchAll(/\.greyui-window\[data-active="false"\][^{]*\{([^}]*)\}/g),
+ (match) => match[1],
+ ).join("\n");
- expect(css).toContain("--greyui-font-size: 12px");
- expect(css).toContain("--greyui-font-size-small: 11px");
- expect(css).toContain("--greyui-tab-height: 26px");
- expect(css).toContain("--greyui-menubar-height: 20px");
- expect(css).toMatch(/\.greyui-button\s*\{[\s\S]*?min-height:\s*1\.85rem/);
- expect(css).toMatch(/\.greyui-button\s*\{[\s\S]*?font-weight:\s*400/);
+ expect(inactiveDeclarations).not.toMatch(
+ /(?:^|\s)(?:display|position|inset|width|height|min-width|min-height|max-width|max-height|margin|padding|border-width):/,
+ );
});
it("uses neutral inset keyboard focus without overriding selected button state", () => {
@@ -65,16 +120,34 @@ describe("default theme contrast", () => {
expect(css.slice(selectedStart, focusStart)).toContain("var(--greyui-selection)");
});
- it("provides content rails and a stacked application header without styling body internals", () => {
+ it("provides container-aware content rails and application headers", () => {
const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8");
expect(css).toMatch(/\.greyui-window-content\s*\{[\s\S]*?padding:\s*12px/);
expect(css).toMatch(
- /\.greyui-window-header\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto/,
+ /\.greyui-window-header\s*\{[\s\S]*?container-name:\s*greyui-window-header/,
+ );
+ expect(css).toMatch(
+ /\.greyui-window-header-layout\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto/,
+ );
+ expect(css).toMatch(
+ /@container greyui-window-header \(max-width: 520px\)[\s\S]*?\[data-layout="auto"\]/,
+ );
+ });
+
+ it("stacks field action rows against their own container", () => {
+ const css = readFileSync(resolve(process.cwd(), "src/components-v2.css"), "utf8");
+
+ expect(css).toMatch(
+ /\.greyui-field-action-row\s*\{[\s\S]*?container-name:\s*greyui-field-action-row/,
);
expect(css).toMatch(
- /@media \(max-width: 520px\)[\s\S]*?\.greyui-window-header\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\)/,
+ /@container greyui-field-action-row \(max-width: 350px\)[\s\S]*?\[data-layout="auto"\]/,
);
+ expect(css).toMatch(
+ /\[data-layout="stacked"\][\s\S]*?\.greyui-field-action-row-layout[\s\S]*?flex-direction:\s*column/,
+ );
+ expect(css).not.toContain("@media (max-width: 350px)");
});
it("keeps window control hover chrome off touch devices", () => {
@@ -103,20 +176,46 @@ describe("default theme contrast", () => {
expect(tooltip).toBeGreaterThan(dialog);
});
- it("keeps Select menu labels single-line within a content-sized popup", () => {
+ it("uses anchor-width form popups with explicit content-width overrides", () => {
const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8");
+ const componentCss = readFileSync(resolve(process.cwd(), "src/components-v2.css"), "utf8");
- expect(css).toMatch(/\.greyui-select-list\s*\{[\s\S]*?min-width:\s*max-content/);
+ expect(css).toMatch(
+ /\.greyui-select-popup\s*\{[\s\S]*?width:\s*min\(var\(--anchor-width[\s\S]*?var\(--available-width/,
+ );
+ expect(css).toMatch(
+ /\.greyui-select-popup\[data-greyui-popup-width="content"\]\s*\{[\s\S]*?width:\s*max-content/,
+ );
+ expect(css).toMatch(/\.greyui-select-list\s*\{[\s\S]*?min-width:\s*0/);
+ expect(css).toMatch(
+ /\.greyui-select-popup\[data-greyui-popup-width="content"\] \.greyui-select-list\s*\{[\s\S]*?min-width:\s*max-content/,
+ );
expect(css).toMatch(/\.greyui-select-item-text\s*\{[\s\S]*?grid-column:\s*2/);
expect(css).toMatch(/\.greyui-select-item-text\s*\{[\s\S]*?white-space:\s*nowrap/);
expect(css).toMatch(/\.greyui-select-item-indicator\s*\{[\s\S]*?grid-column:\s*1/);
+ expect(componentCss).toMatch(
+ /\.greyui-combobox-popup\s*\{[\s\S]*?width:\s*min\(var\(--anchor-width[\s\S]*?var\(--available-width/,
+ );
+ expect(componentCss).toMatch(/\.greyui-autocomplete-item-text\s*\{[\s\S]*?grid-column:\s*1/);
+ expect(componentCss).toMatch(
+ /\.greyui-autocomplete-item-indicator\s*\{[\s\S]*?grid-column:\s*2/,
+ );
+ expect(componentCss).not.toContain(
+ ".greyui-combobox-item > :not(.greyui-combobox-item-indicator)",
+ );
});
- it("uses standard panel surfaces for grouped controls", () => {
- const css = readFileSync(resolve(process.cwd(), "src/components-v2.css"), "utf8");
+ it("clamps menus to Base UI's collision area and communicates disabled triggers", () => {
+ const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8");
- expect(css).toMatch(/\.greyui-fieldset\s*\{[\s\S]*?background:\s*var\(--greyui-panel\)/);
- expect(css).toMatch(/\.greyui-accordion-panel\s*\{[\s\S]*?background:\s*var\(--greyui-panel\)/);
+ expect(css).toMatch(
+ /\.greyui-menu-popup\s*\{[\s\S]*?max-width:[^;]*var\(--available-width[\s\S]*?max-height:[^;]*var\(--available-height/,
+ );
+ expect(css).toMatch(/\.greyui-menu-trigger\[data-disabled\]\s*\{[\s\S]*?cursor:\s*not-allowed/);
+ expect(css).toMatch(
+ /\.greyui-menubar \.greyui-menu-trigger\[data-disabled\]\s*\{[\s\S]*?color:\s*var\(--greyui-text-muted\)/,
+ );
+ expect(css).toContain(".greyui-menu-trigger:hover:not([data-disabled])");
});
it("removes nested chrome from plain fieldsets and aligns field action rows", () => {
@@ -125,7 +224,7 @@ describe("default theme contrast", () => {
expect(css).toMatch(
/\.greyui-fieldset\[data-variant="plain"\]\s*\{[\s\S]*?padding:\s*0;[\s\S]*?border:\s*0;[\s\S]*?background:\s*transparent;[\s\S]*?box-shadow:\s*none/,
);
- expect(css).toMatch(/\.greyui-field-action-row\s*\{[\s\S]*?align-items:\s*flex-end/);
+ expect(css).toMatch(/\.greyui-field-action-row-layout\s*\{[\s\S]*?align-items:\s*flex-end/);
});
it("paints table row fills through every cell edge", () => {
diff --git a/vitest.config.ts b/vitest.config.ts
index 16a0e2a..9cc1b77 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -14,5 +14,6 @@ export default defineConfig({
},
test: {
environment: "jsdom",
+ exclude: ["tests/browser/**", "**/node_modules/**", "**/.git/**"],
},
});