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
68 changes: 53 additions & 15 deletions test/unit/uno-preset-rtl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,60 @@ describe('uno-preset-rtl', () => {
const warnings = warnSpy.mock.calls.flat()
expect(warnings).toMatchInlineSnapshot(`
[
"[RTL] Avoid using 'left-0', use 'inset-is-0' instead.",
"[RTL] Avoid using 'right-0', use 'inset-ie-0' instead.",
"[RTL] Avoid using 'pl-1', use 'ps-1' instead.",
"[RTL] Avoid using 'ml-1', use 'ms-1' instead.",
"[RTL] Avoid using 'pr-1', use 'pe-1' instead.",
"[RTL] Avoid using 'mr-1', use 'me-1' instead.",
"[RTL] Avoid using 'border-l', use 'border-is' instead.",
"[RTL] Avoid using 'border-r', use 'border-ie' instead.",
"[RTL] Avoid using 'rounded-l', use 'rounded-is' instead.",
"[RTL] Avoid using 'rounded-r', use 'rounded-ie' instead.",
"[RTL] Avoid using 'position-left-4', use 'inset-is-4' instead.",
"[RTL] Avoid using 'sm:pl-2', use 'sm:ps-2' instead.",
"[RTL] Avoid using 'text-left', use 'text-start' instead.",
"[RTL] Avoid using 'text-right', use 'text-end' instead.",
"[RTL] Avoid using 'hover:text-right', use 'hover:text-end' instead.",
"[RTL] Avoid using 'left-0', use 'inset-is-0' instead, or 'force-left-0' to keep physical direction.",
"[RTL] Avoid using 'right-0', use 'inset-ie-0' instead, or 'force-right-0' to keep physical direction.",
"[RTL] Avoid using 'pl-1', use 'ps-1' instead, or 'force-pl-1' to keep physical direction.",
"[RTL] Avoid using 'ml-1', use 'ms-1' instead, or 'force-ml-1' to keep physical direction.",
"[RTL] Avoid using 'pr-1', use 'pe-1' instead, or 'force-pr-1' to keep physical direction.",
"[RTL] Avoid using 'mr-1', use 'me-1' instead, or 'force-mr-1' to keep physical direction.",
"[RTL] Avoid using 'border-l', use 'border-is' instead, or 'force-border-l' to keep physical direction.",
"[RTL] Avoid using 'border-r', use 'border-ie' instead, or 'force-border-r' to keep physical direction.",
"[RTL] Avoid using 'rounded-l', use 'rounded-is' instead, or 'force-rounded-l' to keep physical direction.",
"[RTL] Avoid using 'rounded-r', use 'rounded-ie' instead, or 'force-rounded-r' to keep physical direction.",
"[RTL] Avoid using 'position-left-4', use 'inset-is-4' instead, or 'force-position-left-4' to keep physical direction.",
"[RTL] Avoid using 'sm:pl-2', use 'sm:ps-2' instead, or 'force-sm:pl-2' to keep physical direction.",
"[RTL] Avoid using 'text-left', use 'text-start' instead, or 'force-text-left' to keep physical direction.",
"[RTL] Avoid using 'text-right', use 'text-end' instead, or 'force-text-right' to keep physical direction.",
"[RTL] Avoid using 'hover:text-right', use 'hover:text-end' instead, or 'force-hover:text-right' to keep physical direction.",
]
`)
})

it('force-* (non rtl rules) use original css styles correctly', async () => {
const uno = await createGenerator({
presets: [presetWind4(), presetRtl()],
})

const { css } = await uno.generate(
'force-left-0 force-right-0 force-pl-1 force-ml-1 force-pr-1 force-mr-1 force-text-left force-text-right force-border-l force-border-r force-rounded-l force-rounded-r sm:force-pl-2 hover:force-text-right force-position-left-4',
)

expect(css).toMatchInlineSnapshot(`
"/* layer: theme */
:root, :host { --font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --default-font-family: var(--font-sans); --default-monoFont-family: var(--font-mono); }
/* layer: base */
*, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); font-feature-settings: var(--default-font-featureSettings, normal); font-variation-settings: var(--default-font-variationSettings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var( --default-monoFont-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); font-feature-settings: var(--default-monoFont-featureSettings, normal); font-variation-settings: var(--default-monoFont-variationSettings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden~='until-found'])) { display: none !important; }
/* layer: default */
.force-pl-1{padding-left:0.25rem;}
.force-pr-1{padding-right:0.25rem;}
.force-ml-1{margin-left:0.25rem;}
.force-mr-1{margin-right:0.25rem;}
.force-left-0{left:0;}
.force-position-left-4{left:1rem;}
.force-right-0{right:0;}
.force-text-left{text-align:left;}
.force-text-right{text-align:right;}
.hover\\:force-text-right:hover{text-align:right;}
.force-rounded-l{border-top-left-radius:0.25rem;border-bottom-left-radius:0.25rem;}
.force-rounded-r{border-top-right-radius:0.25rem;border-bottom-right-radius:0.25rem;}
.force-border-l{border-left-width:1px;}
.force-border-r{border-right-width:1px;}
@media (min-width: 40rem){
.sm\\:force-pl-2{padding-left:0.5rem;}
}"
`)

const warnings = warnSpy.mock.calls.flat()
expect(warnings).toMatchInlineSnapshot(`[]`)
})
})
61 changes: 60 additions & 1 deletion uno-preset-rtl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function resetRtlWarnings() {
}

