@microbit/ui 0.1.0-alpha.30 → 0.1.0-alpha.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,20 +91,31 @@ node_modules/@microbit/ui/lang/ui.fr.json --ast --out-file ...` (multiple
91
91
  their English catalog for those, and should then pass `en`, keeping the
92
92
  chosen language in their own settings for the embed. Everything downstream
93
93
  reads this locale as a statement about the rendered page — `<html lang>`
94
- via `SharedUIProvider`, react-aria's text direction and built-in strings,
95
- `Intl` number/date formatting and plural rules. A catalog that loads but
96
- has per-message gaps (an incomplete translation) is still that language;
97
- only a wholesale fallback to the English catalog should claim `en`.
94
+ and `<html dir>` via `SharedUIProvider`, react-aria's text direction and
95
+ built-in strings, `Intl` number/date formatting and plural rules. A catalog
96
+ that loads but has per-message gaps (an incomplete translation) is still
97
+ that language; only a wholesale fallback to the English catalog should
98
+ claim `en`.
98
99
 
99
100
  6. **`SharedUIProvider`** inside the `IntlProvider`, wrapping the app. It
100
101
  passes the locale on to react-aria, which translates its own built-in
101
102
  strings (see [Strings](#strings)); without it those follow the browser
102
- rather than the app's language setting. It also keeps `<html lang>` in
103
- step with the locale so assistive tech announces the page in the app's
104
- language pass `setDocumentLang={false}` where the app doesn't own the
105
- document it's mounted in (an embedded widget). Also takes an optional
106
- overlay-close registrar, so the app can dismiss open menus from outside
107
- the tree (e.g. the Android hardware back button).
103
+ rather than the app's language setting. It also keeps `<html lang>` and
104
+ `<html dir>` in step with the locale, so assistive tech announces the page
105
+ in the app's language and an RTL language lays out the right way round
106
+ pass `setDocumentLang={false}` where the app doesn't own the document it's
107
+ mounted in (an embedded widget). Also takes an optional overlay-close
108
+ registrar, so the app can dismiss open menus from outside the tree (e.g.
109
+ the Android hardware back button).
110
+
111
+ An app that passes `setDocumentLang={false}` **must set `dir` itself**,
112
+ on whatever element it mounts into, if it offers an RTL language. The two
113
+ halves of mirroring read the direction from different places: react-aria
114
+ takes it from the provider locale in JS and mirrors regardless of the DOM,
115
+ while logical properties and Panda's `_rtl` rules match on `dir`. With one
116
+ side mirrored and the other not, components that combine both come apart —
117
+ `Slider`'s thumb moves to the mirrored end while its filled track stays.
118
+
108
119
  7. **`ToastProvider`** once near the root, inside the two providers above.
109
120
 
110
121
  ## Upgrading in an app
@@ -176,6 +187,18 @@ To drop it all: raise `BUILD_TARGETS`/`browserslist` past the affected
176
187
  browsers, then remove the two PostCSS plugins (and this package's
177
188
  `postcss-legacy-safari` export).
178
189
 
190
+ RTL is fine at this floor, with one thing to know. Panda's `_rtl` condition
191
+ emits `:where([dir=rtl], :dir(rtl))`, and `:dir()` is Safari 16.4. At the
192
+ pinned targets lightningcss rewrites that arm into a `:lang()` list, leaving
193
+ the `[dir=rtl]` arm — which is the one `SharedUIProvider` sets — intact. So
194
+ the legacy build matches on `dir` as intended, and additionally on
195
+ `lang="ar"` and friends, which modern builds don't. Everything else the
196
+ mirroring uses (logical longhands, `text-align: start`, custom properties in
197
+ `calc()`) passes through untouched; only logical _shorthands_ need the shim,
198
+ as before. `:where()` contributes no specificity, so the RTL rules win on
199
+ source order — de-layering preserves it, since it pads later layers rather
200
+ than reordering within one.
201
+
179
202
  ## The CSS-variable contract
180
203
 
181
204
  Panda emits every token as a CSS custom property with its default naming —
@@ -287,9 +310,9 @@ blank.
287
310
  Until this package's Crowdin project is wired up, the non-English catalogs are
288
311
  pre-Crowdin seeds — some strings AI-drafted, all of them for Crowdin to review
289
312
  — credited as follows. Translations of the same strings elsewhere in the
290
- micro:bit translation programme: ml-trainer and python-editor-v3, and
291
- MakeCode's editor strings, which are not in the pxt repo but can be fetched per
292
- locale from
313
+ micro:bit translation programme: ml-trainer, python-editor-v3 and classroom,
314
+ and MakeCode's editor strings, which are not in the pxt repo but can be fetched
315
+ per locale from
293
316
  `https://makecode.microbit.org/api/translations?lang=<locale>&filename=strings.json&approved=true`.
