diff --git a/bulma-ui/src/form/DateInput.stories.tsx b/bulma-ui/src/form/DateInput.stories.tsx
index 407e6512b..48e6607e9 100644
--- a/bulma-ui/src/form/DateInput.stories.tsx
+++ b/bulma-ui/src/form/DateInput.stories.tsx
@@ -767,13 +767,79 @@ export const InputOnly: Story = {
},
};
-const portaledCorners = [
+const corners = [
'bottom-left',
'bottom-right',
'top-left',
'top-right',
] as const;
+// Opens each input in turn and checks, in a real browser, that its panel sits
+// on the input's edge, the gap away, and holds its content. jsdom does no
+// layout, so it cannot see where a panel lands.
+const playCorners: Story['play'] = async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const inputs = within(canvasElement).getAllByRole('combobox');
+ for (const [i, position] of corners.entries()) {
+ await userEvent.click(inputs[i]);
+ const panel = await page.findByRole('dialog');
+ // The open animation slides the panel, so measure where it lands. A
+ // cancelled animation rejects `finished` and no longer moves the panel,
+ // so it is settled rather than awaited.
+ await Promise.allSettled(panel.getAnimations().map(a => a.finished));
+ const anchor = inputs[i].parentElement!.getBoundingClientRect();
+ const box = panel.getBoundingClientRect();
+ const gap = parseFloat(
+ getComputedStyle(panel).getPropertyValue('--bulma-picker-popover-offset')
+ );
+ const off = (actual: number, expected: number) =>
+ Math.round(Math.abs(actual - expected));
+ expect({
+ position,
+ vertical: position.startsWith('bottom')
+ ? off(box.top, anchor.bottom + gap)
+ : off(box.bottom, anchor.top - gap),
+ horizontal: position.endsWith('left')
+ ? off(box.left, anchor.left)
+ : off(box.right, anchor.right),
+ // The scroll and client sizes are each rounded to whole pixels, so
+ // 1px apart is rounding. A collapsed panel overflows by its content.
+ overflow: Math.max(
+ panel.scrollHeight - panel.clientHeight - 1,
+ panel.scrollWidth - panel.clientWidth - 1,
+ 0
+ ),
+ }).toEqual({ position, vertical: 0, horizontal: 0, overflow: 0 });
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => expect(page.queryByRole('dialog')).toBeNull());
+ }
+};
+
+export const Positions: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ '`position` picks the corner of the input the calendar opens from, `--bulma-picker-popover-offset` away from it. Each input here opens from a different one.',
+ },
+ },
+ },
+ render: () => (
+
+ {corners.map(position => (
+
+ ))}
+
+ ),
+ // In place, the gap is a margin on the side facing the input, and it has
+ // to add up with the corner's inset for the panel to land the gap away.
+ play: playCorners,
+};
+
export const AppendToBody: Story = {
parameters: {
docs: {
@@ -785,7 +851,7 @@ export const AppendToBody: Story = {
},
render: () => (
- {portaledCorners.map(position => (
+ {corners.map(position => (
),
- // Opens each one and checks, in a real browser, that the panel sits on its
- // input's edge, the gap away, and holds its content. A portaled panel that
- // kept its corner's own `right` or `bottom` stretched to the viewport's
- // right edge or collapsed to its padding, which jsdom cannot see.
- play: async ({ canvasElement }) => {
- const page = within(canvasElement.ownerDocument.body);
- const inputs = within(canvasElement).getAllByRole('combobox');
- for (const [i, position] of portaledCorners.entries()) {
- await userEvent.click(inputs[i]);
- const panel = await page.findByRole('dialog');
- // The open animation slides the panel, so measure where it lands. A
- // cancelled animation rejects `finished` and no longer moves the panel,
- // so it is settled rather than awaited.
- await Promise.allSettled(panel.getAnimations().map(a => a.finished));
- const anchor = inputs[i].parentElement!.getBoundingClientRect();
- const box = panel.getBoundingClientRect();
- const gap = parseFloat(
- getComputedStyle(panel).getPropertyValue(
- '--bulma-picker-popover-offset'
- )
- );
- const off = (actual: number, expected: number) =>
- Math.round(Math.abs(actual - expected));
- expect({
- position,
- vertical: position.startsWith('bottom')
- ? off(box.top, anchor.bottom + gap)
- : off(box.bottom, anchor.top - gap),
- horizontal: position.endsWith('left')
- ? off(box.left, anchor.left)
- : off(box.right, anchor.right),
- // The scroll and client sizes are each rounded to whole pixels, so
- // 1px apart is rounding. A collapsed panel overflows by its content.
- overflow: Math.max(
- panel.scrollHeight - panel.clientHeight - 1,
- panel.scrollWidth - panel.clientWidth - 1,
- 0
- ),
- }).toEqual({ position, vertical: 0, horizontal: 0, overflow: 0 });
- await userEvent.keyboard('{Escape}');
- await waitFor(() => expect(page.queryByRole('dialog')).toBeNull());
- }
- },
+ // A portaled panel that keeps its corner's own `right` or `bottom`
+ // stretches to the viewport's right edge or collapses to its padding.
+ play: playCorners,
};
export const Composed: Story = {
diff --git a/bulma-ui/src/form/__tests__/PickerPopover.styles.test.tsx b/bulma-ui/src/form/__tests__/PickerPopover.styles.test.tsx
index f8f2d808d..11368afb8 100644
--- a/bulma-ui/src/form/__tests__/PickerPopover.styles.test.tsx
+++ b/bulma-ui/src/form/__tests__/PickerPopover.styles.test.tsx
@@ -7,7 +7,7 @@ import * as sass from 'sass';
import { readdirSync } from 'fs';
import path from 'path';
import type { ReactElement } from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
import { DateInput } from '../DateInput';
import { TimeInput } from '../TimeInput';
import { DateTimeInput } from '../DateTimeInput';
@@ -22,23 +22,24 @@ const OPTIONS = {
logger: sass.Logger.silent,
};
-const sheets: HTMLStyleElement[] = [];
+/** The partial, compiled under each class prefix, by prefix. */
+const sheets: Record = {};
beforeAll(() => {
- for (const source of [
- `@use 'form/picker-popover';`,
- `@use 'bulma/sass/utilities/initial-variables' with ($class-prefix: 'bestax-');
+ for (const [prefix, source] of Object.entries({
+ '': `@use 'form/picker-popover';`,
+ 'bestax-': `@use 'bulma/sass/utilities/initial-variables' with ($class-prefix: 'bestax-');
@use 'form/picker-popover';`,
- ]) {
+ })) {
const el = document.createElement('style');
el.textContent = sass.compileString(source, OPTIONS).css;
document.head.appendChild(el);
- sheets.push(el);
+ sheets[prefix] = el;
}
});
afterAll(() => {
- for (const el of sheets) el.remove();
+ for (const el of Object.values(sheets)) el.remove();
});
type Corner = Exclude;
@@ -49,7 +50,7 @@ const corners: Corner[] = [
'top-right',
];
-type PickerProps = { position: Corner; appendToBody: boolean };
+type PickerProps = { position: PickerPosition; appendToBody: boolean };
const pickers: [string, (props: PickerProps) => ReactElement][] = [
['DateInput', props => ],
['TimeInput', props => ],
@@ -74,7 +75,9 @@ function openPanel(
return panel;
}
-const offset = 'calc(100% + var(--bulma-picker-popover-offset))';
+const gap = 'var(--bulma-picker-popover-offset)';
+// The edge an in-place panel faces the input with, the gap past it.
+const offset = `calc(100% + ${gap})`;
describe.each(pickers)('%s panel placement', (_name, picker) => {
describe.each(prefixes)('with class prefix "%s"', prefix => {
@@ -98,10 +101,10 @@ describe.each(pickers)('%s panel placement', (_name, picker) => {
);
it.each<[Corner, Record]>([
- ['bottom-left', { top: offset, left: '0px' }],
- ['bottom-right', { top: offset, right: '0px' }],
- ['top-left', { bottom: offset, left: '0px' }],
- ['top-right', { bottom: offset, right: '0px' }],
+ ['bottom-left', { top: '100%', left: '0px', 'margin-top': gap }],
+ ['bottom-right', { top: '100%', right: '0px', 'margin-top': gap }],
+ ['top-left', { bottom: '100%', left: '0px', 'margin-bottom': gap }],
+ ['top-right', { bottom: '100%', right: '0px', 'margin-bottom': gap }],
])('keeps an in-place %s panel on its corner', (position, expected) => {
const panel = openPanel(picker, prefix, {
position,
@@ -121,6 +124,65 @@ describe.each(pickers)('%s panel placement', (_name, picker) => {
});
});
+const box = (top: number, left: number, height: number, width: number) =>
+ ({
+ top,
+ left,
+ bottom: top + height,
+ right: left + width,
+ height,
+ width,
+ x: left,
+ y: top,
+ toJSON: () => ({}),
+ }) as DOMRect;
+
+describe.each(pickers)('%s auto placement in place', (_name, picker) => {
+ describe.each(prefixes)('with class prefix "%s"', prefix => {
+ it.each<[string, number, Corner]>([
+ ['4px', 4, 'bottom-left'],
+ ['4px', 3, 'top-left'],
+ ['20px', 20, 'bottom-left'],
+ ['20px', 19, 'top-left'],
+ ])(
+ 'with a %s offset and %ipx free below, opens at %s',
+ (value, room, expected) => {
+ // A theme's offset, resolved the way a browser would: jsdom leaves
+ // `var()` as written, which reads as no gap at all.
+ const themed = document.createElement('style');
+ themed.textContent = sheets[prefix].textContent!.split(gap).join(value);
+ document.head.appendChild(themed);
+ try {
+ render(
+
+ {picker({ position: 'auto', appendToBody: false })}
+
+ );
+ fireEvent.click(screen.getByRole('combobox'));
+ const panel = screen.getByRole('dialog');
+ const height = 300;
+ jest
+ .spyOn(panel.parentElement!, 'getBoundingClientRect')
+ .mockReturnValue(
+ box(window.innerHeight - room - height - 40, 20, 40, 200)
+ );
+ jest
+ .spyOn(panel, 'getBoundingClientRect')
+ .mockReturnValue(box(0, 0, height, 280));
+ act(() => {
+ fireEvent(window, new Event('resize'));
+ });
+ // Below, the panel needs the room the offset puts between it and
+ // the input, as well as its own height.
+ expect(panel).toHaveClass(`${prefix}is-${expected}`);
+ } finally {
+ themed.remove();
+ }
+ }
+ );
+ });
+});
+
// Any partial loaded after this one can move the panel too, so where it sits
// is also checked in every stylesheet the package publishes, at a phone and
// a desktop width. jsdom's own cascade ignores specificity and width media
@@ -229,8 +291,26 @@ function mediaHolds(mediaText: string, width: number): boolean {
});
}
-const PLACEMENT = ['position', 'top', 'right', 'bottom', 'left'];
-const isInset = (property: string) => property.startsWith('inset');
+// What says where a box sits, and the value each has when nothing sets it.
+// The gap to the anchor can be a margin, so margins count too.
+const INITIAL: Record = {
+ position: 'static',
+ top: 'auto',
+ right: 'auto',
+ bottom: 'auto',
+ left: 'auto',
+ 'margin-top': '0',
+ 'margin-right': '0',
+ 'margin-bottom': '0',
+ 'margin-left': '0',
+};
+const PLACEMENT = Object.keys(INITIAL);
+// Shorthands and logical properties move the box without naming these.
+const isIndirect = (property: string) =>
+ property.startsWith('inset') ||
+ property === 'margin' ||
+ property.startsWith('margin-block') ||
+ property.startsWith('margin-inline');
/**
* The style rules that can hold at this width and say where a box sits, in
@@ -244,7 +324,7 @@ function placementRules(
for (const rule of Array.from(rules)) {
if (rule instanceof CSSStyleRule) {
const declared = Array.from(rule.style);
- if (declared.some(p => PLACEMENT.includes(p) || isInset(p))) {
+ if (declared.some(p => PLACEMENT.includes(p) || isIndirect(p))) {
found.push(rule);
}
} else if (rule instanceof CSSMediaRule) {
@@ -271,7 +351,7 @@ function matches(el: Element, selector: string): boolean {
* Where the cascade puts the element under these rules and its inline style.
* Each property takes the declaration ranked highest by importance, then
* inline over a rule, then specificity, then source order. A property
- * nothing declares is empty, as jsdom reports it.
+ * nothing declares has its initial value.
*/
function placement(el: HTMLElement, rules: CSSStyleRule[]) {
const winners = new Map();
@@ -287,10 +367,10 @@ function placement(el: HTMLElement, rules: CSSStyleRule[]) {
rules.forEach((rule, order) => {
const matching = splitList(rule.selectorText).filter(s => matches(el, s));
if (matching.length === 0) return;
- // An `inset` shorthand moves the box without naming these properties.
- expect([rule.selectorText, Array.from(rule.style).filter(isInset)]).toEqual(
- [rule.selectorText, []]
- );
+ expect([
+ rule.selectorText,
+ Array.from(rule.style).filter(isIndirect),
+ ]).toEqual([rule.selectorText, []]);
const rank = highest(matching.map(specificity));
for (const property of PLACEMENT) {
offer(
@@ -304,10 +384,38 @@ function placement(el: HTMLElement, rules: CSSStyleRule[]) {
offer(property, [important(el.style, property), 1, 0, 0, 0, 0], el.style);
}
return Object.fromEntries(
- PLACEMENT.map(property => [property, winners.get(property)?.value ?? ''])
+ PLACEMENT.map(property => [
+ property,
+ winners.get(property)?.value ?? INITIAL[property],
+ ])
);
}
+const isZero = (length: string) => /^0(px)?$/.test(length);
+
+/**
+ * Where each side of a positioned box sits from its containing block's, as
+ * an inset alone would say it. On the side whose inset is set, the edge is
+ * that inset plus the margin there. The other side's inset is auto, and the
+ * box's height puts that edge where it falls, whatever the margin there.
+ */
+function edges({ position, ...placed }: Record) {
+ const edge = (side: string) => {
+ const inset = placed[side];
+ const margin = placed[`margin-${side}`] ?? '0';
+ return inset === 'auto' || isZero(margin)
+ ? inset
+ : `calc(${inset} + ${margin})`;
+ };
+ return {
+ position,
+ top: edge('top'),
+ right: edge('right'),
+ bottom: edge('bottom'),
+ left: edge('left'),
+ };
+}
+
/** Parses CSS into a sheet without leaving it on the document. */
function parseSheet(css: string): CSSStyleSheet {
const el = document.createElement('style');
@@ -359,12 +467,57 @@ describe.each(published)('panel placement in %s', file => {
// on top of them stretches or collapses the fixed box.
expect(panel.style.top).not.toBe('');
expect(panel.style.left).not.toBe('');
+ // The gap is the margin on top, negative above the input.
expect(placement(panel, rules)).toEqual({
position: 'fixed',
top: panel.style.top,
right: 'auto',
bottom: 'auto',
left: panel.style.left,
+ 'margin-top': position.startsWith('top')
+ ? `calc(-1 * ${gap})`
+ : gap,
+ 'margin-right': '0',
+ 'margin-bottom': '0',
+ 'margin-left': '0',
+ });
+ }
+ );
+ });
+
+ // On a phone an in-place TimeInput panel is a sheet, checked below.
+ const inPlace =
+ width === PHONE
+ ? pickers.filter(([name]) => name !== 'TimeInput')
+ : pickers;
+
+ describe.each(inPlace)('%s', (_name, picker) => {
+ it.each<[Corner, Record]>([
+ [
+ 'bottom-left',
+ { top: offset, right: 'auto', bottom: 'auto', left: '0' },
+ ],
+ [
+ 'bottom-right',
+ { top: offset, right: '0', bottom: 'auto', left: 'auto' },
+ ],
+ ['top-left', { top: 'auto', right: 'auto', bottom: offset, left: '0' }],
+ [
+ 'top-right',
+ { top: 'auto', right: '0', bottom: offset, left: 'auto' },
+ ],
+ ])(
+ 'keeps an in-place %s panel the offset off its corner',
+ (position, sides) => {
+ // Compared as edges, where the inset and the margin facing the
+ // input add up to the gap past it.
+ const panel = openPanel(picker, prefix, {
+ position,
+ appendToBody: false,
+ });
+ expect(edges(placement(panel, rules))).toEqual({
+ position: 'absolute',
+ ...sides,
});
}
);
@@ -375,7 +528,8 @@ describe.each(published)('panel placement in %s', file => {
'keeps an in-place TimeInput panel at %s a sheet on a phone',
position => {
// On a small screen an in-place TimeInput panel spans the bottom of
- // the viewport instead of sitting on its corner.
+ // the viewport instead of sitting on its corner. No margin moves it,
+ // so `auto` reads no gap whichever corner it is given.
const panel = openPanel(props => , prefix, {
position,
appendToBody: false,
@@ -386,6 +540,10 @@ describe.each(published)('panel placement in %s', file => {
right: '1rem',
bottom: '1rem',
left: '1rem',
+ 'margin-top': '0',
+ 'margin-right': '0',
+ 'margin-bottom': '0',
+ 'margin-left': '0',
});
}
);
diff --git a/bulma-ui/src/form/__tests__/PickerPopover.test.tsx b/bulma-ui/src/form/__tests__/PickerPopover.test.tsx
index 1bc6d864c..0174b6e1b 100644
--- a/bulma-ui/src/form/__tests__/PickerPopover.test.tsx
+++ b/bulma-ui/src/form/__tests__/PickerPopover.test.tsx
@@ -376,6 +376,28 @@ describe('PickerPopover position resolution', () => {
expect(dialog.className).toMatch(/is-top-left/);
});
+ it('auto leaves room below an in-place panel for its margin and no more', () => {
+ setViewport(1000, 1000);
+ const result = render();
+ const dialog = result.getByRole('dialog');
+ // The margin `--bulma-picker-popover-offset: 20px` would give it.
+ dialog.style.marginTop = '20px';
+ jest
+ .spyOn(result.getByTestId('anchor'), 'getBoundingClientRect')
+ .mockReturnValue(
+ makeRect({ top: 660, bottom: 680, left: 50, right: 110 })
+ );
+ jest
+ .spyOn(dialog, 'getBoundingClientRect')
+ .mockReturnValue(makeRect({ top: 0, left: 0, right: 200, bottom: 300 }));
+ act(() => {
+ fireEvent(window, new Event('resize'));
+ });
+ // 680 + 300 + 20 fills the 1000px viewport exactly.
+ expect(dialog.className).toMatch(/is-bottom-left/);
+ expect(dialog.style.top).toBe('');
+ });
+
it('auto without appendToBody applies the corner class with no inline coordinates', () => {
setViewport(1000, 1000);
const { getByRole } = renderWithRects(
diff --git a/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx b/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx
index dc7ea31aa..8d33d3a59 100644
--- a/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx
+++ b/bulma-ui/src/form/_pickerInternals/PickerPopover.tsx
@@ -79,11 +79,10 @@ export const PickerPopover: React.FC = ({
active: isOpen,
position,
fixed: appendToBody,
- // A portaled panel takes its gap from `--bulma-picker-popover-offset` as
- // a margin, so its coordinates leave the gap out and `auto` reads it off
- // the panel. An in-place panel's gap is in its `top` or `bottom`, which
- // `auto` cannot read, so it keeps the hook's default estimate.
- offset: appendToBody ? 0 : undefined,
+ // The gap comes from `--bulma-picker-popover-offset` as a margin, in place
+ // or portaled, so the coordinates leave it out and `auto` reads it off
+ // the panel.
+ offset: 0,
styledGap: marginGap,
});
diff --git a/bulma-ui/src/helpers/useAnchoredPosition.ts b/bulma-ui/src/helpers/useAnchoredPosition.ts
index f84ac15a7..cabf80ee4 100644
--- a/bulma-ui/src/helpers/useAnchoredPosition.ts
+++ b/bulma-ui/src/helpers/useAnchoredPosition.ts
@@ -34,7 +34,10 @@ export interface UseAnchoredPositionOptions {
/**
* The gap between the anchor and the panel, in pixels, that the hook puts
* there itself: added to the fixed coordinates and to the room `auto` asks
- * for below the anchor.
+ * for below the anchor. Popover and the pickers' popover pass `0` and take
+ * their gap from a margin in their stylesheet, read through `styledGap`, so
+ * a theme's offset moves the panel and `auto` alike. A new panel should do
+ * the same rather than rely on this default, which no stylesheet sees.
* @defaultValue 4
*/
offset?: number;
diff --git a/bulma-ui/src/scss/form/_picker-popover.scss b/bulma-ui/src/scss/form/_picker-popover.scss
index b17215912..82621a927 100644
--- a/bulma-ui/src/scss/form/_picker-popover.scss
+++ b/bulma-ui/src/scss/form/_picker-popover.scss
@@ -40,39 +40,42 @@ $picker-trigger-hover-color: cv.getVar('text-strong') !default;
animation: picker-popover-fade-in
cv.getVar('picker-popover-animation-duration') ease;
+ // The gap is a margin on the side facing the anchor, as on Popover, so the
+ // positioning hook can read it off the panel when `auto` picks a corner.
&.#{iv.$class-prefix}is-bottom-left {
- top: calc(100% + cv.getVar('picker-popover-offset'));
+ top: 100%;
left: 0;
+ margin-top: cv.getVar('picker-popover-offset');
}
&.#{iv.$class-prefix}is-bottom-right {
- top: calc(100% + cv.getVar('picker-popover-offset'));
+ top: 100%;
right: 0;
+ margin-top: cv.getVar('picker-popover-offset');
}
&.#{iv.$class-prefix}is-top-left {
- bottom: calc(100% + cv.getVar('picker-popover-offset'));
+ bottom: 100%;
left: 0;
+ margin-bottom: cv.getVar('picker-popover-offset');
}
&.#{iv.$class-prefix}is-top-right {
- bottom: calc(100% + cv.getVar('picker-popover-offset'));
+ bottom: 100%;
right: 0;
+ margin-bottom: cv.getVar('picker-popover-offset');
}
// Portaled: fixed to the viewport at the top and left the positioning hook
// sets inline, so the corner's own right and bottom must not apply. Left
// in, `right: 0` stretches the panel to the viewport's right edge and the
// top corners' `bottom` collapses it to its padding. The coordinates are
- // the anchor's edge, so the gap is a margin.
+ // the anchor's edge, so a panel above it pulls up by the gap instead.
&.#{iv.$class-prefix}is-portal {
position: fixed;
right: auto;
bottom: auto;
- &.#{iv.$class-prefix}is-bottom-left,
- &.#{iv.$class-prefix}is-bottom-right {
- margin-top: cv.getVar('picker-popover-offset');
- }
&.#{iv.$class-prefix}is-top-left,
&.#{iv.$class-prefix}is-top-right {
margin-top: calc(-1 * #{cv.getVar('picker-popover-offset')});
+ margin-bottom: 0;
}
}
}
diff --git a/bulma-ui/src/scss/form/_timeinput.scss b/bulma-ui/src/scss/form/_timeinput.scss
index 4f3a7c3bb..5eb9d4f11 100644
--- a/bulma-ui/src/scss/form/_timeinput.scss
+++ b/bulma-ui/src/scss/form/_timeinput.scss
@@ -364,6 +364,11 @@ $timeinput-footer-padding: 0.5rem 0 0 !default;
top: auto;
width: auto;
max-width: none;
+ // The sheet sits off the viewport, not the input, so it drops the gap its
+ // corner puts in a margin. Kept, a top corner's would lift it off its
+ // `bottom`, and `auto` would leave room for a gap the sheet doesn't have.
+ margin-top: 0;
+ margin-bottom: 0;
}
.#{iv.$class-prefix}timeinput-panel {
display: flex;