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
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,61 @@ Changelog ist die Upgrade-Anleitung für die Tools.

## [Unreleased]

### @basicbar/ui (→ wird `ui/v0.7.0`)

**TipTap raus aus den Bundles, die keinen Editor rendern** (Framework-Review):
`RichTextEditor` liegt jetzt in einem eigenen Entry
`@basicbar/ui/rich-text-editor`; das Paket ist `sideEffects: false` und wird
mit Code-Splitting gebaut. Der Haupt-Entry `@basicbar/ui` importiert kein
`@tiptap/*` mehr. Nagelprobe am gerenderten Template (kein Editor): Bundle
575 kB → 280 kB (gzip 185 → 91 kB). Die `@tiptap/*`-Pakete bleiben bewusst
`dependencies` (nicht optionale Peers): sie kosten nur Installationszeit, und
die eine Pin-Stelle für alle Tools bleibt erhalten.

Außerdem:

- `stripHtml(html)` und `isEmptyHtml(html)` sind exportiert (abstimmbar
hatte vier Kopien). `isEmptyHtml` zählt ein Editor-Leer-`<p></p>` als
leer, einen reinen Bild-Inhalt aber als gefüllt; `TranslatableField` mit
`format="html"` nutzt das für seine Ausgefüllt-Punkte (vorher galt ein
Bild ohne Text als „nicht übersetzt“).
- `richTextClass` (der Prosa-Default von `RichText`) ist exportiert, damit
Aufrufer ihn ergänzen können statt zu kopieren; `className` ersetzt
weiterhin.
- `RichTextEditor` hat `editable?: boolean` (Default `true`): schreibgeschützt
ohne Toolbar und Cursor, Drop/Paste ignoriert; Umschalten zur Laufzeit via
`setEditable`.
- `TranslationFormProvider` hat `controlsClassName?` für die Position der
Floating-Controls (Default `fixed bottom-6 right-6 z-40`) — ersetzt den
CSS-Override auf die Utility-Klassen in abstimmbar.
- `renderInput` von `TranslatableField` bekommt `onBlur` durchgereicht.
- Performance: `TranslatableField` berechnet die Sprachreihenfolge und den
Ausgefüllt-Status pro Render nur noch einmal (vorher pro Tab/Lookup, bei
HTML jeweils mit DOM-Parse); `targets` im Provider memoisiert.
- A11y: die Sprach-Tabs tragen den Status („translated“ …) zusätzlich als
`sr-only`-Text (der Punkt war farb-only, WCAG 1.4.1); `PreferencesMenu`
fokussiert beim Öffnen die erste Zeile und unterstützt Pfeiltasten,
Home/End (WAI-ARIA-Menü-Pattern), `role="menu"` hat einen Namen.
- README: Abschnitt „Übersetzungs-Keys“ mit allen Keys des Pakets.

**Migration:**

1. `package.json`: Tarball auf `ui/v0.7.0`.
2. Jeden `RichTextEditor`-Import auf den neuen Pfad umstellen:
`import { RichTextEditor } from "@basicbar/ui/rich-text-editor";`
(`RichText`, `RichTextEditorProps`-Typ: `RichText` bleibt in
`@basicbar/ui`, der Props-Typ kommt aus dem Editor-Entry). Betroffen:
ausleihbar (`AdminWelcomePage`, `AdminPagesPage`, `AdminPoolsPage`),
abstimmbar (`components/TranslatableField.tsx`). Tools ohne Editor
(erkennbar, modulierbar) ändern nichts.
3. Optional, empfohlen: lokale `stripHtml`-Kopien durch den Export ersetzen
(abstimmbar: `RoomsPage`, `ResultsPage`, `SetPage`, `QuestionPage` — dort
`!stripHtml(x) && !/<img/.test(x)` → `isEmptyHtml(x)`); abstimmbars
`.fixed.bottom-6.right-6.z-40`-Override in `index.css` durch
`controlsClassName="fixed bottom-6 right-6 z-40 max-md:bottom-[5.5rem]"` am
Provider ersetzen.
4. Katalog: keine neuen Keys.

### basicbar-lti (→ wird `lti/v0.1.4`)