294
317
  The `ui.toast-status-*` words come from Spectrum 2's InlineAlert catalogs
295
318
  (`@react-spectrum/s2/intl/*.json`, keys `inlinealert.informative` / `notice` /
@@ -0,0 +1,62 @@
1
+ {
2
+ "ui.breadcrumb": {
3
+ "defaultMessage": "مسار التنقل",
4
+ "description": "Accessible label for the breadcrumb navigation trail (the WAI-ARIA APG's conventional name)"
5
+ },
6
+ "ui.close-action": {
7
+ "defaultMessage": "إغلاق",
8
+ "description": "Close button text or label"
9
+ },
10
+ "ui.combobox-listbox": {
11
+ "defaultMessage": "مقترحات",
12
+ "description": "Accessible label for a ComboBox's dropdown list of suggestions"
13
+ },
14
+ "ui.combobox-trigger": {
15
+ "defaultMessage": "عرض المقترحات",
16
+ "description": "Accessible label for the button that opens a ComboBox's suggestions dropdown"
17
+ },
18
+ "ui.loading": {
19
+ "defaultMessage": "جاري التحميل",
20
+ "description": "Announced by screen readers for a button showing a loading spinner"
21
+ },
22
+ "ui.new-tab-notice": {
23
+ "defaultMessage": "يفتح في علامة تبويب جديدة",
24
+ "description": "Visually hidden suffix on links that open a new tab, announced by screen readers"
25
+ },
26
+ "ui.numberfield-decrease": {
27
+ "defaultMessage": "خفض {fieldLabel}",
28
+ "description": "Accessible label for a number field's decrement stepper; fieldLabel is the field's label and may be empty"
29
+ },
30
+ "ui.numberfield-increase": {
31
+ "defaultMessage": "زيادة {fieldLabel}",
32
+ "description": "Accessible label for a number field's increment stepper; fieldLabel is the field's label and may be empty"
33
+ },
34
+ "ui.select-placeholder": {
35
+ "defaultMessage": "حدد عنصرًا",
36
+ "description": "Shown in a Select's trigger while nothing is chosen and the app supplies no placeholder"
37
+ },
38
+ "ui.select-row-action": {
39
+ "defaultMessage": "تحديد",
40
+ "description": "Accessible label for a row-selection checkbox in a list or table; screen readers read it together with the row's content"
41
+ },
42
+ "ui.toast-region": {
43
+ "defaultMessage": "{count, plural, zero {لا إشعارات} one {إشعار واحد} two {إشعاران} few {# إشعارات} many {# إشعارًا} other {# إشعار}}.",
44
+ "description": "Accessible label for the notifications area; count is the number of notifications showing"
45
+ },
46
+ "ui.toast-status-error": {
47
+ "defaultMessage": "خطأ",
48
+ "description": "Announced by screen readers before an error notification"
49
+ },
50
+ "ui.toast-status-info": {
51
+ "defaultMessage": "معلومات",
52
+ "description": "Announced by screen readers before an informational notification"
53
+ },
54
+ "ui.toast-status-success": {
55
+ "defaultMessage": "تم بنجاح",
56
+ "description": "Announced by screen readers before a success notification"
57
+ },
58
+ "ui.toast-status-warning": {
59
+ "defaultMessage": "تحذير",
60
+ "description": "Announced by screen readers before a warning notification"
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.1.0-alpha.30",
3
+ "version": "0.1.0-alpha.31",
4
4
  "description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,6 +65,19 @@ export const avatar = defineSlotRecipe({
65
65
  borderWidth: "0.2em",
66
66
  borderStyle: "solid",
67
67
  borderColor: "white",
68
+ // The corner variants pin the badge with logical insets but nudge it
69
+ // out over the edge with a transform, which is physical — so the
70
+ // outward direction is a variable the RTL rule negates. Without it a
71
+ // `start` badge would sit at the right corner and nudge left, i.e.
72
+ // inwards over the avatar.
73
+ // Fallbacks carry a unit: `calc(-1 * 0)` is a number, which is invalid
74
+ // where a length is wanted, and would drop the RTL transform whole.
75
+ transform:
76
+ "translate(var(--avatar-badge-x, 0px), var(--avatar-badge-y, 0px))",
77
+ _rtl: {
78
+ transform:
79
+ "translate(calc(-1 * var(--avatar-badge-x, 0px)), var(--avatar-badge-y, 0px))",
80
+ },
68
81
  },
69
82
  },
70
83
  variants: {
@@ -141,21 +154,33 @@ export const avatar = defineSlotRecipe({
141
154
  badge: {
142
155
  top: "0",
143
156
  insetStart: "0",
144
- transform: "translate(-25%, -25%)",
157
+ "--avatar-badge-x": "-25%",
158
+ "--avatar-badge-y": "-25%",
145
159
  },
146
160
  },
147
161
  "top-end": {
148
- badge: { top: "0", insetEnd: "0", transform: "translate(25%, -25%)" },
162
+ badge: {
163
+ top: "0",
164
+ insetEnd: "0",
165
+ "--avatar-badge-x": "25%",
166
+ "--avatar-badge-y": "-25%",
167
+ },
149
168
  },
150
169
  "bottom-start": {
151
170
  badge: {
152
171
  bottom: "0",
153
172
  insetStart: "0",
154
- transform: "translate(-25%, 25%)",
173
+ "--avatar-badge-x": "-25%",
174
+ "--avatar-badge-y": "25%",
155
175
  },
156
176
  },
157
177
  "bottom-end": {
158
- badge: { bottom: "0", insetEnd: "0", transform: "translate(25%, 25%)" },
178
+ badge: {
179
+ bottom: "0",
180
+ insetEnd: "0",
181
+ "--avatar-badge-x": "25%",
182
+ "--avatar-badge-y": "25%",
183
+ },
159
184
  },
160
185
  },
161
186
  },
@@ -34,6 +34,12 @@ export const breadcrumb = defineSlotRecipe({
34
34
  },
35
35
  separator: {
36
36
  mx: "2",
37
+ // A chevron separator points along the trail, so it has to turn round
38
+ // in RTL. Scoped to an svg because the default separator is the text
39
+ // "/", which would become "\": a caller passing a glyph owns its
40
+ // direction, one passing an icon gets it handled. Override via the
41
+ // separator slot's css for an icon that shouldn't turn.
42
+ _rtl: { "& svg": { transform: "scaleX(-1)" } },
37
43
  },
38
44
  },
39
45
  });
