Skip to content

Commit d3a5099

Browse files
rrolfclaude
andcommitted
feat(ui): shared AppearanceControl, LanguageOptions and PreferencesMenu (ausleihbar#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 16c2581 commit d3a5099

6 files changed

Lines changed: 220 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ Changelog ist die Upgrade-Anleitung für die Tools.
77

88
## [Unreleased]
99

10+
### @basicbar/ui (→ wird `ui/v0.6.0`)
11+
12+
**Geteilte Einstellungs-Bausteine** (ausleihbar#35): `LanguageOptions`,
13+
`AppearanceControl` und `PreferencesMenu`. Die ersten beiden sind Menüzeilen
14+
für das Account-Menü des Tools (Sprache als `menuitemradio`, Erscheinungsbild
15+
als `radiogroup`); `PreferencesMenu` ist der runde Einstellungen-Button mit
16+
Popover für nicht angemeldete Besucher. Es gibt bewusst keinen Eintrag „Auto“
17+
bei der Sprache: ohne Wahl folgt der Detector dem System (die angezeigte
18+
Sprache ist markiert), eine Wahl wird von i18next gecacht und ist damit
19+
verbindlich. `onChange` / `onLanguageChange` dienen zum serverseitigen
20+
Speichern. README: neuer Abschnitt „Preferences (language & appearance)“.
21+
22+
Migration: additiv, nichts zu tun. Die Tools ergänzen in ihren Katalogen den
23+
Schlüssel „Preferences“ (die übrigen — Appearance, Auto, „(follows your
24+
system)“, Light, Dark, Language — existieren meist schon).
25+
1026
### @basicbar/ui (→ wird `ui/v0.5.1`)
1127

1228
**CSP-tauglicher `RichTextEditor`** (basicbar#9): TipTap hängte beim Mounten

‎packages/ui/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,33 @@ siehe `initI18n`): `"Bold"`, `"Italic"`, `"Heading (large)"`,
175175
`"Image upload failed"`, `"Image description (alt text)"`,
176176
`"Image description"`.
177177

178+
## Preferences (language & appearance)
179+
180+
Three shared components, styled like the rest of the -bar menus:
181+
182+
- `LanguageOptions` (`{ onChange?, onPicked? }`) — language rows
183+
(`menuitemradio`) from `SUPPORTED_LANGUAGES`.
184+
- `AppearanceControl` — Auto / Light / Dark radio rows (needs `ThemeProvider`).
185+
- `PreferencesMenu` (`{ onLanguageChange? }`) — round button with a popover
186+
holding both; for signed-out visitors.
187+
188+
```tsx
189+
// Signed-in: rows inside the app's own account menu
190+
<LanguageOptions onChange={(lang) => api.setLanguage(lang)} onPicked={close} />
191+
<AppearanceControl />
192+
193+
// Guests
194+
<PreferencesMenu />
195+
```
196+
197+
Language rule: there is no "Auto" entry. Without a manual pick the detector
198+
follows the system and the shown language is the marked one; a pick calls
199+
`i18n.changeLanguage`, which the detector caches, so it is binding from then
200+
on. Use `onChange` / `onLanguageChange` to persist the choice server-side.
201+
202+
Translation keys (English source strings): `Appearance`, `Auto`,
203+
`(follows your system)`, `Light`, `Dark`, `Language`, `Preferences`.
204+
178205
## CSP
179206

180207
Die Komponenten injizieren zur Laufzeit **keine** Inline-Styles oder

‎packages/ui/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/ui/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@basicbar/ui",
3-
"version": "0.5.1",
3+
"version": "0.6.0",
44
"description": "Design-System-Basis der virtUOS -bar-Tools: Tailwind-Preset, Basis-Styles, Theme (Dark Mode), i18n-Bootstrap, contentLang, RichTextEditor/RichText (TipTap)",
55
"license": "Apache-2.0",
66
"author": "Universität Osnabrück (virtUOS)",

‎packages/ui/src/Preferences.tsx‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Universität Osnabrück (virtUOS)
3+
4+
/** Shared preference controls for the -bar tools: language and appearance,
5+
* either as rows inside the app's own account menu (`LanguageOptions` +
6+
* `AppearanceControl`) or, for signed-out visitors, as a round popover
7+
* button (`PreferencesMenu`). */
8+
import { Check, Monitor, Moon, SlidersHorizontal, Sun } from "lucide-react";
9+
import { useEffect, useRef, useState } from "react";
10+
import { useTranslation } from "react-i18next";
11+
import { SUPPORTED_LANGUAGES } from "./i18n";
12+
import { useTheme, type Appearance } from "./theme";
13+
14+
/** Auto / Light / Dark setting. Auto follows the OS; an explicit pick
15+
* overrides it. Full-width radio rows (icon + label + check on the active
16+
* option) to match the menu vocabulary. */
17+
export function AppearanceControl() {
18+
const { t } = useTranslation();
19+
const { appearance, setAppearance } = useTheme();
20+
const options: {
21+
value: Appearance;
22+
label: string;
23+
hint?: string;
24+
icon: typeof Sun;
25+
}[] = [
26+
{ value: "auto", label: t("Auto"), hint: t("(follows your system)"), icon: Monitor },
27+
{ value: "light", label: t("Light"), icon: Sun },
28+
{ value: "dark", label: t("Dark"), icon: Moon },
29+
];
30+
return (
31+
<div role="radiogroup" aria-label={t("Appearance")}>
32+
<p className="px-3 pb-0.5 pt-1 text-xs text-slate-400 dark:text-slate-300">
33+
{t("Appearance")}
34+
</p>
35+
{options.map((opt) => {
36+
const active = appearance === opt.value;
37+
return (
38+
<button
39+
key={opt.value}
40+
type="button"
41+
role="radio"
42+
aria-checked={active}
43+
onClick={() => setAppearance(opt.value)}
44+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-700"
45+
>
46+
<opt.icon aria-hidden className="h-4 w-4 text-slate-400" />
47+
<span className="flex-1">
48+
{opt.label}
49+
{opt.hint && (
50+
<span className="block text-xs text-slate-400 dark:text-slate-300">
51+
{opt.hint}
52+
</span>
53+
)}
54+
</span>
55+
{active && <Check aria-hidden className="h-4 w-4 text-brand-600" />}
56+
</button>
57+
);
58+
})}
59+
</div>
60+
);
61+
}
62+
63+
/** Language options as `menuitemradio` rows. There is deliberately no "Auto"
64+
* entry: without a manual pick the detector follows the system and the
65+
* resolved language is the one marked; a pick is cached by i18next (the
66+
* detector's cache) and is therefore binding from then on. `onChange` lets
67+
* the app persist the choice server-side; `onPicked` lets a menu close. */
68+
export function LanguageOptions({
69+
onChange,
70+
onPicked,
71+
}: {
72+
onChange?: (lang: string) => void;
73+
onPicked?: () => void;
74+
}) {
75+
const { i18n } = useTranslation();
76+
const resolved = i18n.resolvedLanguage ?? i18n.language;
77+
const current = SUPPORTED_LANGUAGES.some((l) => l.code === resolved)
78+
? resolved
79+
: SUPPORTED_LANGUAGES[0]?.code;
80+
return (
81+
<>
82+
{SUPPORTED_LANGUAGES.map((lang) => (
83+
<button
84+
key={lang.code}
85+
type="button"
86+
role="menuitemradio"
87+
aria-checked={lang.code === current}
88+
onClick={() => {
89+
i18n.changeLanguage(lang.code);
90+
onChange?.(lang.code);
91+
onPicked?.();
92+
}}
93+
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-700"
94+
>
95+
{lang.label}
96+
{lang.code === current && (
97+
<Check aria-hidden className="h-4 w-4 text-brand-600" />
98+
)}
99+
</button>
100+
))}
101+
</>
102+
);
103+
}
104+
105+
/** Round "preferences" button with a popover holding language + appearance —
106+
* for signed-out visitors (signed-in users get the same rows in their
107+
* account menu). */
108+
export function PreferencesMenu({
109+
onLanguageChange,
110+
}: {
111+
onLanguageChange?: (lang: string) => void;
112+
}) {
113+
const { t } = useTranslation();
114+
const [open, setOpen] = useState(false);
115+
const ref = useRef<HTMLDivElement>(null);
116+
const triggerRef = useRef<HTMLButtonElement>(null);
117+
118+
useEffect(() => {
119+
if (!open) return;
120+
function onPointerDown(event: PointerEvent) {
121+
if (ref.current && !ref.current.contains(event.target as Node)) {
122+
setOpen(false);
123+
}
124+
}
125+
function onKeyDown(event: KeyboardEvent) {
126+
if (event.key === "Escape") {
127+
setOpen(false);
128+
triggerRef.current?.focus();
129+
}
130+
}
131+
document.addEventListener("pointerdown", onPointerDown);
132+
document.addEventListener("keydown", onKeyDown);
133+
return () => {
134+
document.removeEventListener("pointerdown", onPointerDown);
135+
document.removeEventListener("keydown", onKeyDown);
136+
};
137+
}, [open]);
138+
139+
function closeAndFocus() {
140+
setOpen(false);
141+
triggerRef.current?.focus();
142+
}
143+
144+
return (
145+
<div className="relative" ref={ref}>
146+
<button
147+
ref={triggerRef}
148+
type="button"
149+
onClick={() => setOpen((o) => !o)}
150+
aria-haspopup="menu"
151+
aria-expanded={open}
152+
aria-label={t("Preferences")}
153+
className="flex h-9 w-9 items-center justify-center rounded-full sm:h-10 sm:w-10 text-slate-600 transition-colors duration-150 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
154+
>
155+
<SlidersHorizontal aria-hidden className="h-5 w-5" />
156+
</button>
157+
{open && (
158+
<div
159+
role="menu"
160+
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"
161+
>
162+
<p className="px-3 pb-0.5 pt-1 text-xs text-slate-400 dark:text-slate-300">
163+
{t("Language")}
164+
</p>
165+
<LanguageOptions onChange={onLanguageChange} onPicked={closeAndFocus} />
166+
<div className="my-1 border-t border-slate-100 dark:border-slate-700" />
167+
<AppearanceControl />
168+
</div>
169+
)}
170+
</div>
171+
);
172+
}

‎packages/ui/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type {
2121
export { TranslatableField } from "./TranslatableField";
2222
export type { RenderInputArgs, TranslatableFieldProps } from "./TranslatableField";
2323

24+
export { AppearanceControl, LanguageOptions, PreferencesMenu } from "./Preferences";
25+
2426
export { RichTextEditor } from "./RichTextEditor";
2527
export type { RichTextEditorProps } from "./RichTextEditor";
2628
export { RichText } from "./RichText";

0 commit comments

Comments
 (0)