**Sicherheit: Reflected XSS im LTI-Login behoben.** Die 400er-Antworten von
Expand Down
85 changes: 74 additions & 11 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Tools identisch aussehen, übergeben sie dieselbe Ramp.
`<html lang>`-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`).
- **`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
landet, die den Editor wirklich rendern (siehe „Rich text“).

## Einbinden

Expand Down Expand Up @@ -68,6 +74,19 @@ Link, Überschriften H2/H3, optional Bilder) und die passende Renderkomponente
für das gespeicherte HTML. Aus AbstimmBAR in die Basis verschoben
(modulierbar#5), damit alle -bar-Tools eine Implementierung teilen.

**Import-Pfade:** der Editor kommt aus dem eigenen Entry
`@basicbar/ui/rich-text-editor`; `RichText`, `stripHtml` und `isEmptyHtml`
aus `@basicbar/ui`. Grund: TipTap + ProseMirror sind ~300 kB (≈95 kB gzip)
und sollen nur in Bundles landen, die den Editor rendern — ein Tool, das nur
`RichText` anzeigt oder gar kein Rich-Text hat, zahlt sonst mit. Wer den
Editor nur auf Admin-Seiten braucht, lädt ihn zusätzlich per `React.lazy`
nach, dann liegt er in einem eigenen Chunk.

```tsx
import { RichText, stripHtml, isEmptyHtml } from "@basicbar/ui";
import { RichTextEditor } from "@basicbar/ui/rich-text-editor";
```

**Sanitizing-Vertrag:** die Komponenten selbst sanitizen nichts — die
Sicherheitsgrenze ist das Backend. Jedes Rich-Text-Feld muss beim Speichern
(und beim Import) durch einen Allowlist-Sanitizer laufen, z. B.
Expand All @@ -90,7 +109,7 @@ Drag&Drop/Einfügen aus der Zwischenablage werden ignoriert; bestehende
`<img>`-Inhalte bleiben trotzdem sichtbar):

```tsx
import { RichTextEditor } from "@basicbar/ui";
import { RichTextEditor } from "@basicbar/ui/rich-text-editor";

<RichTextEditor
value={description}
Expand All @@ -99,6 +118,11 @@ import { RichTextEditor } from "@basicbar/ui";
/>
```

**Schreibgeschützt** mit `editable={false}`: kein Toolbar, kein Cursor,
Dateien per Drop/Paste werden ignoriert — z. B. während ein Formular
speichert oder für Nutzer ohne Schreibrecht. Für die reine Anzeige
gespeicherten HTMLs ist `RichText` das richtige Werkzeug (kostet kein TipTap).

**Editor mit Bildern** — `onUploadImage` lädt hoch und liefert die relative
URL als String; scheitert der Upload, zeigt der Editor
`t("Image upload failed")` (plus die Fehlermeldung, falls vorhanden) per
Expand Down Expand Up @@ -143,20 +167,31 @@ unangetastet.
**Rendern** des serverseitig sanitisierten HTML:

```tsx
import { RichText } from "@basicbar/ui";
import { RichText, richTextClass } from "@basicbar/ui";

<RichText html={product.description} />
// eigene Klassen statt des Prosa-Defaults (ersetzt, nicht ergänzt):
<RichText html={product.description} className="text-3xl [&_img]:max-h-64" />
// Prosa-Default ergänzen statt kopieren:
<RichText html={product.description} className={`${richTextClass} mt-4`} />
```

**Prüfen** des gespeicherten HTML — `stripHtml(html)` liefert den sichtbaren
Text (für Listen, Suchtreffer, Platzhalter „kein Fragetext“), `isEmptyHtml(html)`
ist `true` für leere Werte und das `<p></p>`, das ein geöffneter Editor
hinterlässt, aber `false` für reine Bild-Inhalte. Beide parsen per DOM (keine
Regex), ohne etwas auszuführen. `TranslatableField` mit `format="html"`
benutzt `isEmptyHtml` für seine Ausgefüllt-Punkte.

