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() { + + + + + + ); +} + +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} + ); } @@ -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 ( +