@cavebatsofware/riposte-pickers 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +214 -0
- package/dist/chunk-6FAEMGAR.js +93 -0
- package/dist/chunk-6FAEMGAR.js.map +1 -0
- package/dist/chunk-6OZDKKEP.cjs +95 -0
- package/dist/chunk-6OZDKKEP.cjs.map +1 -0
- package/dist/chunk-7JL223UJ.cjs +84 -0
- package/dist/chunk-7JL223UJ.cjs.map +1 -0
- package/dist/chunk-A4OVAXLP.js +261 -0
- package/dist/chunk-A4OVAXLP.js.map +1 -0
- package/dist/chunk-ATS6MI5Q.js +3 -0
- package/dist/chunk-ATS6MI5Q.js.map +1 -0
- package/dist/chunk-GDYHLOSQ.cjs +268 -0
- package/dist/chunk-GDYHLOSQ.cjs.map +1 -0
- package/dist/chunk-IWUGV7HL.js +113 -0
- package/dist/chunk-IWUGV7HL.js.map +1 -0
- package/dist/chunk-LIGL56YJ.cjs +116 -0
- package/dist/chunk-LIGL56YJ.cjs.map +1 -0
- package/dist/chunk-U73YJG4C.cjs +4 -0
- package/dist/chunk-U73YJG4C.cjs.map +1 -0
- package/dist/chunk-XWM3AYYR.js +82 -0
- package/dist/chunk-XWM3AYYR.js.map +1 -0
- package/dist/i18n/index.cjs +192 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +156 -0
- package/dist/i18n/index.d.ts +156 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.cjs +52 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/language.cjs +18 -0
- package/dist/language.cjs.map +1 -0
- package/dist/language.d.cts +39 -0
- package/dist/language.d.ts +39 -0
- package/dist/language.js +5 -0
- package/dist/language.js.map +1 -0
- package/dist/shared.cjs +18 -0
- package/dist/shared.cjs.map +1 -0
- package/dist/shared.d.cts +29 -0
- package/dist/shared.d.ts +29 -0
- package/dist/shared.js +5 -0
- package/dist/shared.js.map +1 -0
- package/dist/theme.cjs +33 -0
- package/dist/theme.cjs.map +1 -0
- package/dist/theme.d.cts +54 -0
- package/dist/theme.d.ts +54 -0
- package/dist/theme.js +4 -0
- package/dist/theme.js.map +1 -0
- package/package.json +97 -0
- package/styles/index.css +3 -0
- package/styles/language.css +89 -0
- package/styles/palette.css +614 -0
- package/styles/picker.css +139 -0
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# @cavebatsofware/riposte-pickers
|
|
2
|
+
|
|
3
|
+
Accessible React **theme** and **language** pickers that share one
|
|
4
|
+
popover/roving-focus chassis. The theme picker has a two-axis model (colorway
|
|
5
|
+
and light/dark mode) with a `localStorage`-backed engine that honors
|
|
6
|
+
`prefers-color-scheme`; the language picker drives `react-i18next` and persists
|
|
7
|
+
through your i18next instance, with an optional hook for server-side sync.
|
|
8
|
+
Extracted from [riposte-social](https://github.com/cavebatsofware/riposte-social).
|
|
9
|
+
|
|
10
|
+
## What you get
|
|
11
|
+
|
|
12
|
+
- **`ThemeProvider` / `useTheme`** engine: persists the choice, tracks the OS
|
|
13
|
+
preference until the user picks, and applies `<html data-theme="…">`.
|
|
14
|
+
- **`ThemePicker`**: a popover (or inline) grid of swatches plus a light/dark
|
|
15
|
+
toggle, each an ARIA radiogroup with roving tabindex.
|
|
16
|
+
- **`LanguagePicker`**: a popover (or inline) list of languages by native name,
|
|
17
|
+
built on the same chassis, with an injectable `onChange` for persistence.
|
|
18
|
+
- **`PopoverPicker` / `useRovingFocus`**: the shared toggle/popover/focus shell
|
|
19
|
+
and the WAI-ARIA roving-tabindex hook, exported so you can build sibling
|
|
20
|
+
pickers (visibility, compose, etc.) on the same foundation.
|
|
21
|
+
- A default palette (`styles/`) and translation fragments (en, de, es, fr, zh).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install @cavebatsofware/riposte-pickers
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Peer dependencies (provided by your app): `react >=18`, `react-dom >=18`,
|
|
30
|
+
`react-i18next >=13`.
|
|
31
|
+
|
|
32
|
+
## Entry points
|
|
33
|
+
|
|
34
|
+
| Import | Contents |
|
|
35
|
+
|--------|----------|
|
|
36
|
+
| `@cavebatsofware/riposte-pickers` | everything below |
|
|
37
|
+
| `@cavebatsofware/riposte-pickers/theme` | `ThemeProvider`, `useTheme`, `ThemePicker`, `COLORWAYS`, … |
|
|
38
|
+
| `@cavebatsofware/riposte-pickers/language` | `LanguagePicker`, `DEFAULT_LANGUAGES`, `Language` |
|
|
39
|
+
| `@cavebatsofware/riposte-pickers/shared` | `PopoverPicker`, `useRovingFocus` |
|
|
40
|
+
| `@cavebatsofware/riposte-pickers/i18n` | `themeResources`, `languageResources` |
|
|
41
|
+
| `@cavebatsofware/riposte-pickers/styles` | all CSS (`palette` + `picker` + `language`) |
|
|
42
|
+
|
|
43
|
+
The root entry re-exports every symbol, so a single import works too; the
|
|
44
|
+
subpaths exist for explicit boundaries and to keep theme-only or language-only
|
|
45
|
+
consumers lean.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { ThemeProvider, ThemePicker, LanguagePicker } from "@cavebatsofware/riposte-pickers";
|
|
51
|
+
import "@cavebatsofware/riposte-pickers/styles";
|
|
52
|
+
|
|
53
|
+
function App() {
|
|
54
|
+
return (
|
|
55
|
+
<ThemeProvider>
|
|
56
|
+
<header>
|
|
57
|
+
<LanguagePicker />
|
|
58
|
+
<ThemePicker />
|
|
59
|
+
</header>
|
|
60
|
+
{/* ... */}
|
|
61
|
+
</ThemeProvider>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Both pickers read their strings from `react-i18next`, so they must render
|
|
67
|
+
inside an `I18nextProvider` (or your global i18next instance). Merge the
|
|
68
|
+
bundled fragments into your namespace (default `common`):
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import i18n from "./i18n"; // your configured i18next instance
|
|
72
|
+
import { themeResources, languageResources } from "@cavebatsofware/riposte-pickers/i18n";
|
|
73
|
+
|
|
74
|
+
for (const [lng, res] of Object.entries(themeResources)) {
|
|
75
|
+
i18n.addResourceBundle(lng, "common", res, true, true);
|
|
76
|
+
}
|
|
77
|
+
for (const [lng, res] of Object.entries(languageResources)) {
|
|
78
|
+
i18n.addResourceBundle(lng, "common", res, true, true);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Using a different namespace? Pass it to each picker and merge into the same one:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
<ThemePicker namespace="ui" />
|
|
86
|
+
<LanguagePicker namespace="ui" />
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Persisting the language server-side
|
|
90
|
+
|
|
91
|
+
The `LanguagePicker` always calls `i18next.changeLanguage` (which, with the
|
|
92
|
+
standard browser-language-detector `caches`, writes to `localStorage`). It
|
|
93
|
+
knows nothing about auth or your API. To additionally persist the choice for a
|
|
94
|
+
signed-in user, pass `onChange`:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { useAuth } from "./auth";
|
|
98
|
+
import { updateLocale } from "./api";
|
|
99
|
+
|
|
100
|
+
function HeaderLanguage() {
|
|
101
|
+
const { user } = useAuth();
|
|
102
|
+
return (
|
|
103
|
+
<LanguagePicker
|
|
104
|
+
onChange={(code) => {
|
|
105
|
+
if (user) return updateLocale(code); // fire-and-forget; rejections are swallowed
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A rejected `onChange` never undoes the local switch; surface errors inside the
|
|
113
|
+
callback if you need to.
|
|
114
|
+
|
|
115
|
+
## Styling and the palette
|
|
116
|
+
|
|
117
|
+
`import ".../styles"` pulls in three stylesheets:
|
|
118
|
+
|
|
119
|
+
- `styles/palette.css` defines the colorway design tokens as
|
|
120
|
+
`[data-theme="<id>"]` / `[data-theme="<id>-dark"]` blocks, plus shared tokens
|
|
121
|
+
(typography, spacing, radius, shadow) both pickers consume.
|
|
122
|
+
- `styles/picker.css` is the theme-picker layout (`.theme-picker`,
|
|
123
|
+
`.theme-swatch*`, `.theme-mode*`).
|
|
124
|
+
- `styles/language.css` is the language-picker layout (`.language-picker`,
|
|
125
|
+
`.language-picker-item`, …).
|
|
126
|
+
|
|
127
|
+
Import them separately if you only want some:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import "@cavebatsofware/riposte-pickers/styles/picker.css";
|
|
131
|
+
import "@cavebatsofware/riposte-pickers/styles/language.css";
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Both picker stylesheets reference the shared tokens in `palette.css`, so ship
|
|
135
|
+
`palette.css` (or define equivalent tokens) whenever you use either picker.
|
|
136
|
+
|
|
137
|
+
### Bring your own colorways
|
|
138
|
+
|
|
139
|
+
Each colorway id must have a matching `[data-theme="<id>"]` (and
|
|
140
|
+
`[data-theme="<id>-dark"]`) block in your CSS. To replace the default catalog,
|
|
141
|
+
skip `palette.css`, ship your own blocks, and pass your catalog to the provider:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
import { ThemeProvider, type Colorway } from "@cavebatsofware/riposte-pickers";
|
|
145
|
+
|
|
146
|
+
const colorways: Colorway[] = [
|
|
147
|
+
{ id: "ocean", label: "Ocean", swatch: "#0b6e7a" },
|
|
148
|
+
{ id: "sand", label: "Sand", swatch: "#c9a26b" },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
<ThemeProvider colorways={colorways} defaultColorway="ocean" storageKey="myapp_theme_v1">
|
|
152
|
+
{children}
|
|
153
|
+
</ThemeProvider>;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Bring your own languages
|
|
157
|
+
|
|
158
|
+
Pass a `languages` catalog (base code plus a native-script display name).
|
|
159
|
+
Defaults to the bundled five. Each code is handed to `i18next.changeLanguage`,
|
|
160
|
+
so make sure your i18next instance has a catalog for it:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
import { LanguagePicker, type Language } from "@cavebatsofware/riposte-pickers";
|
|
164
|
+
|
|
165
|
+
const languages: Language[] = [
|
|
166
|
+
{ code: "en", nativeName: "English" },
|
|
167
|
+
{ code: "ja", nativeName: "日本語" },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
<LanguagePicker languages={languages} />;
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## API
|
|
174
|
+
|
|
175
|
+
### `<ThemeProvider>`
|
|
176
|
+
|
|
177
|
+
| Prop | Default | Description |
|
|
178
|
+
|------|---------|-------------|
|
|
179
|
+
| `colorways` | bundled `COLORWAYS` | The catalog the picker renders and the engine validates against. |
|
|
180
|
+
| `defaultColorway` | `"forest"` | Used when nothing is stored. |
|
|
181
|
+
| `storageKey` | `"rs_theme_v1"` | `localStorage` key for the persisted id. |
|
|
182
|
+
|
|
183
|
+
### `useTheme()`
|
|
184
|
+
|
|
185
|
+
Returns `{ theme, setTheme, colorways, mode, setMode }`. `theme` is the resolved
|
|
186
|
+
id of record (`"forest"` or `"forest-dark"`); `mode` is the derived
|
|
187
|
+
`"light"` | `"dark"`.
|
|
188
|
+
|
|
189
|
+
### `<ThemePicker>`
|
|
190
|
+
|
|
191
|
+
| Prop | Default | Description |
|
|
192
|
+
|------|---------|-------------|
|
|
193
|
+
| `variant` | `"popover"` | `"popover"` (toggle + dialog) or `"inline"` (always-visible grid). |
|
|
194
|
+
| `namespace` | `"common"` | i18next namespace holding the `theme.*` keys. |
|
|
195
|
+
|
|
196
|
+
### `<LanguagePicker>`
|
|
197
|
+
|
|
198
|
+
| Prop | Default | Description |
|
|
199
|
+
|------|---------|-------------|
|
|
200
|
+
| `variant` | `"popover"` | `"popover"` (toggle + dialog) or `"inline"` (always-visible list). |
|
|
201
|
+
| `namespace` | `"common"` | i18next namespace holding the `language.*` keys. |
|
|
202
|
+
| `languages` | bundled `DEFAULT_LANGUAGES` | Selectable languages (`{ code, nativeName }`). |
|
|
203
|
+
| `onChange` | none | Optional side effect after a switch (e.g. server persist). Rejections are swallowed. |
|
|
204
|
+
|
|
205
|
+
### Shared chassis
|
|
206
|
+
|
|
207
|
+
`PopoverPicker` and `useRovingFocus` are exported for building sibling pickers
|
|
208
|
+
on the same toggle/popover/focus chassis. `PopoverPicker` owns the
|
|
209
|
+
close-on-outside-interaction and Escape handling; `useRovingFocus` wires the
|
|
210
|
+
WAI-ARIA roving-tabindex keyboard pattern onto a container.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
GPL-3.0-only. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
// src/shared/PopoverPicker.tsx
|
|
5
|
+
function mergeRefs(...refs) {
|
|
6
|
+
return (el) => {
|
|
7
|
+
refs.forEach((r) => {
|
|
8
|
+
if (typeof r === "function") r(el);
|
|
9
|
+
else if (r != null) r.current = el;
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function PopoverPicker({
|
|
14
|
+
variant = "popover",
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
className,
|
|
18
|
+
toggleAriaLabel,
|
|
19
|
+
toggleIcon,
|
|
20
|
+
popoverAriaLabel,
|
|
21
|
+
popoverRef = null,
|
|
22
|
+
inlineRef = null,
|
|
23
|
+
children
|
|
24
|
+
}) {
|
|
25
|
+
const containerRef = useRef(null);
|
|
26
|
+
const triggerRef = useRef(null);
|
|
27
|
+
const internalPopoverRef = useRef(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (variant !== "popover" || !open) return void 0;
|
|
30
|
+
const popover = internalPopoverRef.current;
|
|
31
|
+
if (popover && !popover.contains(document.activeElement)) {
|
|
32
|
+
const focusable = popover.querySelector(
|
|
33
|
+
'[tabindex="0"], button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"])'
|
|
34
|
+
);
|
|
35
|
+
focusable?.focus();
|
|
36
|
+
}
|
|
37
|
+
function handleClickOutside(e) {
|
|
38
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
39
|
+
onOpenChange(false);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function handleFocusOut(e) {
|
|
43
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
44
|
+
onOpenChange(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function handleEsc(e) {
|
|
48
|
+
if (e.key === "Escape") {
|
|
49
|
+
onOpenChange(false);
|
|
50
|
+
triggerRef.current?.focus();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
54
|
+
document.addEventListener("focusin", handleFocusOut);
|
|
55
|
+
document.addEventListener("keydown", handleEsc);
|
|
56
|
+
return () => {
|
|
57
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
58
|
+
document.removeEventListener("focusin", handleFocusOut);
|
|
59
|
+
document.removeEventListener("keydown", handleEsc);
|
|
60
|
+
};
|
|
61
|
+
}, [open, variant, onOpenChange]);
|
|
62
|
+
if (variant === "inline") {
|
|
63
|
+
return /* @__PURE__ */ jsx("div", { ref: inlineRef, className: `${className}-inline`, children });
|
|
64
|
+
}
|
|
65
|
+
return /* @__PURE__ */ jsxs("div", { className, ref: containerRef, children: [
|
|
66
|
+
open && /* @__PURE__ */ jsx(
|
|
67
|
+
"div",
|
|
68
|
+
{
|
|
69
|
+
ref: mergeRefs(internalPopoverRef, popoverRef),
|
|
70
|
+
className: `${className}-popover`,
|
|
71
|
+
role: "dialog",
|
|
72
|
+
"aria-label": popoverAriaLabel,
|
|
73
|
+
children
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
/* @__PURE__ */ jsx(
|
|
77
|
+
"button",
|
|
78
|
+
{
|
|
79
|
+
ref: triggerRef,
|
|
80
|
+
type: "button",
|
|
81
|
+
className: `${className}-toggle`,
|
|
82
|
+
"aria-label": toggleAriaLabel,
|
|
83
|
+
"aria-expanded": open,
|
|
84
|
+
onClick: () => onOpenChange(!open),
|
|
85
|
+
children: toggleIcon
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
] });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { PopoverPicker };
|
|
92
|
+
//# sourceMappingURL=chunk-6FAEMGAR.js.map
|
|
93
|
+
//# sourceMappingURL=chunk-6FAEMGAR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/PopoverPicker.tsx"],"names":[],"mappings":"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","file":"chunk-6FAEMGAR.js","sourcesContent":["/* This file is part of @cavebatsofware/riposte-pickers\n * Copyright (C) 2026 Grant DeFayette\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, version 3 of the License (GPL-3.0-only).\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.\n */\nimport React, { useEffect, useRef } from \"react\";\n\n/// Shared toggle-button + popover shell used by the theme and language\n/// pickers. Owns the close-on-outside-interaction listeners (mousedown,\n/// focusin, Escape) and the wrapper JSX. Open/close state is controlled by\n/// the parent so the parent can drive other hooks from the same flag.\n///\n/// Two variants:\n/// - `popover` (default): renders a toggle button that toggles a\n/// `role=\"dialog\"` popover containing the content.\n/// - `inline`: renders only the content (no toggle, always visible),\n/// wrapped in a `${className}-inline` div for use inside layouts where\n/// vertical space is abundant (e.g. a mobile drawer).\n///\n/// Class names follow a `${className}` / `${className}-popover` /\n/// `${className}-toggle` / `${className}-inline` convention so each caller\n/// can keep its own styles.\nfunction mergeRefs<T>(...refs: Array<React.Ref<T> | null | undefined>) {\n return (el: T | null) => {\n refs.forEach((r) => {\n if (typeof r === \"function\") r(el);\n else if (r != null) (r as React.MutableRefObject<T | null>).current = el;\n });\n };\n}\n\nexport interface PopoverPickerProps {\n variant?: \"popover\" | \"inline\";\n open: boolean;\n onOpenChange: (v: boolean) => void;\n className: string;\n toggleAriaLabel: string;\n toggleIcon: React.ReactNode;\n popoverAriaLabel: string;\n popoverRef?: React.Ref<HTMLDivElement> | null;\n inlineRef?: React.Ref<HTMLDivElement> | null;\n children: React.ReactNode;\n}\n\nexport default function PopoverPicker({\n variant = \"popover\",\n open,\n onOpenChange,\n className,\n toggleAriaLabel,\n toggleIcon,\n popoverAriaLabel,\n popoverRef = null,\n inlineRef = null,\n children,\n}: PopoverPickerProps) {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const triggerRef = useRef<HTMLButtonElement | null>(null);\n const internalPopoverRef = useRef<HTMLDivElement | null>(null);\n\n useEffect(() => {\n if (variant !== \"popover\" || !open) return undefined;\n\n // Move focus into the popover so keyboard users land on the first\n // tabbable item rather than staying on the toggle (which would make\n // arrow keys scroll the page and Tab skip past the popover because it\n // sits before the toggle in DOM order). If a consumer hook has already\n // moved focus inside, leave it alone.\n const popover = internalPopoverRef.current;\n if (popover && !popover.contains(document.activeElement)) {\n const focusable = popover.querySelector<HTMLElement>(\n '[tabindex=\"0\"], button:not([disabled]):not([tabindex=\"-1\"]), a[href]:not([tabindex=\"-1\"])',\n );\n focusable?.focus();\n }\n\n function handleClickOutside(e: MouseEvent) {\n if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n onOpenChange(false);\n }\n }\n // Close when focus moves out of the picker container. Without this a\n // keyboard user can Tab from this picker to an adjacent picker's toggle\n // and open the second one while this one stays open, since mousedown\n // never fires.\n function handleFocusOut(e: FocusEvent) {\n if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n onOpenChange(false);\n }\n }\n function handleEsc(e: KeyboardEvent) {\n if (e.key === \"Escape\") {\n onOpenChange(false);\n triggerRef.current?.focus();\n }\n }\n document.addEventListener(\"mousedown\", handleClickOutside);\n document.addEventListener(\"focusin\", handleFocusOut);\n document.addEventListener(\"keydown\", handleEsc);\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside);\n document.removeEventListener(\"focusin\", handleFocusOut);\n document.removeEventListener(\"keydown\", handleEsc);\n };\n }, [open, variant, onOpenChange]);\n\n if (variant === \"inline\") {\n return (\n <div ref={inlineRef} className={`${className}-inline`}>\n {children}\n </div>\n );\n }\n\n return (\n <div className={className} ref={containerRef}>\n {open && (\n <div\n ref={mergeRefs(internalPopoverRef, popoverRef)}\n className={`${className}-popover`}\n role=\"dialog\"\n aria-label={popoverAriaLabel}\n >\n {children}\n </div>\n )}\n <button\n ref={triggerRef}\n type=\"button\"\n className={`${className}-toggle`}\n aria-label={toggleAriaLabel}\n aria-expanded={open}\n onClick={() => onOpenChange(!open)}\n >\n {toggleIcon}\n </button>\n </div>\n );\n}\n"]}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
|
|
6
|
+
// src/shared/PopoverPicker.tsx
|
|
7
|
+
function mergeRefs(...refs) {
|
|
8
|
+
return (el) => {
|
|
9
|
+
refs.forEach((r) => {
|
|
10
|
+
if (typeof r === "function") r(el);
|
|
11
|
+
else if (r != null) r.current = el;
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function PopoverPicker({
|
|
16
|
+
variant = "popover",
|
|
17
|
+
open,
|
|
18
|
+
onOpenChange,
|
|
19
|
+
className,
|
|
20
|
+
toggleAriaLabel,
|
|
21
|
+
toggleIcon,
|
|
22
|
+
popoverAriaLabel,
|
|
23
|
+
popoverRef = null,
|
|
24
|
+
inlineRef = null,
|
|
25
|
+
children
|
|
26
|
+
}) {
|
|
27
|
+
const containerRef = react.useRef(null);
|
|
28
|
+
const triggerRef = react.useRef(null);
|
|
29
|
+
const internalPopoverRef = react.useRef(null);
|
|
30
|
+
react.useEffect(() => {
|
|
31
|
+
if (variant !== "popover" || !open) return void 0;
|
|
32
|
+
const popover = internalPopoverRef.current;
|
|
33
|
+
if (popover && !popover.contains(document.activeElement)) {
|
|
34
|
+
const focusable = popover.querySelector(
|
|
35
|
+
'[tabindex="0"], button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"])'
|
|
36
|
+
);
|
|
37
|
+
focusable?.focus();
|
|
38
|
+
}
|
|
39
|
+
function handleClickOutside(e) {
|
|
40
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
41
|
+
onOpenChange(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function handleFocusOut(e) {
|
|
45
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
46
|
+
onOpenChange(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function handleEsc(e) {
|
|
50
|
+
if (e.key === "Escape") {
|
|
51
|
+
onOpenChange(false);
|
|
52
|
+
triggerRef.current?.focus();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
56
|
+
document.addEventListener("focusin", handleFocusOut);
|
|
57
|
+
document.addEventListener("keydown", handleEsc);
|
|
58
|
+
return () => {
|
|
59
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
60
|
+
document.removeEventListener("focusin", handleFocusOut);
|
|
61
|
+
document.removeEventListener("keydown", handleEsc);
|
|
62
|
+
};
|
|
63
|
+
}, [open, variant, onOpenChange]);
|
|
64
|
+
if (variant === "inline") {
|
|
65
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: inlineRef, className: `${className}-inline`, children });
|
|
66
|
+
}
|
|
67
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, ref: containerRef, children: [
|
|
68
|
+
open && /* @__PURE__ */ jsxRuntime.jsx(
|
|
69
|
+
"div",
|
|
70
|
+
{
|
|
71
|
+
ref: mergeRefs(internalPopoverRef, popoverRef),
|
|
72
|
+
className: `${className}-popover`,
|
|
73
|
+
role: "dialog",
|
|
74
|
+
"aria-label": popoverAriaLabel,
|
|
75
|
+
children
|
|
76
|
+
}
|
|
77
|
+
),
|
|
78
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
79
|
+
"button",
|
|
80
|
+
{
|
|
81
|
+
ref: triggerRef,
|
|
82
|
+
type: "button",
|
|
83
|
+
className: `${className}-toggle`,
|
|
84
|
+
"aria-label": toggleAriaLabel,
|
|
85
|
+
"aria-expanded": open,
|
|
86
|
+
onClick: () => onOpenChange(!open),
|
|
87
|
+
children: toggleIcon
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
] });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
exports.PopoverPicker = PopoverPicker;
|
|
94
|
+
//# sourceMappingURL=chunk-6OZDKKEP.cjs.map
|
|
95
|
+
//# sourceMappingURL=chunk-6OZDKKEP.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/PopoverPicker.tsx"],"names":["useRef","useEffect","jsx","jsxs"],"mappings":"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","file":"chunk-6OZDKKEP.cjs","sourcesContent":["/* This file is part of @cavebatsofware/riposte-pickers\n * Copyright (C) 2026 Grant DeFayette\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, version 3 of the License (GPL-3.0-only).\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.\n */\nimport React, { useEffect, useRef } from \"react\";\n\n/// Shared toggle-button + popover shell used by the theme and language\n/// pickers. Owns the close-on-outside-interaction listeners (mousedown,\n/// focusin, Escape) and the wrapper JSX. Open/close state is controlled by\n/// the parent so the parent can drive other hooks from the same flag.\n///\n/// Two variants:\n/// - `popover` (default): renders a toggle button that toggles a\n/// `role=\"dialog\"` popover containing the content.\n/// - `inline`: renders only the content (no toggle, always visible),\n/// wrapped in a `${className}-inline` div for use inside layouts where\n/// vertical space is abundant (e.g. a mobile drawer).\n///\n/// Class names follow a `${className}` / `${className}-popover` /\n/// `${className}-toggle` / `${className}-inline` convention so each caller\n/// can keep its own styles.\nfunction mergeRefs<T>(...refs: Array<React.Ref<T> | null | undefined>) {\n return (el: T | null) => {\n refs.forEach((r) => {\n if (typeof r === \"function\") r(el);\n else if (r != null) (r as React.MutableRefObject<T | null>).current = el;\n });\n };\n}\n\nexport interface PopoverPickerProps {\n variant?: \"popover\" | \"inline\";\n open: boolean;\n onOpenChange: (v: boolean) => void;\n className: string;\n toggleAriaLabel: string;\n toggleIcon: React.ReactNode;\n popoverAriaLabel: string;\n popoverRef?: React.Ref<HTMLDivElement> | null;\n inlineRef?: React.Ref<HTMLDivElement> | null;\n children: React.ReactNode;\n}\n\nexport default function PopoverPicker({\n variant = \"popover\",\n open,\n onOpenChange,\n className,\n toggleAriaLabel,\n toggleIcon,\n popoverAriaLabel,\n popoverRef = null,\n inlineRef = null,\n children,\n}: PopoverPickerProps) {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const triggerRef = useRef<HTMLButtonElement | null>(null);\n const internalPopoverRef = useRef<HTMLDivElement | null>(null);\n\n useEffect(() => {\n if (variant !== \"popover\" || !open) return undefined;\n\n // Move focus into the popover so keyboard users land on the first\n // tabbable item rather than staying on the toggle (which would make\n // arrow keys scroll the page and Tab skip past the popover because it\n // sits before the toggle in DOM order). If a consumer hook has already\n // moved focus inside, leave it alone.\n const popover = internalPopoverRef.current;\n if (popover && !popover.contains(document.activeElement)) {\n const focusable = popover.querySelector<HTMLElement>(\n '[tabindex=\"0\"], button:not([disabled]):not([tabindex=\"-1\"]), a[href]:not([tabindex=\"-1\"])',\n );\n focusable?.focus();\n }\n\n function handleClickOutside(e: MouseEvent) {\n if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n onOpenChange(false);\n }\n }\n // Close when focus moves out of the picker container. Without this a\n // keyboard user can Tab from this picker to an adjacent picker's toggle\n // and open the second one while this one stays open, since mousedown\n // never fires.\n function handleFocusOut(e: FocusEvent) {\n if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n onOpenChange(false);\n }\n }\n function handleEsc(e: KeyboardEvent) {\n if (e.key === \"Escape\") {\n onOpenChange(false);\n triggerRef.current?.focus();\n }\n }\n document.addEventListener(\"mousedown\", handleClickOutside);\n document.addEventListener(\"focusin\", handleFocusOut);\n document.addEventListener(\"keydown\", handleEsc);\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside);\n document.removeEventListener(\"focusin\", handleFocusOut);\n document.removeEventListener(\"keydown\", handleEsc);\n };\n }, [open, variant, onOpenChange]);\n\n if (variant === \"inline\") {\n return (\n <div ref={inlineRef} className={`${className}-inline`}>\n {children}\n </div>\n );\n }\n\n return (\n <div className={className} ref={containerRef}>\n {open && (\n <div\n ref={mergeRefs(internalPopoverRef, popoverRef)}\n className={`${className}-popover`}\n role=\"dialog\"\n aria-label={popoverAriaLabel}\n >\n {children}\n </div>\n )}\n <button\n ref={triggerRef}\n type=\"button\"\n className={`${className}-toggle`}\n aria-label={toggleAriaLabel}\n aria-expanded={open}\n onClick={() => onOpenChange(!open)}\n >\n {toggleIcon}\n </button>\n </div>\n );\n}\n"]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/shared/useRovingFocus.ts
|
|
6
|
+
var DEFAULT_SELECTOR = '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]';
|
|
7
|
+
function useRovingFocus(containerRef, active, {
|
|
8
|
+
selector = DEFAULT_SELECTOR,
|
|
9
|
+
orientation = "vertical",
|
|
10
|
+
wrap = true,
|
|
11
|
+
autoFocus = true
|
|
12
|
+
} = {}) {
|
|
13
|
+
react.useEffect(() => {
|
|
14
|
+
if (!active) return void 0;
|
|
15
|
+
const container = containerRef.current;
|
|
16
|
+
if (!container) return void 0;
|
|
17
|
+
function items() {
|
|
18
|
+
return Array.from(
|
|
19
|
+
container.querySelectorAll(selector)
|
|
20
|
+
).filter((el) => !el.hasAttribute("disabled"));
|
|
21
|
+
}
|
|
22
|
+
function setRovingTabIndex(list, focusedIndex) {
|
|
23
|
+
list.forEach((el, i) => {
|
|
24
|
+
el.tabIndex = i === focusedIndex ? 0 : -1;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const initial = items();
|
|
28
|
+
if (initial.length > 0) {
|
|
29
|
+
const checkedIdx = initial.findIndex(
|
|
30
|
+
(el) => el.getAttribute("aria-checked") === "true"
|
|
31
|
+
);
|
|
32
|
+
const startIdx = checkedIdx >= 0 ? checkedIdx : 0;
|
|
33
|
+
setRovingTabIndex(initial, startIdx);
|
|
34
|
+
if (autoFocus) initial[startIdx].focus();
|
|
35
|
+
}
|
|
36
|
+
const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
37
|
+
const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
38
|
+
function onKeyDown(e) {
|
|
39
|
+
if (![nextKey, prevKey, "Home", "End"].includes(e.key)) return;
|
|
40
|
+
const list = items();
|
|
41
|
+
if (list.length === 0) return;
|
|
42
|
+
const current = document.activeElement;
|
|
43
|
+
const idx = current ? list.indexOf(current) : -1;
|
|
44
|
+
let next;
|
|
45
|
+
if (e.key === "Home") {
|
|
46
|
+
next = 0;
|
|
47
|
+
} else if (e.key === "End") {
|
|
48
|
+
next = list.length - 1;
|
|
49
|
+
} else if (e.key === nextKey) {
|
|
50
|
+
next = idx + 1;
|
|
51
|
+
if (next >= list.length) next = wrap ? 0 : list.length - 1;
|
|
52
|
+
} else {
|
|
53
|
+
next = idx - 1;
|
|
54
|
+
if (next < 0) next = wrap ? list.length - 1 : 0;
|
|
55
|
+
}
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
setRovingTabIndex(list, next);
|
|
58
|
+
list[next].focus();
|
|
59
|
+
}
|
|
60
|
+
function onClick(e) {
|
|
61
|
+
const target = e.target?.closest(
|
|
62
|
+
selector
|
|
63
|
+
);
|
|
64
|
+
if (!target) return;
|
|
65
|
+
const list = items();
|
|
66
|
+
const idx = list.indexOf(target);
|
|
67
|
+
if (idx >= 0) setRovingTabIndex(list, idx);
|
|
68
|
+
}
|
|
69
|
+
container.addEventListener("keydown", onKeyDown);
|
|
70
|
+
container.addEventListener("click", onClick);
|
|
71
|
+
return () => {
|
|
72
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
73
|
+
container.removeEventListener("click", onClick);
|
|
74
|
+
items().forEach((el) => {
|
|
75
|
+
el.removeAttribute("tabindex");
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}, [active, containerRef, selector, orientation, wrap, autoFocus]);
|
|
79
|
+
}
|
|
80
|
+
var useRovingFocus_default = useRovingFocus;
|
|
81
|
+
|
|
82
|
+
exports.useRovingFocus_default = useRovingFocus_default;
|
|
83
|
+
//# sourceMappingURL=chunk-7JL223UJ.cjs.map
|
|
84
|
+
//# sourceMappingURL=chunk-7JL223UJ.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/useRovingFocus.ts"],"names":["useEffect"],"mappings":"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","file":"chunk-7JL223UJ.cjs","sourcesContent":["/* This file is part of @cavebatsofware/riposte-pickers\n * Copyright (C) 2026 Grant DeFayette\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, version 3 of the License (GPL-3.0-only).\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.\n */\nimport { useEffect, type RefObject } from \"react\";\n\nexport interface RovingFocusOptions {\n /** CSS selector for the navigable items. Defaults to the ARIA menu roles. */\n selector?: string;\n /** Arrow-key axis. Defaults to vertical. */\n orientation?: \"vertical\" | \"horizontal\";\n /** Wrap from the last item back to the first (and vice versa). Default true. */\n wrap?: boolean;\n /** Move DOM focus into the list on activation. Default true. */\n autoFocus?: boolean;\n}\n\nconst DEFAULT_SELECTOR =\n '[role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"menuitemcheckbox\"]';\n\n/// Wire roving-focus keyboard navigation onto a popover container.\n///\n/// Listens for Arrow / Home / End on the container element while `active`\n/// is true and moves focus across the matching descendants (default: any\n/// element with a `menuitem`, `menuitemradio`, or `menuitemcheckbox` role).\n///\n/// Implements the WAI-ARIA menu pattern's roving tabindex: only one item is\n/// in the tab order at a time (`tabindex=\"0\"`), and arrow keys move both DOM\n/// focus and the tab-order anchor across items. Tab from inside the menu\n/// therefore exits to the next focusable element in the document instead of\n/// cycling through siblings.\n///\n/// On activation the active item (or the first item if none is marked active)\n/// receives focus so screen reader users land inside the menu without an extra\n/// Tab. Consumers that need a focus trap should compose with a trap hook.\nexport function useRovingFocus(\n containerRef: RefObject<HTMLElement | null>,\n active: boolean,\n {\n selector = DEFAULT_SELECTOR,\n orientation = \"vertical\",\n wrap = true,\n autoFocus = true,\n }: RovingFocusOptions = {},\n) {\n useEffect(() => {\n if (!active) return undefined;\n const container = containerRef.current;\n if (!container) return undefined;\n\n function items(): HTMLElement[] {\n return Array.from(\n container!.querySelectorAll<HTMLElement>(selector),\n ).filter((el) => !el.hasAttribute(\"disabled\"));\n }\n\n function setRovingTabIndex(list: HTMLElement[], focusedIndex: number) {\n list.forEach((el, i) => {\n el.tabIndex = i === focusedIndex ? 0 : -1;\n });\n }\n\n const initial = items();\n if (initial.length > 0) {\n // Anchor the tab order on the first menuitemradio that's already\n // checked, otherwise the first item.\n const checkedIdx = initial.findIndex(\n (el) => el.getAttribute(\"aria-checked\") === \"true\",\n );\n const startIdx = checkedIdx >= 0 ? checkedIdx : 0;\n setRovingTabIndex(initial, startIdx);\n if (autoFocus) initial[startIdx].focus();\n }\n\n const nextKey = orientation === \"horizontal\" ? \"ArrowRight\" : \"ArrowDown\";\n const prevKey = orientation === \"horizontal\" ? \"ArrowLeft\" : \"ArrowUp\";\n\n function onKeyDown(e: KeyboardEvent) {\n if (![nextKey, prevKey, \"Home\", \"End\"].includes(e.key)) return;\n const list = items();\n if (list.length === 0) return;\n const current = document.activeElement as HTMLElement | null;\n const idx = current ? list.indexOf(current) : -1;\n let next: number;\n if (e.key === \"Home\") {\n next = 0;\n } else if (e.key === \"End\") {\n next = list.length - 1;\n } else if (e.key === nextKey) {\n next = idx + 1;\n if (next >= list.length) next = wrap ? 0 : list.length - 1;\n } else {\n next = idx - 1;\n if (next < 0) next = wrap ? list.length - 1 : 0;\n }\n e.preventDefault();\n setRovingTabIndex(list, next);\n list[next].focus();\n }\n\n // Click on any item also re-anchors the tab order. Without this, a\n // consumer that updates selection state via click leaves the tab-order\n // anchor stuck on the previously-selected item: aria-checked follows\n // React state, but tabindex was set imperatively at activation and never\n // moves.\n function onClick(e: MouseEvent) {\n const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(\n selector,\n );\n if (!target) return;\n const list = items();\n const idx = list.indexOf(target);\n if (idx >= 0) setRovingTabIndex(list, idx);\n }\n\n container.addEventListener(\"keydown\", onKeyDown);\n container.addEventListener(\"click\", onClick);\n return () => {\n container.removeEventListener(\"keydown\", onKeyDown);\n container.removeEventListener(\"click\", onClick);\n // Restore items to default tab order on cleanup so the next mount\n // starts from a clean slate.\n items().forEach((el) => {\n el.removeAttribute(\"tabindex\");\n });\n };\n }, [active, containerRef, selector, orientation, wrap, autoFocus]);\n}\n\nexport default useRovingFocus;\n"]}
|