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;