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 + + … +
+ {/* optional: className, media */} + + +
+``` + ## Ü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. */} +
+ {withHandle && ( + + )} +
+
+ {showTranslate && ( + <> +
+ + ); + return ( - {children} - {showControls && ( -
- {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. */} -
-
-
- {showTranslate && ( - <> -
- )} + ))} ); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0c38973..a49b0b0 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,6 +9,8 @@ export type { Language } from "./i18n"; export { MAX_TRANSLATE_LENGTH, + TRANSLATION_SLOT_MEDIA, + TranslationControlsSlot, TranslationFormProvider, useTranslationForm, } from "./TranslationForm";