From d3a5099f3eee4100a117301f412ec2a0aab03c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Rolf?= Date: Wed, 30 Sep 2026 22:48:38 +0200 Subject: [PATCH 1/2] feat(ui): shared AppearanceControl, LanguageOptions and PreferencesMenu (ausleihbar#35) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 +++ packages/ui/README.md | 27 +++++ packages/ui/package-lock.json | 4 +- packages/ui/package.json | 2 +- packages/ui/src/Preferences.tsx | 172 ++++++++++++++++++++++++++++++++ packages/ui/src/index.ts | 2 + 6 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/Preferences.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0e2d8..049a2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ Changelog ist die Upgrade-Anleitung für die Tools. ## [Unreleased] +### @basicbar/ui (→ wird `ui/v0.6.0`) + +**Geteilte Einstellungs-Bausteine** (ausleihbar#35): `LanguageOptions`, +`AppearanceControl` und `PreferencesMenu`. Die ersten beiden sind Menüzeilen +für das Account-Menü des Tools (Sprache als `menuitemradio`, Erscheinungsbild +als `radiogroup`); `PreferencesMenu` ist der runde Einstellungen-Button mit +Popover für nicht angemeldete Besucher. Es gibt bewusst keinen Eintrag „Auto“ +bei der Sprache: ohne Wahl folgt der Detector dem System (die angezeigte +Sprache ist markiert), eine Wahl wird von i18next gecacht und ist damit +verbindlich. `onChange` / `onLanguageChange` dienen zum serverseitigen +Speichern. README: neuer Abschnitt „Preferences (language & appearance)“. + +Migration: additiv, nichts zu tun. Die Tools ergänzen in ihren Katalogen den +Schlüssel „Preferences“ (die übrigen — Appearance, Auto, „(follows your +system)“, Light, Dark, Language — existieren meist schon). + ### @basicbar/ui (→ wird `ui/v0.5.1`) **CSP-tauglicher `RichTextEditor`** (basicbar#9): TipTap hängte beim Mounten diff --git a/packages/ui/README.md b/packages/ui/README.md index 0faba5b..26a8f53 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -175,6 +175,33 @@ siehe `initI18n`): `"Bold"`, `"Italic"`, `"Heading (large)"`, `"Image upload failed"`, `"Image description (alt text)"`, `"Image description"`. +## Preferences (language & appearance) + +Three shared components, styled like the rest of the -bar menus: + +- `LanguageOptions` (`{ onChange?, onPicked? }`) — language rows + (`menuitemradio`) from `SUPPORTED_LANGUAGES`. +- `AppearanceControl` — Auto / Light / Dark radio rows (needs `ThemeProvider`). +- `PreferencesMenu` (`{ onLanguageChange? }`) — round button with a popover + holding both; for signed-out visitors. + +```tsx +// Signed-in: rows inside the app's own account menu + api.setLanguage(lang)} onPicked={close} /> + + +// Guests + +``` + +Language rule: there is no "Auto" entry. Without a manual pick the detector +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`. + ## CSP Die Komponenten injizieren zur Laufzeit **keine** Inline-Styles oder diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json index a74fed9..be8005e 100644 --- a/packages/ui/package-lock.json +++ b/packages/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@basicbar/ui", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@basicbar/ui", - "version": "0.5.1", + "version": "0.6.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 e4c1af1..693dd92 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@basicbar/ui", - "version": "0.5.1", + "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)", "license": "Apache-2.0", "author": "Universität Osnabrück (virtUOS)", diff --git a/packages/ui/src/Preferences.tsx b/packages/ui/src/Preferences.tsx new file mode 100644 index 0000000..428d1e4 --- /dev/null +++ b/packages/ui/src/Preferences.tsx @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Universität Osnabrück (virtUOS) + +/** Shared preference controls for the -bar tools: language and appearance, + * either as rows inside the app's own account menu (`LanguageOptions` + + * `AppearanceControl`) or, for signed-out visitors, as a round popover + * button (`PreferencesMenu`). */ +import { Check, Monitor, Moon, SlidersHorizontal, Sun } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SUPPORTED_LANGUAGES } from "./i18n"; +import { useTheme, type Appearance } from "./theme"; + +/** Auto / Light / Dark setting. Auto follows the OS; an explicit pick + * overrides it. Full-width radio rows (icon + label + check on the active + * option) to match the menu vocabulary. */ +export function AppearanceControl() { + const { t } = useTranslation(); + const { appearance, setAppearance } = useTheme(); + const options: { + value: Appearance; + label: string; + hint?: string; + icon: typeof Sun; + }[] = [ + { value: "auto", label: t("Auto"), hint: t("(follows your system)"), icon: Monitor }, + { value: "light", label: t("Light"), icon: Sun }, + { value: "dark", label: t("Dark"), icon: Moon }, + ]; + return ( +
+

+ {t("Appearance")} +

+ {options.map((opt) => { + const active = appearance === opt.value; + return ( + + ); + })} +
+ ); +} + +/** Language options as `menuitemradio` rows. There is deliberately no "Auto" + * entry: without a manual pick the detector follows the system and the + * resolved language is the one marked; a pick is cached by i18next (the + * detector's cache) and is therefore binding from then on. `onChange` lets + * the app persist the choice server-side; `onPicked` lets a menu close. */ +export function LanguageOptions({ + onChange, + onPicked, +}: { + onChange?: (lang: string) => void; + onPicked?: () => void; +}) { + const { i18n } = useTranslation(); + const resolved = i18n.resolvedLanguage ?? i18n.language; + const current = SUPPORTED_LANGUAGES.some((l) => l.code === resolved) + ? resolved + : SUPPORTED_LANGUAGES[0]?.code; + return ( + <> + {SUPPORTED_LANGUAGES.map((lang) => ( + + ))} + + ); +} + +/** Round "preferences" button with a popover holding language + appearance — + * for signed-out visitors (signed-in users get the same rows in their + * account menu). */ +export function PreferencesMenu({ + onLanguageChange, +}: { + onLanguageChange?: (lang: string) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(event: PointerEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setOpen(false); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + triggerRef.current?.focus(); + } + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + function closeAndFocus() { + setOpen(false); + triggerRef.current?.focus(); + } + + return ( +
+ + {open && ( +
+

+ {t("Language")} +

+ +
+ +
+ )} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f50a719..c95698b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -21,6 +21,8 @@ export type { export { TranslatableField } from "./TranslatableField"; export type { RenderInputArgs, TranslatableFieldProps } from "./TranslatableField"; +export { AppearanceControl, LanguageOptions, PreferencesMenu } from "./Preferences"; + export { RichTextEditor } from "./RichTextEditor"; export type { RichTextEditorProps } from "./RichTextEditor"; export { RichText } from "./RichText"; From f1bae4a09465967cae0b12e9bc85ba2789723944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Rolf?= Date: Wed, 30 Sep 2026 22:50:19 +0200 Subject: [PATCH 2/2] fix(ui): valid menu ARIA for preference groups; document dist Tailwind glob Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++-- packages/ui/README.md | 21 +++++++++++++---- packages/ui/src/Preferences.tsx | 41 ++++++++++++++++++++++----------- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 049a2aa..dc2c95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ Changelog ist die Upgrade-Anleitung für die Tools. **Geteilte Einstellungs-Bausteine** (ausleihbar#35): `LanguageOptions`, `AppearanceControl` und `PreferencesMenu`. Die ersten beiden sind Menüzeilen -für das Account-Menü des Tools (Sprache als `menuitemradio`, Erscheinungsbild -als `radiogroup`); `PreferencesMenu` ist der runde Einstellungen-Button mit +für das Account-Menü des Tools (je eine beschriftete `role="group"` mit +`menuitemradio`-Zeilen, gültig in `role="menu"`; `LanguageOptions` hat +`heading?`); `PreferencesMenu` ist der runde Einstellungen-Button mit Popover für nicht angemeldete Besucher. Es gibt bewusst keinen Eintrag „Auto“ bei der Sprache: ohne Wahl folgt der Detector dem System (die angezeigte Sprache ist markiert), eine Wahl wird von i18next gecacht und ist damit diff --git a/packages/ui/README.md b/packages/ui/README.md index 26a8f53..e345465 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -36,7 +36,11 @@ Tools identisch aussehen, übergeben sie dieselbe Ramp. import { createPreset } from "@basicbar/ui/tailwind-preset"; export default { presets: [createPreset({ colors: { slate: {/* Tool-Ramp */}, brand: {/* Tool-Ramp */} } })], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "./node_modules/@basicbar/ui/dist/**/*.js", + ], }; // index.css @@ -48,6 +52,10 @@ import { initI18n, ThemeProvider } from "@basicbar/ui"; initI18n({ resources: { en, de } }); ``` +Der `dist`-Glob ist nötig, weil die Komponenten des Pakets (z. B. +`RichTextEditor`, `PreferencesMenu`) ihre Tailwind-Klassen im Paket-Code +tragen — ohne den Glob erzeugt Tailwind dafür kein CSS. + Build: `npm install && npm run build` (tsup → `dist/`). Distribution als npm-Tarball über ein GitHub-Release-Asset (siehe ADR-0002, ADR-0004 und Repo-CI). @@ -179,11 +187,14 @@ siehe `initI18n`): `"Bold"`, `"Italic"`, `"Heading (large)"`, Three shared components, styled like the rest of the -bar menus: -- `LanguageOptions` (`{ onChange?, onPicked? }`) — language rows - (`menuitemradio`) from `SUPPORTED_LANGUAGES`. -- `AppearanceControl` — Auto / Light / Dark radio rows (needs `ThemeProvider`). +- `LanguageOptions` (`{ onChange?, onPicked?, heading? }`) — a labelled + `role="group"` with its "Language" heading (`heading={false}` hides it) and + `menuitemradio` rows from `SUPPORTED_LANGUAGES`. +- `AppearanceControl` — a labelled `role="group"` with its "Appearance" + heading and Auto / Light / Dark as `menuitemradio` rows (needs + `ThemeProvider`). Both are valid inside a `role="menu"`. - `PreferencesMenu` (`{ onLanguageChange? }`) — round button with a popover - holding both; for signed-out visitors. + holding both (groups separated by a `role="separator"`); for signed-out visitors. ```tsx // Signed-in: rows inside the app's own account menu diff --git a/packages/ui/src/Preferences.tsx b/packages/ui/src/Preferences.tsx index 428d1e4..1fce71d 100644 --- a/packages/ui/src/Preferences.tsx +++ b/packages/ui/src/Preferences.tsx @@ -6,17 +6,19 @@ * `AppearanceControl`) or, for signed-out visitors, as a round popover * button (`PreferencesMenu`). */ import { Check, Monitor, Moon, SlidersHorizontal, Sun } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { SUPPORTED_LANGUAGES } from "./i18n"; import { useTheme, type Appearance } from "./theme"; -/** Auto / Light / Dark setting. Auto follows the OS; an explicit pick +/** Auto / Light / Dark setting (a labelled `role="group"` of `menuitemradio` + * rows, valid inside a `role="menu"`). Auto follows the OS; an explicit pick * overrides it. Full-width radio rows (icon + label + check on the active * option) to match the menu vocabulary. */ export function AppearanceControl() { const { t } = useTranslation(); const { appearance, setAppearance } = useTheme(); + const headingId = useId(); const options: { value: Appearance; label: string; @@ -28,8 +30,8 @@ export function AppearanceControl() { { value: "dark", label: t("Dark"), icon: Moon }, ]; return ( -
-

+

+

{t("Appearance")}

{options.map((opt) => { @@ -38,7 +40,7 @@ export function AppearanceControl() {
); } @@ -159,11 +177,8 @@ export function PreferencesMenu({ role="menu" 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" > -

- {t("Language")} -

-
+
)}