Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/12593-modal-viewport-gutter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-form': patch
---

An `lg` or `xl` `ModalForm` dialog keeps a 1rem gutter on each side of the window from `sm` (640px) up (objectui#12593).

The two sizes are bounded at 672px (`lg`) and 1024px (`xl`), but the dialog took the full width of any window narrower than that, at x = 0, with no margin. Since every field-heavy create or edit in the console opens `lg` or `xl`, a laptop or tablet window between 640px and 1056px drew those dialogs edge to edge. Both sizes now read one shared width, the window less 2rem, under their own max width, so the dialog is centred with an equal gutter. On a window wider than the max width plus 2rem each size is exactly as wide as before.

Unchanged: the `sm`, `default` and `full` sizes, the full-screen sheet below `sm`, and the dialog's height rules. Nothing is added to the package entry: no export, prop, type member or `modalSize` value.
31 changes: 29 additions & 2 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,45 @@ export interface ModalFormProps {
className?: string;
}

/**
* The viewport gutter the bounded wide sizes keep from `sm` up
* (objectui#12593): a width of the window less 1rem on each side, under the
* size's own `max-w`. ONE value, read by both `lg` and `xl`.
*
* Without it, the dialog primitive's `w-full` makes an `lg` (672px) or `xl`
* (1024px) dialog exactly as wide as any window narrower than its max width,
* at x = 0, edge to edge. With it, the dialog is the smaller of its max width
* and the window less 2rem, and the primitive's `left-[50%]` plus
* `translate-x-[-50%]` centres it with an equal 1rem either side. Above the
* max width plus 2rem the size is exactly as wide as before.
*
* `100%`, not `100vw`: the dialog is `fixed`, so `100%` is its containing
* block, the viewport less any classic scrollbar, the same box `left-[50%]`
* centres in. `100vw` counts the scrollbar, so each gutter would lose half
* the scrollbar's width.
*
* `sm:` only: below `sm` the dialog is the full-screen phone sheet, unchanged.
* `sm` (384px) and `default` (512px) never reach the window edge at or above
* 640px, and `full` keeps its own `95vw`, so none of the three reads it. The
* class string is literal so Tailwind's source scan emits it.
*/
const BOUNDED_SIZE_VIEWPORT_GUTTER = 'sm:w-[calc(100%-2rem)]';

/**
* Size class map for the dialog content.
*
* Uses `sm:` prefix so that `tailwind-merge` correctly resolves the conflict
* with MobileDialogContent's base `sm:max-w-lg` class. On mobile (< sm) the
* dialog is already full-screen, so max-width only matters at sm+ breakpoints.
*
* The bounded wide sizes, `lg` and `xl`, also read
* {@link BOUNDED_SIZE_VIEWPORT_GUTTER} (objectui#12593).
*/
const modalSizeClasses: Record<string, string> = {
sm: 'sm:max-w-sm',
default: 'sm:max-w-lg',
lg: 'sm:max-w-2xl',
xl: 'sm:max-w-5xl',
lg: `sm:max-w-2xl ${BOUNDED_SIZE_VIEWPORT_GUTTER}`,
xl: `sm:max-w-5xl ${BOUNDED_SIZE_VIEWPORT_GUTTER}`,
full: 'sm:max-w-[95vw] sm:w-full',
};

Expand Down
165 changes: 165 additions & 0 deletions packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The bounded wide dialog sizes keep a viewport gutter from `sm` up
* (objectui#12593).
*
* `lg` (`sm:max-w-2xl`, 672px) and `xl` (`sm:max-w-5xl`, 1024px) took the
* dialog primitive's `w-full`, so on a window between `sm` (640px) and the max
* width the dialog was as wide as the window, at x = 0. Both now read ONE
* shared class, a width of the window less 1rem on each side, under their own
* `max-w`.
*
* ## What it reads
*
* The real `ModalForm`, mounted as `AppContent` mounts it, and the class list
* on the dialog element after `tailwind-merge` has resolved it against
* `MobileDialogContent`'s base classes. jsdom computes no layout, so the pins
* read classes. The rendered widths are a Chromium reading, recorded on the
* pull request, not here.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { registerAllFields } from '@object-ui/fields';
import type { DataSource } from '@object-ui/types';
import { ModalForm, type ModalFormSchema } from '../ModalForm';

registerAllFields();

afterEach(() => cleanup());

/** The shared gutter: the window less 1rem on each side, from `sm` up. */
const GUTTER = 'sm:w-[calc(100%-2rem)]';
/** Any width class written for the `sm` tier and up. */
const isSmWidth = (t: string) => t.startsWith('sm:w-');

const TWO = {
name: 'thing',
fields: {
name: { type: 'text', label: 'Name' },
code: { type: 'text', label: 'Code' },
},
};

const opt = (v: string) => [{ label: v, value: v }];

/** Twelve plain fields plus one textarea: the default size answers `xl`. */
const TWELVE = {
name: 'thing',
fields: {
name: { type: 'text', label: 'Name' },
code: { type: 'text', label: 'Code' },
email: { type: 'email', label: 'Email' },
phone: { type: 'text', label: 'Phone' },
website: { type: 'url', label: 'Website' },
status: { type: 'select', label: 'Status', options: opt('open') },
stage: { type: 'select', label: 'Stage', options: opt('new') },
source: { type: 'select', label: 'Source', options: opt('web') },
amount: { type: 'currency', label: 'Amount' },
quantity: { type: 'number', label: 'Quantity' },
due: { type: 'date', label: 'Due' },
notes: { type: 'textarea', label: 'Notes' },
},
};

const ds = (def: unknown) => ({
getObjectSchema: vi.fn().mockResolvedValue(def),
findOne: vi.fn().mockResolvedValue({}),
find: vi.fn().mockResolvedValue({ data: [] }),
create: vi.fn(),
update: vi.fn(),
}) as unknown as DataSource;

/** Mount the dialog, wait for its fields, and return its class tokens. */
async function dialogTokens(
def: { fields: Record<string, unknown> },
modalSize?: ModalFormSchema['modalSize'],
): Promise<string[]> {
const first = Object.keys(def.fields)[0];
render(
<ModalForm
schema={{
type: 'object-form',
formType: 'modal',
objectName: 'thing',
mode: 'create',
open: true,
fields: Object.keys(def.fields),
...(modalSize ? { modalSize } : {}),
}}
dataSource={ds(def)}
/>,
);
await waitFor(() => {
if (!document.body.querySelector(`[name="${first}"]`)) throw new Error('form not drawn yet');
});
const dialog = document.body.querySelector('[role="dialog"]') as HTMLElement;
const tokens = dialog.className.split(/\s+/).filter(Boolean);
cleanup();
return tokens;
}

const minus = (a: string[], b: string[]) => a.filter((t) => !b.includes(t)).sort();

describe('the bounded wide sizes keep a viewport gutter from sm up (objectui#12593)', () => {
it('lg and xl both carry the one shared gutter, under their own max width', async () => {
const lg = await dialogTokens(TWO, 'lg');
const xl = await dialogTokens(TWO, 'xl');

expect(lg).toContain('sm:max-w-2xl');
expect(xl).toContain('sm:max-w-5xl');
// One value, read by both: the same single sm-tier width on each.
expect(lg.filter(isSmWidth)).toEqual([GUTTER]);
expect(xl.filter(isSmWidth)).toEqual([GUTTER]);
// tailwind-merge kept the size's max width over the primitive's base one.
expect(lg).not.toContain('sm:max-w-lg');
expect(xl).not.toContain('sm:max-w-lg');
});

it('a field-heavy dialog with no authored size, the console\'s New and Edit, carries it', async () => {
const heavy = await dialogTokens(TWELVE);
expect(heavy).toContain('sm:max-w-5xl');
expect(heavy.filter(isSmWidth)).toEqual([GUTTER]);
});

it('below sm nothing changes: against default, lg and xl differ only in sm-tier classes', async () => {
const def = await dialogTokens(TWO, 'default');
const lg = await dialogTokens(TWO, 'lg');
const xl = await dialogTokens(TWO, 'xl');

expect(minus(lg, def)).toEqual([GUTTER, 'sm:max-w-2xl'].sort());
expect(minus(xl, def)).toEqual([GUTTER, 'sm:max-w-5xl'].sort());
expect(minus(def, lg)).toEqual(['sm:max-w-lg']);
expect(minus(def, xl)).toEqual(['sm:max-w-lg']);
for (const added of [...minus(lg, def), ...minus(xl, def)]) {
expect(added.startsWith('sm:')).toBe(true);
}
// The phone sheet and the height rules are still on both.
for (const tokens of [lg, xl]) {
expect(tokens).toEqual(
expect.arrayContaining(['fixed', 'inset-0', 'w-full', 'h-[100dvh]', 'sm:h-auto', 'sm:max-h-[90vh]']),
);
}
});

it('CONTROL: sm, default and full are unchanged and read no gutter', async () => {
const sm = await dialogTokens(TWO, 'sm');
const def = await dialogTokens(TWO, 'default');
const full = await dialogTokens(TWO, 'full');

expect(sm).toContain('sm:max-w-sm');
expect(sm.filter(isSmWidth)).toEqual([]);
expect(def).toContain('sm:max-w-lg');
expect(def.filter(isSmWidth)).toEqual([]);
expect(full).toContain('sm:max-w-[95vw]');
expect(full.filter(isSmWidth)).toEqual(['sm:w-full']);
});
});
Loading