**Integration in `TranslatableField`** über `renderInput` (pro Sprache ein
Editor, mit `format="html"` bleiben die Ausgefüllt-Punkte markup-blind und
die Maschinenübersetzung erhält die Tags). `renderInput` bekommt neben `id`
auch `labelId` — die Id von `TranslatableField`s eigenem sichtbaren `<label>`
(nur gesetzt, wenn die `label`-Prop übergeben wurde); durchgereicht als
`labelledBy` bindet der Editor sich per `aria-labelledby` an dieses Label,
statt ein zweites, redundantes `ariaLabel` zu brauchen:
statt ein zweites, redundantes `ariaLabel` zu brauchen. Auch `onBlur` wird
durchgereicht (die `onBlur`-Prop des Feldes), falls der eigene Editor ein
Blur-Speichern verdrahten will:

```tsx
<TranslatableField
Expand All @@ -176,12 +211,38 @@ statt ein zweites, redundantes `ariaLabel` zu brauchen:
/>
```

**Übersetzungs-Keys**, die das Tool bereitstellen muss (Englisch als Key,
siehe `initI18n`): `"Bold"`, `"Italic"`, `"Heading (large)"`,
`"Heading (small)"`, `"Bulleted list"`, `"Numbered list"`, `"Link"`,
`"Enter URL"`, `"Insert image (or drag and drop)"`,
`"Image upload failed"`, `"Image description (alt text)"`,
`"Image description"`.
**Floating-Controls von `TranslationFormProvider`** (Sprachumschalter +
„alle Felder übersetzen“) sitzen per Default `fixed bottom-6 right-6 z-40`.
Kollidiert das mit einer eigenen Sticky-Leiste (z. B. Speichern/Abbrechen am
unteren Rand auf schmalen Screens), setzt das Tool die Position per
`controlsClassName` statt per CSS-Override auf die Utility-Klassen:

```tsx
<TranslationFormProvider translate={…} controlsClassName="fixed bottom-6 right-6 z-40 max-md:bottom-[5.5rem]">
```

## Übersetzungs-Keys

Alle Strings des Pakets laufen über `t()` mit Englisch als Key (siehe
`initI18n`); das Tool stellt die deutschen (und weiteren) Übersetzungen in
seinem Katalog bereit. Fehlende Keys fallen auf den englischen Text zurück.

- Rich-Text-Editor: `"Bold"`, `"Italic"`, `"Heading (large)"`,
`"Heading (small)"`, `"Bulleted list"`, `"Numbered list"`, `"Link"`,
`"Enter URL"`, `"Insert image (or drag and drop)"`,
`"Image upload failed"`, `"Image description (alt text)"`,
`"Image description"`.
- TranslatableField: `"translated"`, `"not translated"`,
`"translation may be outdated"`,
`"The other language was changed since this translation."`,
`"Mark as up to date"`, `"Translate from {{language}}"`,
`"Translating…"`, `"Translation failed."`,
`"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."`.
- Preferences: `"Preferences"`, `"Language"`, `"Appearance"`, `"Auto"`,
`"(follows your system)"`, `"Light"`, `"Dark"`.

## Preferences (language & appearance)

Expand Down Expand Up @@ -210,8 +271,10 @@ follows the system and the shown language is the marked one; a pick calls
`i18n.changeLanguage`, which the detector caches, so it is binding from then
on. Use `onChange` / `onLanguageChange` to persist the choice server-side.

Translation keys (English source strings): `Appearance`, `Auto`,
`(follows your system)`, `Light`, `Dark`, `Language`, `Preferences`.
Keyboard: `PreferencesMenu` follows the WAI-ARIA menu pattern — opening
focuses the first row, Up/Down cycle through the rows, Home/End jump,
Escape closes and returns focus to the button. Translation keys: see
"Übersetzungs-Keys" above.

## CSP