package/src/Button.tsx CHANGED
@@ -48,9 +48,9 @@ export interface ButtonProps
48
48
  css?: SystemStyleObject;
49
49
  className?: string;
50
50
  /** Icon rendered before the label. */
51
- leftIcon?: ReactNode;
51
+ startIcon?: ReactNode;
52
52
  /** Icon rendered after the label. */
53
- rightIcon?: ReactNode;
53
+ endIcon?: ReactNode;
54
54
  /**
55
55
  * Show a spinner in place of the label and disable interaction: the label
56
56
  * stays in the layout but invisible, so the button keeps its size, and the
@@ -74,8 +74,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
74
74
  tone,
75
75
  css: cssProp,
76
76
  className,
77
- leftIcon,
78
- rightIcon,
77
+ startIcon,
78
+ endIcon,
79
79
  isLoading,
80
80
  children,
81
81
  ...rest
@@ -84,12 +84,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
84
84
  ) {
85
85
  const label = (
86
86
  <>
87
- {leftIcon ? (
88
- <span className={buttonIcon({ side: "left" })}>{leftIcon}</span>
87
+ {startIcon ? (
88
+ <span className={buttonIcon({ side: "start" })}>{startIcon}</span>
89
89
  ) : null}
90
90
  {children}
91
- {rightIcon ? (
92
- <span className={buttonIcon({ side: "right" })}>{rightIcon}</span>
91
+ {endIcon ? (
92
+ <span className={buttonIcon({ side: "end" })}>{endIcon}</span>
93
93
  ) : null}
94
94
  </>
95
95
  );
@@ -76,22 +76,34 @@ export const drawer = defineSlotRecipe({
76
76
  },
77
77
  },
78
78
  variants: {
79
+ // Reading-order sides, so a drawer keeps its relationship to the content
80
+ // in an RTL locale rather than its screen position. The slide-out
81
+ // transforms are physical, so each needs mirroring under `dir=rtl` to
82
+ // travel towards its own edge.
79
83
  placement: {
80
- left: {
84
+ start: {
81
85
  content: {
82
- left: 0,
86
+ insetStart: 0,
83
87
  "&[data-entering]": { transform: "translateX(-100%)" },
84
88
  "&[data-exiting]": { transform: "translateX(-100%)" },
89
+ _rtl: {
90
+ "&[data-entering]": { transform: "translateX(100%)" },
91
+ "&[data-exiting]": { transform: "translateX(100%)" },
92
+ },
85
93
  },
86
94
  },
87
- right: {
95
+ end: {
88
96
  content: {
89
- right: 0,
97
+ insetEnd: 0,
90
98
  "&[data-entering]": { transform: "translateX(100%)" },
91
99
  "&[data-exiting]": { transform: "translateX(100%)" },
100
+ _rtl: {
101
+ "&[data-entering]": { transform: "translateX(-100%)" },
102
+ "&[data-exiting]": { transform: "translateX(-100%)" },
103
+ },
92
104
  },
93
105
  },
94
106
  },
95
107
  },
96
- defaultVariants: { placement: "left" },
108
+ defaultVariants: { placement: "start" },
97
109
  });
@@ -38,6 +38,6 @@ export const ExternalLink = ({ children, ...props }: ExternalLinkProps) => (
38
38
  {", "}
39
39
  <FormattedMessage {...uiMessage("ui.new-tab-notice")} />
40
40
  </VisuallyHidden>
41
- <Icon as={RiExternalLinkLine} aria-hidden css={{ ml: 1 }} />
41
+ <Icon as={RiExternalLinkLine} aria-hidden css={{ ms: 1 }} />
42
42
  </Link>
43
43
  );
@@ -7,7 +7,7 @@ import { forwardRef } from "react";
7
7
  import { Button, ButtonProps } from "./Button";
8
8
 
9
9
  export interface IconButtonProps
10
- extends Omit<ButtonProps, "leftIcon" | "rightIcon"> {
10
+ extends Omit<ButtonProps, "startIcon" | "endIcon"> {
11
11
  /** Icon-only buttons have no visible label, so this is required. */
