diff --git a/CONTEXT.md b/CONTEXT.md index f3ca4ab4..315b7b88 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -143,7 +143,7 @@ Important concepts: - Theme variants are recognized from known theme names. - Variant tokens (`:active`, `:focus`, `:disabled`, `:where(.theme)`, `:dir()`, `[data-x]`) are read from two selector shapes: nested under the class as `&:active` (Tailwind < 4.3.3) and flattened into the class selector as `.active\:x:active` (Tailwind >= 4.3.3). A selector carrying any token the runtime cannot observe (e.g. `[aria-disabled="true"]`, alone or stacked with a supported variant) is skipped, never applied under a weaker condition. - Data attribute variants support boolean `data-x` and exact `data-x="value"` matching against component props. -- Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata. +- Media queries drive dimensions, orientation, color scheme, platform, and native/web-specific metadata. Native exclusive width bounds use the generated artifact's `0.01pt` numeric precision to exclude equality, including bounds expressed with viewport-relative units. - Important declarations are preserved as `importantProperties`. - Unsupported CSS features may be silently ignored on native. Prefer documenting support coverage over adding noisy runtime failures for every unsupported CSS construct. - Tailwind composes `filter` from per-utility `--tw-*` variables and relies on `var(--x,)` empty fallbacks for unset parts, so `Var` resolves those to an empty string. Each filter function compiles to `rt.filterFn(name, amount, unit)` because `addMissingSpaces` would otherwise corrupt an inline `blur(${...}px)` template. diff --git a/packages/uniwind/src/bundler/css-processor/mq.ts b/packages/uniwind/src/bundler/css-processor/mq.ts index 02be572b..99b0a066 100644 --- a/packages/uniwind/src/bundler/css-processor/mq.ts +++ b/packages/uniwind/src/bundler/css-processor/mq.ts @@ -4,6 +4,8 @@ import type { MediaQuery, QueryFeatureFor_MediaFeatureId } from 'lightningcss' import type { ProcessorBuilder } from './processor' import type { MediaQueryResolver } from './types' +const EXCLUSIVE_BOUND_EPSILON = 0.01 + export class MQ { constructor(private readonly Processor: ProcessorBuilder) {} @@ -48,13 +50,25 @@ export class MQ { const { operator, value } = query const result = this.Processor.CSS.processValue(value) - if (operator === 'greater-than-equal' || operator === 'greater-than') { + if (operator === 'greater-than-equal') { mq.minWidth = result } - if (operator === 'less-than-equal' || operator === 'less-than') { + if (operator === 'greater-than') { + mq.minWidth = typeof result === 'number' + ? result + EXCLUSIVE_BOUND_EPSILON + : `(${result}) + ${EXCLUSIVE_BOUND_EPSILON}` + } + + if (operator === 'less-than-equal') { mq.maxWidth = result } + + if (operator === 'less-than') { + mq.maxWidth = typeof result === 'number' + ? result - EXCLUSIVE_BOUND_EPSILON + : `(${result}) - ${EXCLUSIVE_BOUND_EPSILON}` + } } private processPlainMediaQuery(query: QueryFeatureFor_MediaFeatureId & { type: 'plain' }, mq: MediaQueryResolver) { diff --git a/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts b/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts new file mode 100644 index 00000000..b7142572 --- /dev/null +++ b/packages/uniwind/tests/native/styles-parsing/media-queries.test.ts @@ -0,0 +1,40 @@ +import { StyleDependency } from '../../../src/common/consts' +import { UniwindListener } from '../../../src/core/listener' +import { UniwindStore } from '../../../src/core/native/store' + +const resolveAtWidth = (className: string, width: number) => { + UniwindStore.runtime.screen = { ...UniwindStore.runtime.screen, width } + UniwindListener.notify([StyleDependency.Dimensions]) + + return UniwindStore.getStyles( + className, + {}, + {}, + { scopedTheme: null, rtl: null, variables: null }, + ).styles +} + +describe('media query boundaries', () => { + const originalScreen = UniwindStore.runtime.screen + + afterEach(() => { + UniwindStore.runtime.screen = originalScreen + UniwindListener.notify([StyleDependency.Dimensions]) + }) + + test.each([ + [389.99, { top: 0, right: 0, bottom: 1, left: 1 }], + [390, { top: 0, right: 1, bottom: 0, left: 1 }], + [390.01, { top: 1, right: 1, bottom: 0, left: 0 }], + ])('resolves width %s', (width, expected) => { + expect(resolveAtWidth('media-query-boundaries', width)).toMatchObject(expected) + }) + + test('resolves custom width media queries', () => { + expect(resolveAtWidth('min-[50vw]:top-[1px]', 390)).toMatchObject({ top: 1 }) + expect(resolveAtWidth('min-[300px]:top-[1px]', 299.99)).toEqual({}) + expect(resolveAtWidth('min-[300px]:top-[1px]', 300)).toMatchObject({ top: 1 }) + expect(resolveAtWidth('max-[500px]:right-[1px]', 499.99)).toMatchObject({ right: 1 }) + expect(resolveAtWidth('max-[500px]:right-[1px]', 500)).toEqual({}) + }) +}) diff --git a/packages/uniwind/tests/native/styles-parsing/meta.test.ts b/packages/uniwind/tests/native/styles-parsing/meta.test.ts index efb113e3..81946699 100644 --- a/packages/uniwind/tests/native/styles-parsing/meta.test.ts +++ b/packages/uniwind/tests/native/styles-parsing/meta.test.ts @@ -2,6 +2,7 @@ import { UniwindBundlerConfig } from '../../../src/bundler/config' import { compileCSS } from '../../../src/bundler/css-compiler' import { Platform, StyleDependency } from '../../../src/common/consts' import { StyleSheets } from '../../../src/core/types' +import { SCREEN_HEIGHT, SCREEN_WIDTH } from '../../consts' type CompiledResult = { stylesheet: StyleSheets @@ -14,7 +15,7 @@ const compileMetadata = async (): Promise => { const virtualCode = await compileCSS(bundlerConfig) // oxlint-disable-next-line no-unused-vars - const rt = {} + const rt = { screen: { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } } // oxlint-disable-next-line no-eval return eval(`(${virtualCode})`) diff --git a/packages/uniwind/tests/test.css b/packages/uniwind/tests/test.css index 9300e88e..39abb952 100644 --- a/packages/uniwind/tests/test.css +++ b/packages/uniwind/tests/test.css @@ -45,3 +45,34 @@ color: var(--inline-after); --inline-after: #123456; } + +.media-query-boundaries { + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +@media (width > 390px) { + .media-query-boundaries { + top: 1px; + } +} + +@media (width >= 390px) { + .media-query-boundaries { + right: 1px; + } +} + +@media (width < 390px) { + .media-query-boundaries { + bottom: 1px; + } +} + +@media (width <= 390px) { + .media-query-boundaries { + left: 1px; + } +}