diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b..780e822 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,29 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, +import autoprefixer from 'autoprefixer' +import tailwindcss from 'tailwindcss' + +// Apply the same breakpoints to handwritten CSS and Tailwind utilities. +export const inlineBreakpoints = { + postcssPlugin: 'inline-breakpoints', + OnceExit (root) { + const queries = [] + root.walkAtRules ('media', query => { + if (/\b(?:min|max)-width\s*:/.test (query.params)) + queries.push (query) + }) + for (const query of queries) { + const vertical = query.cloneAfter ({ + params: query.params.replace (/\b(min|max)-width(?=\s*:)/g, '$1-height') }) + for (const [branch, scope] of [ + [query, ':where(html:not(:lang(mn-Mong)))'], + [vertical, ':where(html:lang(mn-Mong))']]) { + branch.walkRules (rule => { + rule.selectors = rule.selectors.map (selector => `${scope} ${selector}`) + }) + } + } }, } + +export default { + plugins: [tailwindcss (), inlineBreakpoints, autoprefixer ()], +} diff --git a/frontend/postcss.config.test.js b/frontend/postcss.config.test.js new file mode 100644 index 0000000..266b407 --- /dev/null +++ b/frontend/postcss.config.test.js @@ -0,0 +1,52 @@ +import postcss from 'postcss' +import tailwindcss from 'tailwindcss' +import { describe, expect, it } from 'vitest' + +import { inlineBreakpoints } from './postcss.config.js' + +describe ('inline breakpoints', () => { + it ('uses height for vertical menus and width for horizontal menus', async () => { + const source = ` + @media (min-width: 768px) { .menu, .account { display: flex } } + @media (min-width: 1024px) { .menu { font-size: 1rem } } + @media (max-width: 767px) { .mobile { display: block } } + @media (prefers-reduced-motion: reduce) { .menu { transition: none } } + ` + const { css } = await postcss ([inlineBreakpoints]).process ( + source, { from: 'menus.css' }) + const rules = [] + postcss.parse (css).walkRules (rule => { + rules.push ([rule.parent.params, rule.selector]) + }) + + expect (rules).toEqual ([ + ['(min-width: 768px)', + ':where(html:not(:lang(mn-Mong))) .menu, ' + + ':where(html:not(:lang(mn-Mong))) .account'], + ['(min-height: 768px)', + ':where(html:lang(mn-Mong)) .menu, :where(html:lang(mn-Mong)) .account'], + ['(min-width: 1024px)', ':where(html:not(:lang(mn-Mong))) .menu'], + ['(min-height: 1024px)', ':where(html:lang(mn-Mong)) .menu'], + ['(max-width: 767px)', ':where(html:not(:lang(mn-Mong))) .mobile'], + ['(max-height: 767px)', ':where(html:lang(mn-Mong)) .mobile'], + ['(prefers-reduced-motion: reduce)', '.menu']]) + }) + + it ('includes responsive utilities generated by Tailwind', async () => { + const { css } = await postcss ([tailwindcss ({ + content: [{ raw: '
' }], + corePlugins: { preflight: false } }), inlineBreakpoints]).process ( + '@tailwind utilities;', { from: 'utilities.css' }) + const rules = [] + postcss.parse (css).walkRules (rule => { + rules.push ([rule.parent.params, rule.selector]) + }) + + expect (rules).toContainEqual ([ + '(min-width: 768px)', ':where(html:not(:lang(mn-Mong))) .md\\:hidden']) + expect (rules).toContainEqual ([ + '(min-height: 768px)', ':where(html:lang(mn-Mong)) .md\\:hidden']) + expect (rules).toContainEqual ([ + '(min-height: 1024px)', ':where(html:lang(mn-Mong)) .lg\\:text-base']) + }) +}) diff --git a/frontend/src/components/ResponsiveMarqueeText.tsx b/frontend/src/components/ResponsiveMarqueeText.tsx index 0960730..a703d2a 100644 --- a/frontend/src/components/ResponsiveMarqueeText.tsx +++ b/frontend/src/components/ResponsiveMarqueeText.tsx @@ -1,7 +1,9 @@ -import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' -import { cn } from '@/lib/utils' import { useEffect, useRef, useState } from 'react' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { useVerticalPreview } from '@/lib/useLocale' +import { cn } from '@/lib/utils' + import type { FC } from 'react' type Props = { @@ -11,7 +13,7 @@ type Props = { title?: string } const DESKTOP_MARQUEE_MEDIA = - '(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)' + '(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)' const MARQUEE_START_DELAY_MS = 1600 const MARQUEE_END_HOLD_MS = 2400 const MARQUEE_SCROLL_PX_PER_SECOND = 43 @@ -21,6 +23,7 @@ const MIN_MARQUEE_OVERFLOW_PX = 1 const ResponsiveMarqueeText: FC = ( { text, className, truncateOnMobile = false, title }, ) => { + const verticalPreview = useVerticalPreview () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const outerRef = useRef (null) @@ -69,7 +72,8 @@ const ResponsiveMarqueeText: FC = ( if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return - const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA) + const sizeMedia = verticalPreview ? '(min-height: 768px)' : '(min-width: 768px)' + const media = window.matchMedia (`${ sizeMedia } and ${ DESKTOP_MARQUEE_MEDIA }`) const update = () => { setDesktopMarqueeEnabled (media.matches) } @@ -80,7 +84,7 @@ const ResponsiveMarqueeText: FC = ( return () => { media.removeEventListener ('change', update) } - }, []) + }, [verticalPreview]) useEffect (() => { const clearScheduled = () => { diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 7859858..4904749 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -299,21 +299,14 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { let active = true const update = () => { - if (!(window.matchMedia ('(min-width: 768px)').matches)) + const desktopMedia = verticalPreview ? '(min-height: 768px)' : '(min-width: 768px)' + if (!(window.matchMedia (desktopMedia).matches)) { setDesktop (false) setDensity ('auto') return } - if (verticalPreview) - { - setDesktop (true) - setDensity ('auto') - measure (moreVsbl ? -1 : activeIdx) - return - } - // Measure the same desktop elements while the mobile layout is active. layout.setAttribute ('data-measuring', '') const previousDensity = layout.getAttribute ('data-density') ?? 'auto' @@ -324,16 +317,25 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { { layout.setAttribute ('data-density', candidate) const style = getComputedStyle (bar) - const primaryWidth = Math.max ( - primary.getBoundingClientRect ().width, primary.scrollWidth) - const accountWidth = + const primarySize = + verticalPreview + ? Math.max (primary.getBoundingClientRect ().height, primary.scrollHeight) + : Math.max (primary.getBoundingClientRect ().width, primary.scrollWidth) + const accountSize = account == null ? 0 - : Math.max (account.getBoundingClientRect ().width, account.scrollWidth) - const required = primaryWidth + accountWidth - + parseFloat (style.paddingLeft) + parseFloat (style.paddingRight) - + (account == null ? 0 : parseFloat (style.columnGap)) - if (Math.ceil (required) <= bar.clientWidth) + : (verticalPreview + ? Math.max (account.getBoundingClientRect ().height, account.scrollHeight) + : Math.max (account.getBoundingClientRect ().width, account.scrollWidth)) + const padding = + verticalPreview + ? parseFloat (style.paddingTop) + parseFloat (style.paddingBottom) + : parseFloat (style.paddingLeft) + parseFloat (style.paddingRight) + const gap = verticalPreview ? style.rowGap : style.columnGap + const required = primarySize + accountSize + padding + + (account == null ? 0 : parseFloat (gap)) + const available = verticalPreview ? bar.clientHeight : bar.clientWidth + if (Math.ceil (required) <= available) return candidate } return null diff --git a/frontend/src/index.css b/frontend/src/index.css index fd135b7..084d6b0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1355,6 +1355,16 @@ html:lang(mn-Mong) .inheritance-dialogue-fields > button height: auto; } + [data-vertical-preview] .top-nav-layout[data-measuring] .top-nav-primary, + [data-vertical-preview] .top-nav-layout[data-measuring] .top-nav-desktop-menu, + [data-vertical-preview] .top-nav-layout[data-measuring] .top-nav-account + { + flex: 0 0 auto; + width: 100%; + height: max-content; + max-height: none; + } + [data-vertical-preview] .top-nav-desktop-menu > div:not(.top-nav-highlight) { width: 100%;