12
12
  "aria-label": string;
13
13
  }
@@ -7,7 +7,8 @@ import { styled } from "styled-system/jsx";
7
7
 
8
8
  /**
9
9
  * InputGroup — relative wrapper for an Input with addon elements. Pad the
10
- * input (`pl`/`pr`) to make room for the elements.
10
+ * input (`ps`/`pe`) to make room for the elements; the elements sit on the
11
+ * reading-order sides, so physical padding lands opposite them in RTL.
11
12
  */
12
13
  export const InputGroup = styled("div", {
13
14
  base: { position: "relative", width: "100%", display: "flex" },
@@ -19,11 +20,11 @@ export const InputGroup = styled("div", {
19
20
  // size; pass the same `size` as the grouped Input.
20
21
 
21
22
  /** Element overlaying the start of an InputGroup. */
22
- export const InputLeftElement = styled("div", {
23
+ export const InputStartElement = styled("div", {
23
24
  base: {
24
25
  position: "absolute",
25
26
  top: 0,
26
- left: 0,
27
+ insetStart: 0,
27
28
  display: "flex",
28
29
  alignItems: "center",
29
30
  justifyContent: "center",
@@ -40,11 +41,11 @@ export const InputLeftElement = styled("div", {
40
41
  });
41
42
 
42
43
  /** Element overlaying the end of an InputGroup. */
43
- export const InputRightElement = styled("div", {
44
+ export const InputEndElement = styled("div", {
44
45
  base: {
45
46
  position: "absolute",
46
47
  top: 0,
47
- right: 0,
48
+ insetEnd: 0,
48
49
  display: "flex",
49
50
  alignItems: "center",
50
51
  justifyContent: "center",
@@ -20,9 +20,9 @@ export interface LinkButtonProps
20
20
  css?: SystemStyleObject;
21
21
  className?: string;
22
22
  /** Icon rendered before the label. */
23
- leftIcon?: ReactNode;
23
+ startIcon?: ReactNode;
24
24
  /** Icon rendered after the label. */
25
- rightIcon?: ReactNode;
25
+ endIcon?: ReactNode;
26
26
  children?: ReactNode;
27
27
  }
28
28
 
@@ -51,8 +51,8 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
51
51
  tone,
52
52
  css: cssProp,
53
53
  className,
54
- leftIcon,
55
- rightIcon,
54
+ startIcon,
55
+ endIcon,
56
56
  children,
57
57
  ...rest
58
58
  },
@@ -68,12 +68,12 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
68
68
  )}
69
69
  {...rest}
70
70
  >
71
- {leftIcon ? (
72
- <span className={buttonIcon({ side: "left" })}>{leftIcon}</span>
71
+ {startIcon ? (
72
+ <span className={buttonIcon({ side: "start" })}>{startIcon}</span>
73
73
  ) : null}
74
74
  {children}
75
- {rightIcon ? (
76
- <span className={buttonIcon({ side: "right" })}>{rightIcon}</span>
75
+ {endIcon ? (
76
+ <span className={buttonIcon({ side: "end" })}>{endIcon}</span>
77
77
  ) : null}
78
78
  </RACLink>
79
79
  );
@@ -33,6 +33,9 @@ const dialogBox = {
33
33
  };
34
34
 
35
35
  // The `full` variant also styles these slots; same symmetry requirement.
36
+ // The header's padding stays physical to pair with that variant's
37
+ // window-controls inset, which is a screen position rather than a
38
+ // reading-order one; the base `px` covers the start side either way.
36
39
  const dialogSlots = {
37
40
  header: { pl: "6" },
38
41
  body: { overflowY: "visible" },
@@ -54,7 +54,7 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
54
54
  _disabled: { cursor: "not-allowed" },
55
55
  },
56
56
  // Room for the chevron overlay (constant across sizes).
57
- hideChevron ? undefined : { paddingRight: "8" },
57
+ hideChevron ? undefined : { paddingEnd: "8" },
58
58
  cssProp,
59
59
  ),
60
60
  className,
@@ -83,7 +83,7 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
83
83
  aria-hidden
84
84
  className={css({
85
85
  position: "absolute",
86
- right: "2",
86
+ insetEnd: "2",
87
87
  top: "50%",
88
88
  transform: "translateY(-50%)",
89
89
  width: "5",
@@ -10,6 +10,7 @@ import {
10
10
  useEffect,
11
11
  useMemo,
12
12
  } from "react";
13
+ import { isRTL } from "react-aria";
13
14
  import { I18nProvider } from "react-aria-components";
14
15
  import { IntlContext } from "react-intl";
15
16
  import { racLocale } from "./rac-locale";
@@ -37,11 +38,17 @@ export interface SharedUIProviderProps {
37
38
  */
38
39
  locale?: string;
39
40
  /**
40
- * Keeps `<html lang>` in step with the locale, so assistive tech announces
41
- * the page in the app's language. On by default; set false where the app
42
- * doesn't own the document it's mounted in (an embedded widget), or when
43
- * mounting more than one provider per page. The static `lang="en"` in
44
- * index.html remains the pre-hydration default.
41
+ * Keeps `<html lang>` and `<html dir>` in step with the locale, so assistive
42
+ * tech announces the page in the app's language and the layout runs the
43
+ * right way round. On by default; set false where the app doesn't own the
44
+ * document it's mounted in (an embedded widget), or when mounting more than
45
+ * one provider per page. The static `lang="en"` in index.html remains the
46
+ * pre-hydration default.
47
+ *
48
+ * Turning it off in an RTL locale means the app must set `dir` itself above
49
+ * the components, or the UI half-mirrors: react-aria mirrors from the locale
50
+ * in JS whatever the DOM says, while logical properties and `_rtl` rules
51
+ * read `dir` (see the README's RTL note).
45
52
  *
46
53
  * Correct only when the IntlProvider locale is the language the app
47
54
  * actually renders in — an app falling back wholesale to its English
@@ -74,18 +81,23 @@ export const SharedUIProvider = ({
74
81
  // no IntlProvider: react-aria falls back to the browser locale, as before.
75
82
  const intlLocale = useContext(IntlContext)?.locale;
76
83
  const appLocale = locale ?? intlLocale;
84
+ // The tag react-aria gets, so the document's direction and the components'
85
+ // cannot disagree; also the only form isRTL can be handed safely, as it
86
+ // throws on a malformed tag.
87
+ const sanitizedLocale = racLocale(appLocale);
77
88
  useEffect(() => {
78
- if (setDocumentLang && appLocale) {
89
+ if (setDocumentLang && appLocale && sanitizedLocale) {
79
90
  document.documentElement.lang = appLocale;
91
+ document.documentElement.dir = isRTL(sanitizedLocale) ? "rtl" : "ltr";
80
92
  }
81
- }, [setDocumentLang, appLocale]);
93
+ }, [setDocumentLang, appLocale, sanitizedLocale]);
82
94
  const value = useMemo(
83
95
  () => ({ overlayCloseRegistrar }),
84
96
  [overlayCloseRegistrar],
85
97
  );
86
98
  return (
87
99
  <SharedUIContext.Provider value={value}>
88
- <I18nProvider locale={racLocale(appLocale)}>{children}</I18nProvider>
100
+ <I18nProvider locale={sanitizedLocale}>{children}</I18nProvider>
89
101
  </SharedUIContext.Provider>
90
102
  );
91
103
  };
@@ -60,7 +60,9 @@ export const slider = defineSlotRecipe({
60
60
  filledTrack: {
61
61
  position: "absolute",
62
62
  top: 0,
63
- left: 0,
63
+ // Logical, unlike the thumb: the fill grows from the track's start, so
64
+ // in RTL it fills from the right and the two still meet.
65
+ insetStart: 0,
64
66
  height: "100%",
65
67
  // Fill colour is set per call site via `filledTrackCss`.
66
68
  bg: "brand.500",
package/src/Slider.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { ReactNode } from "react";
7
+ import { useLocale } from "react-aria";
7
8
  import {
8
9
  Slider as RACSlider,
9
10
  SliderThumb,
@@ -40,8 +41,13 @@ export interface SliderProps {
40
41
  /**
41
42
  * Additional positioned overlays rendered inside the slider root
42
43
  * (always-visible value labels, threshold markers, ...). The root is
43
- * position: relative; position children absolutely, typically with a
44
- * percentage `left` for the track position.
44
+ * position: relative; position children absolutely.
45
+ *
46
+ * Anything sitting at a *value* along the track takes a percentage `left`,
47
+ * physical and mirrored by hand in RTL as the thumb is (see `offset`
48
+ * below), never `insetInlineStart`. Anything pinned to the track's *ends*
49
+ * — min/max labels and the like — is ordinary layout and should be
50
+ * logical, so `insetStart`/`insetEnd`.
45
51
  */
46
52
  children?: ReactNode;
47
53
  /**
@@ -76,7 +82,13 @@ export const Slider = ({
76
82
  onThumbFocusChange,
77
83
  }: SliderProps) => {
78
84
  const slots = slider();
85
+ const { direction } = useLocale();
79
86
  const percent = ((value - minValue) / (maxValue - minValue)) * 100;
87
+ // react-aria positions the thumb with a physical `left` that it mirrors
88
+ // itself in RTL (useSliderThumb), so anything tracking the thumb has to
89
+ // mirror the same way rather than going logical — `insetInlineStart` would
90
+ // put these on the opposite side of the track from the thumb.
91
+ const offset = direction === "rtl" ? 100 - percent : percent;
80
92
  return (
81
93
  <RACSlider
82
94
  value={value}
@@ -92,7 +104,7 @@ export const Slider = ({
92
104
  <div
93
105
  data-part="mark"
94
106
  className={cx(slots.mark, markCss ? css(markCss) : undefined)}
95
- style={{ left: `${percent}%` }}
107
+ style={{ left: `${offset}%` }}
96
108
  >
97
109
  {mark}
98
110
  </div>
@@ -144,7 +156,7 @@ export const Slider = ({
144
156
  borderTopColor: "gray.700",
145
157
  },
146
158
  })}
147
- style={{ left: `${percent}%` }}
159
+ style={{ left: `${offset}%` }}
148
160
  >
149
161
  {thumbTooltip}
150
162
  </div>
@@ -59,6 +59,19 @@ export const switchRecipe = defineSlotRecipe({
59
59
  transitionProperty: "transform",
60
60
  transitionDuration: "normal",
61
61
  borderRadius: "inherit",
62
+ // The thumb rests at the track's start and slides to its end, so the
63
+ // travel has to reverse in RTL — a transform is physical however the
64
+ // rest of the switch is laid out. Distance is per size, below.
65
+ // Fallbacks carry a unit: `calc(-1 * 0)` is a number, which is invalid
66
+ // where a length is wanted, and would drop the RTL transform whole.
67
+ "&[data-selected]": {
68
+ transform: "translateX(var(--switch-travel, 0px))",
69
+ },
70
+ _rtl: {
71
+ "&[data-selected]": {
72
+ transform: "translateX(calc(-1 * var(--switch-travel, 0px)))",
73
+ },
74
+ },
62
75
  _forcedColors: {
63
76
  bg: "ButtonText",
64
77
  "&[data-selected]": { bg: "SelectedItemText" },
@@ -89,32 +102,20 @@ export const switchRecipe = defineSlotRecipe({
89
102
  label: { marginStart: "0", marginEnd: "3" },
90
103
  },
91
104
  },
92
- // Track width x height per size; the selected-thumb translate is the
93
- // difference between them.
105
+ // Track width x height per size; the thumb's travel is the difference
106
+ // between them.
94
107
  size: {
95
108
  sm: {
96
109
  track: { width: "1.375rem", height: "3" },
97
- thumb: {
98
- width: "3",
99
- height: "3",
100
- "&[data-selected]": { transform: "translateX(0.625rem)" },
101
- },
110
+ thumb: { width: "3", height: "3", "--switch-travel": "0.625rem" },
102
111
  },
103
112
  md: {
104
113
  track: { width: "1.875rem", height: "4" },
105
- thumb: {
106
- width: "4",
107
- height: "4",
108
- "&[data-selected]": { transform: "translateX(0.875rem)" },
109
- },
114
+ thumb: { width: "4", height: "4", "--switch-travel": "0.875rem" },
110
115
  },
111
116
  lg: {
112
117
  track: { width: "2.875rem", height: "6" },
113
- thumb: {
114
- width: "6",
115
- height: "6",
116
- "&[data-selected]": { transform: "translateX(1.375rem)" },
117
- },
118
+ thumb: { width: "6", height: "6", "--switch-travel": "1.375rem" },
118
119
  },
119
120
  },
120
121
  },
@@ -59,9 +59,9 @@ export const toast = defineSlotRecipe({
59
59
  p: "4",
60
60
  // Leave room for the absolutely-positioned close button plus breathing
61
61
  // space so its hover area never overlaps the title text (24px button at
62
- // 4px inset spans ~4-28px from the right; content stops at 40px for a
63
- // ~12px gap).
64
- paddingRight: "10",
62
+ // 4px inset spans ~4-28px from the end edge; content stops at 40px for
63
+ // a ~12px gap). Logical, like the button's own inset.
64
+ paddingEnd: "10",
65
65
  borderRadius: "md",
66
66
  boxShadow: "lg",
67
67
  color: "white",
package/src/Tooltip.tsx CHANGED
@@ -19,15 +19,32 @@ export interface TooltipProps {
19
19
  label: ReactNode;
20
20
  /** A single focusable trigger element (e.g. a Button). */
21
21
  children: ReactElement;
22
+ /**
23
+ * Which side of the trigger the tooltip prefers. A preference, not a
24
+ * promise: react-aria flips it at runtime when the tooltip would leave the
25
+ * viewport, so pick the side that reads right rather than one that dodges
26
+ * an edge.
27
+ *
28
+ * `top`/`bottom` are direction-neutral and suit most tooltips.
29
+ * `start`/`end` follow reading order and mirror in RTL. `left`/`right` are
30
+ * screen sides and never mirror — reach for them only when the trigger is
31
+ * pinned to a physical edge.
32
+ */
22
33
  placement?:
23
34
  | "top"
24
35
  | "bottom"
36
+ | "start"
37
+ | "end"
25
38
  | "left"
26
39
  | "right"
27
40
  | "top start"
28
41
  | "top end"
29
42
  | "bottom start"
30
43
  | "bottom end"
44
+ | "start top"
45
+ | "start bottom"
46
+ | "end top"
47
+ | "end bottom"
31
48
  | "left top"
32
49
  | "left bottom"
33
50
  | "right top"
@@ -15,8 +15,8 @@ export const buttonIcon = cva({
15
15
  },
16
16
  variants: {
17
17
  side: {
18
- left: { marginEnd: "2" },
19
- right: { marginStart: "2" },
18
+ start: { marginEnd: "2" },
19
+ end: { marginStart: "2" },
20
20
  },
21
21
  },
22
22
  });