diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb7b0cd..0cddf6b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,17 +27,6 @@ oder auf den Token aus `whoami` um. Beim Deploy werden alle Nutzenden einmal
abgemeldet (per Silent-SSO meist sofort wieder angemeldet). Bereits
übernommen: ausleihbar (virtUOS/ausleihbar#105), kalkulierbar.
-### @basicbar/ui (→ wird `ui/v0.7.2`)
-
-**Sicherheits-Fix `stripHtml` / `isEmptyHtml`** (#20): Beide parsten über
-`innerHTML` auf einem Element des Live-Dokuments — dort lädt der Browser
-Bilder und führt Inline-Handler aus, `` lief also, auch
-ohne Einhängen. Jetzt wird in einem inerten `DOMParser`-Dokument geparst
-(nichts wird ausgeführt oder geladen); `isEmptyHtml` erkennt Bilder über den
-geparsten Body statt per Regex. Verhalten sonst unverändert.
-
-Migration: Tarball auf `ui/v0.7.2`. Keine Code-Änderung nötig.
-
### basicbar-integrations (→ wird `integrations/v0.3.0`)
**`CapabilitiesView` entfernt** (Framework-Review: kein Tool hat
@@ -69,6 +58,73 @@ Migration.
- Doku-Drift zum Capabilities-Endpunkt in CLAUDE.md und EXTRAKTIONSPLAN
bereinigt.
+## ui/v0.8.0 — 2026-10-10
+
+### @basicbar/ui
+
+Anlass: Auf Smartphones verdeckte die schwebende Übersetzungsleiste
+(„DE · EN · Alle Felder übersetzen“) Formularinhalte (abstimmbar #179);
+abstimmbar umging das mit einem verschachtelten Provider und zwei
+CSS-Overrides auf Paket-Markup. Das wird jetzt Paket-Funktion.
+
+- **Verschiebbare Übersetzungsleiste:** Die schwebende Leiste von
+ `TranslationFormProvider` hat links einen Griff (`GripVertical`,
+ Name/Tooltip `"Move translation controls"`). Ziehen mit Maus oder Touch
+ verschiebt die ganze Leiste per CSS-`translate` (setzt sich mit
+ Tailwind-Transform-Utilities in `controlsClassName` zusammen) – die Position aus
+ `controlsClassName` bleibt der Anker. Die Leiste bleibt vollständig im
+ Viewport (auch nach Fenstergrößenänderung); der Versatz wird pro Browser in
+ `localStorage` (`basicbar_translation_controls_offset`) gemerkt.
+ Tastatur: Pfeiltasten 16 px, mit Umschalt 64 px; `Pos1`/`Esc` oder
+ Doppelklick auf den Griff setzen zurück. Ein bloßer Klick tut nichts. Der
+ Griff erscheint nur, solange die Leiste `fixed`/`absolute` positioniert ist.
+ Abschalten: `movable={false}`.
+- **Andockplatz `TranslationControlsSlot`** (`{ className?, media? }`): ein
+ Platzhalter, den eine Seite dorthin setzt, wo die Leiste im Fluss stehen
+ soll (z. B. oben im Formular). Solange ein Slot gemountet ist und seine
+ Media Query passt (Default `"(max-width: 767px)"`, exportiert als
+ `TRANSLATION_SLOT_MEDIA`, = unter Tailwind `md`), rendert der nächste
+ Provider die Leiste per Portal in den Slot – ohne Fixed-Positionierung,
+ Griff und Versatz. Sonst schwebt sie wie bisher; der leere Slot ist per
+ `:empty` ausgeblendet. Bei mehreren Slots gewinnt der zuletzt gemountete.
+ Neue Provider-Prop `slotControlsClassName` (Default `flex justify-end`,
+ also rechtsbündig) für die angedockte Variante; `controlsClassName` gilt nur
+ für die schwebende.
+- **Rich-Text-Toolbar bricht um** (`flex-wrap`) statt horizontal zu scrollen
+ – auf schmalen Screens waren die versteckten Buttons nicht zu erahnen; wo
+ die Zeile passt, ändert sich nichts.
+- **Sprach-Tabs von `TranslatableField`:** Trefferfläche per `::after` auf
+ ≥ 24 px Höhe (WCAG 2.5.8), Optik unverändert.
+
+Migration:
+
+1. Tarball auf `ui/v0.8.0`, Vite-Dep-Cache leeren.
+2. Neuen Übersetzungs-Key `"Move translation controls"` in den deutschen
+ Katalog aufnehmen (Vorschlag: `"Übersetzungsleiste verschieben"`).
+3. Optional: `` oben in Formulare mit
+ übersetzbaren Feldern setzen (innerhalb des `TranslationFormProvider`), damit
+ die Leiste auf Smartphones im Fluss steht statt Inhalte zu verdecken. Wer
+ die Leiste in Tests über eine Hook-Klasse findet, gibt sie auch in
+ `slotControlsClassName` mit (z. B. `"my-hook flex justify-end"`).
+ Workarounds wie ein verschachtelter Provider pro Formular entfallen.
+4. Lokale Overrides für Toolbar-Umbruch oder Tab-Trefferfläche (falls
+ vorhanden) entfernen.
+5. Die schwebende Leiste hat jetzt einen Griff – wer das nicht will:
+ `movable={false}`.
+
+## ui/v0.7.2 — 2026-10-07
+
+### @basicbar/ui
+
+**Sicherheits-Fix `stripHtml` / `isEmptyHtml`** (#20): Beide parsten über
+`innerHTML` auf einem Element des Live-Dokuments — dort lädt der Browser
+Bilder und führt Inline-Handler aus, `` lief also, auch
+ohne Einhängen. Jetzt wird in einem inerten `DOMParser`-Dokument geparst
+(nichts wird ausgeführt oder geladen); `isEmptyHtml` erkennt Bilder über den
+geparsten Body statt per Regex. Verhalten sonst unverändert.
+
+Migration: Tarball auf `ui/v0.7.2`. Keine Code-Änderung nötig.
+
## ui/v0.7.1 — 2026-10-02
### @basicbar/ui
diff --git a/packages/ui/README.md b/packages/ui/README.md
index 7336b69..dd11bca 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -28,8 +28,9 @@ Tools identisch aussehen, übergeben sie dieselbe Ramp.
``-Sync nach WCAG 3.1.1). Die Kataloge bleiben im Tool.
- **`contentLang`** — `localizedText`/`localizedMap`/`setLocalizedLang` & Co.
für `{ lang: text }`-Inhalte (Spiegel des Backend-`resolve_translated_text`).
-- **`TranslatableField` / `TranslationFormProvider`** — Sprach-Tabs pro
- Feld plus „alle Felder übersetzen“ (siehe `TranslatableField.tsx`).
+- **`TranslatableField` / `TranslationFormProvider` / `TranslationControlsSlot`**
+ — Sprach-Tabs pro Feld plus verschiebbare Leiste „alle Felder übersetzen“,
+ optional im Formular angedockt (siehe `TranslatableField.tsx`).
- **`RichText` / `stripHtml` / `isEmptyHtml`** — Rendern und Prüfen des
gespeicherten Rich-HTML; **`RichTextEditor`** (TipTap) als **eigener Entry**
`@basicbar/ui/rich-text-editor`, damit TipTap/ProseMirror nur in Bundles
@@ -225,6 +226,35 @@ unteren Rand auf schmalen Screens), setzt das Tool die Position per
```
+**Verschieben:** Die schwebende Leiste hat links einen Griff (Ziehen mit
+Maus/Touch, Pfeiltasten 16 px bzw. mit Umschalt 64 px; `Pos1`/`Esc` oder
+Doppelklick setzen zurück). Der Versatz liegt als CSS-`translate` über der
+Position aus `controlsClassName` und wird pro Browser in `localStorage`
+(`basicbar_translation_controls_offset`) gemerkt. Abschalten mit
+`movable={false}`.
+
+**Andocken im Formular:** Wo die Leiste Inhalte verdecken würde (v. a. auf
+Smartphones), setzt die Seite einen Platzhalter in den Fluss. Solange er
+gemountet ist und `media` passt (Default `"(max-width: 767px)"`), rendert der
+Provider die Leiste dort hinein – ohne Fixed-Positionierung und Griff; sonst
+schwebt sie wie gewohnt. Bei mehreren Slots gewinnt der zuletzt gemountete –
+auch wenn dessen `media` gerade nicht passt und ein älterer Slot passen würde
+(dann schwebt die Leiste).
+
+```tsx
+
+ …
+
+
+```
+
## Übersetzungs-Keys
Alle Strings des Pakets laufen über `t()` mit Englisch als Key (siehe
@@ -244,7 +274,8 @@ seinem Katalog bereit. Fehlende Keys fallen auf den englischen Text zurück.
`"A value in {{language}} is required."`.
- TranslationFormProvider: `"Show all fields in one language"`,
`"Show all fields in {{language}}"`, `"Translate all fields"`,
- `"Translating…"`, `"Some fields could not be translated."`.
+ `"Translating…"`, `"Some fields could not be translated."`,
+ `"Move translation controls"`.
- Preferences: `"Preferences"`, `"Language"`, `"Appearance"`, `"Auto"`,
`"(follows your system)"`, `"Light"`, `"Dark"`.
diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json
index 43a6b71..7163739 100644
--- a/packages/ui/package-lock.json
+++ b/packages/ui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@basicbar/ui",
- "version": "0.7.2",
+ "version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@basicbar/ui",
- "version": "0.7.2",
+ "version": "0.8.0",
"license": "Apache-2.0",
"dependencies": {
"@tiptap/extension-bold": "^3.27.2",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 88e2124..b7c1f89 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@basicbar/ui",
- "version": "0.7.2",
+ "version": "0.8.0",
"description": "Design-System-Basis der virtUOS -bar-Tools: Tailwind-Preset, Basis-Styles, Theme (Dark Mode), i18n-Bootstrap, contentLang, TranslatableField, Preferences, RichText; RichTextEditor (TipTap) als eigener Entry",
"license": "Apache-2.0",
"author": "Universität Osnabrück (virtUOS)",
diff --git a/packages/ui/src/RichTextEditor.tsx b/packages/ui/src/RichTextEditor.tsx
index d9b73cf..c5a7e3e 100644
--- a/packages/ui/src/RichTextEditor.tsx
+++ b/packages/ui/src/RichTextEditor.tsx
@@ -318,7 +318,9 @@ export function RichTextEditor({
}`}
>
{editable && (
-
+ // Wraps onto further rows on narrow screens instead of a horizontal
+ // scroller whose hidden buttons had no affordance.
+
setActive(lang.code)}
- className={`flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium uppercase transition-colors ${
+ className={`relative flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] after:absolute after:inset-x-0 after:-inset-y-1 after:content-[''] font-medium uppercase transition-colors ${
isActive
? "bg-brand-400 text-slate-900"
: "bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
diff --git a/packages/ui/src/TranslationForm.tsx b/packages/ui/src/TranslationForm.tsx
index db127c6..33f3d2c 100644
--- a/packages/ui/src/TranslationForm.tsx
+++ b/packages/ui/src/TranslationForm.tsx
@@ -6,17 +6,30 @@
* the empty translations across the open editor in one pass and switches the
* fields to the translated language for review. Never overwrites an existing
* translation. The actual HTTP call is injected by the tool (`translate`
- * prop), so the package stays free of any API client. */
+ * prop), so the package stays free of any API client.
+ *
+ * The floating controls carry a drag handle (pointer or arrow keys) so they
+ * can be moved out of the way; the offset is persisted per browser. A page can
+ * instead dock them into its flow with `` — on
+ * narrow screens by default, where a floating pill would cover the form. */
import {
createContext,
useCallback,
useContext,
+ useEffect,
+ useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
-import type { MutableRefObject, ReactNode } from "react";
-import { Languages } from "lucide-react";
+import type {
+ KeyboardEvent as ReactKeyboardEvent,
+ MutableRefObject,
+ PointerEvent as ReactPointerEvent,
+ ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+import { GripVertical, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SUPPORTED_LANGUAGES } from "./i18n";
import { getDefaultContentLang, isTranslationEnabled } from "./contentLang";
@@ -65,6 +78,120 @@ export function useTranslationForm(): TranslationFormState | undefined {
* so the editor bounds it here to avoid unbounded upstream provider calls. */
export const MAX_TRANSLATE_LENGTH = 5000;
+// ---------------------------------------------------------------------------
+// Docking slot
+
+/** Default `media` of `TranslationControlsSlot`: below Tailwind's `md`. */
+export const TRANSLATION_SLOT_MEDIA = "(max-width: 767px)";
+
+interface SlotRecord {
+ id: number;
+ el: HTMLElement;
+ matches: boolean;
+}
+
+interface SlotRegistry {
+ upsert: (slot: SlotRecord) => void;
+ remove: (id: number) => void;
+}
+
+const SlotContext = createContext(undefined);
+let nextSlotId = 0;
+
+/** Placeholder where a page wants the translation controls to sit in the
+ * flow (e.g. at the top of a form). While it is mounted and `media` matches
+ * (default: below Tailwind `md`), the nearest `TranslationFormProvider`
+ * renders its controls in here instead of floating over the page. Empty —
+ * and hidden via `:empty` — otherwise. With several mounted slots, the most
+ * recently mounted one wins. */
+export function TranslationControlsSlot({
+ className = "",
+ media = TRANSLATION_SLOT_MEDIA,
+}: {
+ className?: string;
+ /** Media query under which the controls dock here. */
+ media?: string;
+}) {
+ const registry = useContext(SlotContext);
+ const ref = useRef(null);
+ const idRef = useRef(null);
+ if (idRef.current === null) idRef.current = ++nextSlotId;
+
+ // Unregister only on unmount (or provider change): a changed `media` must
+ // update the slot in place, not re-append it as "most recently mounted".
+ useLayoutEffect(() => {
+ if (!registry) return;
+ const id = idRef.current!;
+ return () => registry.remove(id);
+ }, [registry]);
+
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!registry || !el) return;
+ const id = idRef.current!;
+ const mql =
+ typeof window.matchMedia === "function" ? window.matchMedia(media) : null;
+ const report = () => registry.upsert({ id, el, matches: !!mql?.matches });
+ report();
+ mql?.addEventListener("change", report);
+ return () => mql?.removeEventListener("change", report);
+ }, [registry, media]);
+
+ return ;
+}
+
+// ---------------------------------------------------------------------------
+// Movable floating controls
+
+type Offset = { x: number; y: number };
+const ZERO: Offset = { x: 0, y: 0 };
+const OFFSET_KEY = "basicbar_translation_controls_offset";
+/** Gap kept between the dragged controls and the viewport edge. */
+const EDGE = 4;
+
+function loadOffset(): Offset {
+ try {
+ const raw = localStorage.getItem(OFFSET_KEY);
+ if (!raw) return ZERO;
+ const v = JSON.parse(raw) as Partial;
+ if (typeof v?.x === "number" && typeof v?.y === "number"
+ && Number.isFinite(v.x) && Number.isFinite(v.y)) {
+ return { x: v.x, y: v.y };
+ }
+ } catch {
+ // storage blocked or garbage — fall back to the anchor position
+ }
+ return ZERO;
+}
+
+function saveOffset(o: Offset) {
+ try {
+ if (o.x === 0 && o.y === 0) localStorage.removeItem(OFFSET_KEY);
+ else localStorage.setItem(OFFSET_KEY, JSON.stringify(o));
+ } catch {
+ // not persisted — the move still applies for this page view
+ }
+}
+
+/** Clamp `next` so the element (whose untranslated box is `base`) stays
+ * fully inside the viewport. */
+function clampOffset(next: Offset, base: DOMRect): Offset {
+ const vw = document.documentElement.clientWidth || window.innerWidth;
+ const vh = document.documentElement.clientHeight || window.innerHeight;
+ const clamp = (v: number, lo: number, hi: number) =>
+ hi < lo ? lo : Math.min(Math.max(v, lo), hi);
+ return {
+ x: Math.round(clamp(next.x, EDGE - base.left, vw - EDGE - base.right)),
+ y: Math.round(clamp(next.y, EDGE - base.top, vh - EDGE - base.bottom)),
+ };
+}
+
+/** The element's box without the current translate offset. */
+function baseRect(el: HTMLElement, current: Offset): DOMRect {
+ const r = el.getBoundingClientRect();
+ return new DOMRect(r.left - current.x, r.top - current.y, r.width, r.height);
+}
+
/** Wraps the app so every mounted TranslatableField registers itself; renders a
* floating "translate all fields" button that fills empty translations across
* the open editor and switches the fields to the translated language. */
@@ -72,14 +199,23 @@ export function TranslationFormProvider({
children,
translate,
controlsClassName = "fixed bottom-6 right-6 z-40",
+ slotControlsClassName = "flex justify-end",
+ movable = true,
}: {
children: ReactNode;
translate: TranslateFn;
/** Positioning of the floating controls (replaces the default
* `fixed bottom-6 right-6 z-40`) — e.g. to lift them above a tool's own
* sticky action bar on narrow screens: `fixed bottom-6 right-6 z-40
- * max-md:bottom-[5.5rem]`. The pill's own layout is not affected. */
+ * max-md:bottom-[5.5rem]`. The pill's own layout is not affected. This is
+ * the anchor; a drag offset is applied on top of it. */
controlsClassName?: string;
+ /** Classes of the controls when docked into a `TranslationControlsSlot`
+ * (replaces the default `flex justify-end`, i.e. right-aligned). */
+ slotControlsClassName?: string;
+ /** Show the drag handle on the floating controls (default `true`). It only
+ * appears while the controls are `position: fixed`/`absolute`. */
+ movable?: boolean;
}) {
const { t } = useTranslation();
const registry = useRef(
@@ -170,59 +306,245 @@ export function TranslationFormProvider({
[register, unregister, forced, translate],
);
+ // --- docking slots ------------------------------------------------------
+ const [slots, setSlots] = useState([]);
+ const slotRegistry = useMemo(
+ () => ({
+ upsert: (slot) =>
+ setSlots((prev) => {
+ const i = prev.findIndex((s) => s.id === slot.id);
+ if (i === -1) return [...prev, slot];
+ const old = prev[i];
+ if (old.el === slot.el && old.matches === slot.matches) return prev;
+ const copy = prev.slice();
+ copy[i] = slot;
+ return copy;
+ }),
+ remove: (id) => setSlots((prev) => prev.filter((s) => s.id !== id)),
+ }),
+ [],
+ );
+ // Most recently mounted slot wins (mount order = registration order).
+ const latestSlot = slots.length ? slots[slots.length - 1] : undefined;
+ const dockTarget = latestSlot?.matches ? latestSlot.el : null;
+
+ // --- movable floating controls -----------------------------------------
+ const floatRef = useRef(null);
+ const [offset, setOffset] = useState(loadOffset);
+ const offsetRef = useRef(offset);
+ offsetRef.current = offset;
+ // The handle only makes sense while the tool positions the controls out of
+ // the flow (a translate on an in-flow element would just overlap content).
+ const [positioned, setPositioned] = useState(false);
+ const floating = showControls && !dockTarget;
+ const canMove = movable && floating && positioned;
+
+ useLayoutEffect(() => {
+ if (!floating) return;
+ const sync = () => {
+ const el = floatRef.current;
+ if (!el) return;
+ const pos = window.getComputedStyle(el).position;
+ const isOut = pos === "fixed" || pos === "absolute";
+ setPositioned(isOut);
+ // Until `positioned` is set no offset is applied yet, so the box below
+ // would be off by it; this effect re-runs once it flips.
+ if (!isOut || !movable || !positioned) return;
+ // Re-clamp (after a resize, a size change of the pill itself — error
+ // message, "Translating…" — or with an offset stored on a wider
+ // screen) without persisting, so a wider window restores the spot.
+ const cur = offsetRef.current;
+ const next = clampOffset(cur, baseRect(el, cur));
+ if (next.x !== cur.x || next.y !== cur.y) {
+ offsetRef.current = next;
+ setOffset(next);
+ }
+ };
+ sync();
+ window.addEventListener("resize", sync);
+ const ro =
+ typeof ResizeObserver !== "undefined" && floatRef.current
+ ? new ResizeObserver(() => sync())
+ : null;
+ if (ro && floatRef.current) ro.observe(floatRef.current);
+ return () => {
+ window.removeEventListener("resize", sync);
+ ro?.disconnect();
+ };
+ }, [floating, movable, positioned, controlsClassName]);
+
+ const drag = useRef<{
+ pointerId: number;
+ startX: number;
+ startY: number;
+ start: Offset;
+ base: DOMRect;
+ } | null>(null);
+
+ const moveTo = (next: Offset, persist: boolean) => {
+ const el = floatRef.current;
+ if (!el) return;
+ const clamped = clampOffset(next, baseRect(el, offsetRef.current));
+ offsetRef.current = clamped;
+ setOffset(clamped);
+ if (persist) saveOffset(clamped);
+ };
+ const resetOffset = () => {
+ offsetRef.current = ZERO;
+ setOffset(ZERO);
+ saveOffset(ZERO);
+ };
+
+ const onHandlePointerDown = (e: ReactPointerEvent) => {
+ if (e.button !== 0 || !floatRef.current) return;
+ e.preventDefault(); // no text selection / focus scroll while dragging
+ e.currentTarget.setPointerCapture(e.pointerId);
+ drag.current = {
+ pointerId: e.pointerId,
+ startX: e.clientX,
+ startY: e.clientY,
+ start: offsetRef.current,
+ base: baseRect(floatRef.current, offsetRef.current),
+ };
+ };
+ const onHandlePointerMove = (e: ReactPointerEvent) => {
+ const d = drag.current;
+ if (!d || d.pointerId !== e.pointerId) return;
+ const next = clampOffset(
+ { x: d.start.x + e.clientX - d.startX, y: d.start.y + e.clientY - d.startY },
+ d.base,
+ );
+ offsetRef.current = next;
+ setOffset(next);
+ };
+ const endDrag = (e: ReactPointerEvent) => {
+ const d = drag.current;
+ if (!d || d.pointerId !== e.pointerId) return;
+ drag.current = null;
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ const o = offsetRef.current;
+ if (o.x !== d.start.x || o.y !== d.start.y) saveOffset(o);
+ };
+ const onHandleKeyDown = (e: ReactKeyboardEvent) => {
+ const step = e.shiftKey ? 64 : 16;
+ const o = offsetRef.current;
+ const moves: Record = {
+ ArrowLeft: { x: o.x - step, y: o.y },
+ ArrowRight: { x: o.x + step, y: o.y },
+ ArrowUp: { x: o.x, y: o.y - step },
+ ArrowDown: { x: o.x, y: o.y + step },
+ };
+ if (e.key in moves) {
+ e.preventDefault();
+ moveTo(moves[e.key], true);
+ } else if (e.key === "Home" || e.key === "Escape") {
+ e.preventDefault();
+ // Don't let the reset also close a surrounding dialog.
+ e.stopPropagation();
+ resetOffset();
+ }
+ };
+
+ const renderControls = (withHandle: boolean) => (
+ <>
+ {error && (
+
+ {error}
+
+ )}
+ {/* One combined control: the per-language switch and, if machine
+ translation is available, the "translate all" action share a
+ single pill so the corner stays uncluttered. */}
+
- {error && (
-
- {error}
-
- )}
- {/* One combined control: the per-language switch and, if machine
- translation is available, the "translate all" action share a
- single pill so the corner stays uncluttered. */}
-