function reportWarning(match: string, suggestedClass: string, checker?: CollectorChecker) {
const message = `${checker ? 'a' : 'A'}void using '${match}', use '${suggestedClass}' instead.`
const message = `${checker ? 'a' : 'A'}void using '${match}', use '${suggestedClass}' instead, or 'force-${match}' to keep physical direction.`
if (checker) {
checker(message, match)
} else {
Expand Down Expand Up @@ -90,6 +90,20 @@ function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefine
if (directions && v != null) return directions.map(i => [`border${i}-width`, v])
}

function handlerForceDirectionSize(
propertyPrefix: string,
[, direction, size]: string[],
{ theme }: RuleContext<any>,
): CSSEntries | undefined {
const v =
theme.spacing?.[size || 'DEFAULT'] ?? h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
const directions = directionMap[direction!]

if (v != null && directions) {
return directions.map(i => [`${propertyPrefix}${i}`, v])
}
}

/**
* CSS RTL support to detect, replace and warn wrong left/right usages.
*/
Expand All @@ -101,6 +115,51 @@ export function presetRtl(checker?: CollectorChecker): Preset {
['text-right', 'text-end x-rtl-end'],
],
rules: [
// Force physical directions (bypass RTL logic)
[
/^force-p([rl])-(.+)?$/,
(match, context) => handlerForceDirectionSize('padding', match, context),
{ autocomplete: 'force-p(l|r)-<num>' },
],
[
/^force-m([rl])-(.+)?$/,
(match, context) => handlerForceDirectionSize('margin', match, context),
{ autocomplete: 'force-m(l|r)-<num>' },
],
[
/^force-(?:position-|pos-)?(left|right)-(.+)$/,
([_, direction, size], context) => {
// Map 'left'/'right' to 'l'/'r' for directionMap lookup if needed,
// but directionMap has 'left'/'right' keys? No, it has 'l'/'r'.
// Wait, directionMap keys are 'l', 'r'.
// But inset usually uses 'left', 'right' properties directly.
// Let's use a custom handler for inset to be safe.
const v =
(context.theme as unknown as any).spacing?.[size || 'DEFAULT'] ??
h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
if (v != null) {
return [[direction === 'left' ? 'left' : 'right', v]]
}
},
Comment on lines +130 to +143
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove the any cast to keep this rule type-safe.

context.theme as unknown as any breaks strict type-safety. You can destructure theme using RuleContext<any> (like the helper does) and avoid the cast.

💡 Suggested fix
-      ([_, direction, size], context) => {
+      ([_, direction, size], { theme }: RuleContext<any>) => {
         // Map 'left'/'right' to 'l'/'r' for directionMap lookup if needed,
         // but directionMap has 'left'/'right' keys? No, it has 'l'/'r'.
         // Wait, directionMap keys are 'l', 'r'.
         // But inset usually uses 'left', 'right' properties directly.
         // Let's use a custom handler for inset to be safe.
         const v =
-          (context.theme as unknown as any).spacing?.[size || 'DEFAULT'] ??
+          theme.spacing?.[size || 'DEFAULT'] ??
           h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
         if (v != null) {
           return [[direction === 'left' ? 'left' : 'right', v]]
         }
       },
As per coding guidelines, "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/^force-(?:position-|pos-)?(left|right)-(.+)$/,
([_, direction, size], context) => {
// Map 'left'/'right' to 'l'/'r' for directionMap lookup if needed,
// but directionMap has 'left'/'right' keys? No, it has 'l'/'r'.
// Wait, directionMap keys are 'l', 'r'.
// But inset usually uses 'left', 'right' properties directly.
// Let's use a custom handler for inset to be safe.
const v =
(context.theme as unknown as any).spacing?.[size || 'DEFAULT'] ??
h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
if (v != null) {
return [[direction === 'left' ? 'left' : 'right', v]]
}
},
/^force-(?:position-|pos-)?(left|right)-(.+)$/,
([_, direction, size], { theme }: RuleContext<any>) => {
// Map 'left'/'right' to 'l'/'r' for directionMap lookup if needed,
// but directionMap has 'left'/'right' keys? No, it has 'l'/'r'.
// Wait, directionMap keys are 'l', 'r'.
// But inset usually uses 'left', 'right' properties directly.
// Let's use a custom handler for inset to be safe.
const v =
theme.spacing?.[size || 'DEFAULT'] ??
h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
if (v != null) {
return [[direction === 'left' ? 'left' : 'right', v]]
}
},

{ autocomplete: 'force-(left|right)-<num>' },
],
[
/^force-text-(left|right)$/,
([, direction]) => ({ 'text-align': direction }),
{ autocomplete: 'force-text-(left|right)' },
],
[
/^force-rounded-([rl])(?:-(.+))?$/,
([, direction, size], context) =>
handlerRounded(['', direction!, size ?? 'DEFAULT'], context),
{ autocomplete: 'force-rounded-(l|r)-<num>' },
],
[
/^force-border-([rl])(?:-(.+))?$/,
([, direction, size]) => handlerBorderSize(['', direction!, size || '1']),
{ autocomplete: 'force-border-(l|r)-<num>' },
],

// RTL overrides
// We need to move the dash out of the capturing group to avoid capturing it in the direction
[
Expand Down
Loading