Expand Down
4 changes: 2 additions & 2 deletions packages/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 11 additions & 4 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"name": "@basicbar/ui",
"version": "0.6.0",
"description": "Design-System-Basis der virtUOS -bar-Tools: Tailwind-Preset, Basis-Styles, Theme (Dark Mode), i18n-Bootstrap, contentLang, RichTextEditor/RichText (TipTap)",
"version": "0.7.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)",
"type": "module",
"sideEffects": false,
"files": [
"dist",
"base.css",
Expand All @@ -16,6 +17,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./rich-text-editor": {
"types": "./dist/rich-text-editor.d.ts",
"import": "./dist/rich-text-editor.js"
},
"./tailwind-preset": "./tailwind-preset.js",
"./base.css": "./base.css"
},
Expand All @@ -25,14 +30,16 @@
},
"tsup": {
"entry": [
"src/index.ts"
"src/index.ts",
"src/rich-text-editor.ts"
],
"format": [
"esm"
],
"dts": true,
"sourcemap": true,
"clean": true
"clean": true,
"splitting": true
},
"peerDependencies": {
"i18next": ">=23",
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/src/Preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,18 @@ export function PreferencesMenu({
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;
// A `role="menu"` promises arrow-key navigation (WAI-ARIA menu pattern):
// focus moves to the first item on open, Up/Down cycle through the
// menuitemradio rows, Home/End jump, Escape closes and restores focus.
const items = () =>
Array.from(
menuRef.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [],
);
items()[0]?.focus();
function onPointerDown(event: PointerEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
Expand All @@ -144,7 +153,19 @@ export function PreferencesMenu({
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const list = items();
if (!list.length) return;
const current = list.indexOf(document.activeElement as HTMLElement);
let next: number;
if (event.key === "Home") next = 0;
else if (event.key === "End") next = list.length - 1;
else if (event.key === "ArrowDown") next = (current + 1) % list.length;
else next = (current - 1 + list.length) % list.length;
event.preventDefault();
list[next]?.focus();
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
Expand Down Expand Up @@ -174,7 +195,9 @@ export function PreferencesMenu({
</button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label={t("Preferences")}
className="absolute right-0 z-30 mt-2 w-56 animate-fade-up overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg shadow-slate-900/5 dark:border-slate-700 dark:bg-slate-800"
>
<LanguageOptions onChange={onLanguageChange} onPicked={closeAndFocus} />
Expand Down
32 changes: 18 additions & 14 deletions packages/ui/src/RichText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,28 @@
* security boundary: every rich field is cleaned on save/import, so the
* stored HTML is safe to inject here. Pairs with `RichTextEditor`. */

/** The default prose styling of `RichText`. Exported so a call site can build
* on it (`className={\`${richTextClass} mt-4\`}`) instead of copying it. */
export const richTextClass =
"text-slate-700 dark:text-slate-300 [&_img]:max-w-full [&_img]:h-auto [&_p]:my-2 " +
"[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:my-0.5 " +
"[&_h2]:mt-4 [&_h2]:mb-1 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-slate-900 dark:[&_h2]:text-slate-100 " +
"[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-slate-900 dark:[&_h3]:text-slate-100 " +
"[&_a]:font-medium [&_a]:text-brand-700 [&_a]:underline dark:[&_a]:text-brand-300 " +
"[&_strong]:font-semibold [&_em]:italic";

export interface RichTextProps {
html: string;
/** Fully REPLACES the default styling (`richTextClass`) — it is not
* appended — so a migrated call site can render byte-identically to its
* previous markup; some sites use large-display classes (text-3xl,
* [&_img]:max-h-64, [&_ul]:pl-8) that would clash with the prose default.
* To extend the default instead, compose it: `${richTextClass} mt-4`. */
className?: string;
}

export function RichText({ html, className }: RichTextProps) {
// A caller-supplied className fully REPLACES the default styling (it is not
// appended), so a migrated call site can render byte-identically to its
// previous markup — some sites use large-display classes (text-3xl,
// [&_img]:max-h-64, [&_ul]:pl-8) that would clash with the prose default.
// Sites that pass nothing get a sane prose default.
const cls =
className ??
"text-slate-700 dark:text-slate-300 [&_img]:max-w-full [&_img]:h-auto [&_p]:my-2 " +
"[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:my-0.5 " +
"[&_h2]:mt-4 [&_h2]:mb-1 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-slate-900 dark:[&_h2]:text-slate-100 " +
"[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-slate-900 dark:[&_h3]:text-slate-100 " +
"[&_a]:font-medium [&_a]:text-brand-700 [&_a]:underline dark:[&_a]:text-brand-300 " +
"[&_strong]:font-semibold [&_em]:italic";
return <div className={cls} dangerouslySetInnerHTML={{ __html: html }} />;
return (
<div className={className ?? richTextClass} dangerouslySetInnerHTML={{ __html: html }} />
);
}
Loading
Loading