@sweberdev/witness-react 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +18 -2
- package/dist/index.cjs +346 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +321 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -3
- package/src/AiContent.tsx +65 -0
- package/src/AiLabel.tsx +138 -0
- package/src/AiNotice.tsx +104 -0
- package/src/context.tsx +40 -0
- package/src/index.ts +5 -0
- package/src/useAiDisclosure.ts +67 -0
package/src/AiNotice.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { DisclosureEvent, DisclosureKind } from "@sweberdev/witness";
|
|
2
|
+
import { type ReactNode, useEffect, useId, useState } from "react";
|
|
3
|
+
import { useWitnessMessages } from "./context.js";
|
|
4
|
+
import { useAiDisclosure } from "./useAiDisclosure.js";
|
|
5
|
+
|
|
6
|
+
export interface AiNoticeProps {
|
|
7
|
+
/** Default `chatbot`. Also fits `emotion-recognition` and `biometric-categorisation`. */
|
|
8
|
+
kind?: DisclosureKind;
|
|
9
|
+
/** Your id for this disclosure. Default: the kind. */
|
|
10
|
+
id?: string;
|
|
11
|
+
/** Bump when the wording changes, so people see it again. Default "1". */
|
|
12
|
+
version?: string;
|
|
13
|
+
locale?: string | readonly string[];
|
|
14
|
+
/** Link to a page that explains how you use AI. */
|
|
15
|
+
href?: string;
|
|
16
|
+
storage?: "local" | "session" | "memory";
|
|
17
|
+
/** Evidence hook: called when the notice is shown and acknowledged. */
|
|
18
|
+
onEvent?: (event: DisclosureEvent) => void;
|
|
19
|
+
/** Replaces the default body text. */
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The AI notice for a chat (Art. 50(1)): expanded until acknowledged, then a compact label
|
|
26
|
+
* that stays visible and can be reopened. Style it with `@sweberdev/witness/styles.css`.
|
|
27
|
+
*/
|
|
28
|
+
export function AiNotice({
|
|
29
|
+
kind = "chatbot",
|
|
30
|
+
id,
|
|
31
|
+
version,
|
|
32
|
+
locale,
|
|
33
|
+
href,
|
|
34
|
+
storage,
|
|
35
|
+
onEvent,
|
|
36
|
+
children,
|
|
37
|
+
className,
|
|
38
|
+
}: AiNoticeProps) {
|
|
39
|
+
const { locale: code, messages } = useWitnessMessages(locale);
|
|
40
|
+
const disclosure = useAiDisclosure({
|
|
41
|
+
kind,
|
|
42
|
+
locale: code,
|
|
43
|
+
...(id ? { id } : {}),
|
|
44
|
+
...(version ? { version } : {}),
|
|
45
|
+
...(storage ? { storage } : {}),
|
|
46
|
+
...(onEvent ? { onEvent } : {}),
|
|
47
|
+
});
|
|
48
|
+
const [reopened, setReopened] = useState(false);
|
|
49
|
+
const titleId = useId();
|
|
50
|
+
const text = messages.kinds[kind];
|
|
51
|
+
const full = reopened || disclosure.needsAcknowledgement;
|
|
52
|
+
const { markShown } = disclosure;
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (full) markShown();
|
|
56
|
+
}, [full, markShown]);
|
|
57
|
+
|
|
58
|
+
if (!full) {
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
className={cx("witness-notice--compact", className)}
|
|
63
|
+
aria-label={`${text.label}: ${messages.ui.details}`}
|
|
64
|
+
onClick={() => setReopened(true)}
|
|
65
|
+
>
|
|
66
|
+
<span className="witness-label__icon" aria-hidden="true">
|
|
67
|
+
AI
|
|
68
|
+
</span>
|
|
69
|
+
{text.label}
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section className={cx("witness-notice", className)} role="note" aria-labelledby={titleId}>
|
|
76
|
+
<span className="witness-label__icon" aria-hidden="true">
|
|
77
|
+
AI
|
|
78
|
+
</span>
|
|
79
|
+
<div>
|
|
80
|
+
<h2 id={titleId} className="witness-notice__title">
|
|
81
|
+
{text.title}
|
|
82
|
+
</h2>
|
|
83
|
+
<p className="witness-notice__body">{children ?? text.body}</p>
|
|
84
|
+
<div className="witness-notice__actions">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className="witness-notice__ack"
|
|
88
|
+
onClick={() => {
|
|
89
|
+
setReopened(false);
|
|
90
|
+
disclosure.acknowledge();
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{messages.ui.acknowledge}
|
|
94
|
+
</button>
|
|
95
|
+
{href ? <a href={href}>{messages.ui.moreInfo}</a> : null}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function cx(...names: Array<string | undefined | false>): string {
|
|
103
|
+
return names.filter(Boolean).join(" ");
|
|
104
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMessages,
|
|
3
|
+
type LocaleMessages,
|
|
4
|
+
type LocaleOverride,
|
|
5
|
+
resolveLocale,
|
|
6
|
+
} from "@sweberdev/witness";
|
|
7
|
+
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
8
|
+
|
|
9
|
+
interface WitnessContextValue {
|
|
10
|
+
locale: string;
|
|
11
|
+
messages: LocaleMessages;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WitnessContext = createContext<WitnessContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export interface WitnessProviderProps {
|
|
17
|
+
/** A locale tag such as "de-CH", or a list such as `navigator.languages`. Default "en". */
|
|
18
|
+
locale?: string | readonly string[];
|
|
19
|
+
/** Overrides for the wording, e.g. the informal "du" in German. */
|
|
20
|
+
messages?: LocaleOverride;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sets the language and wording for all Witness components below it. */
|
|
25
|
+
export function WitnessProvider({ locale, messages, children }: WitnessProviderProps) {
|
|
26
|
+
const value = useMemo(
|
|
27
|
+
() => ({ locale: resolveLocale(locale), messages: getMessages(locale, messages) }),
|
|
28
|
+
[locale, messages],
|
|
29
|
+
);
|
|
30
|
+
return <WitnessContext.Provider value={value}>{children}</WitnessContext.Provider>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The active locale and messages. A `locale` prop on a component wins over the provider. */
|
|
34
|
+
export function useWitnessMessages(locale?: string | readonly string[]): WitnessContextValue {
|
|
35
|
+
const context = useContext(WitnessContext);
|
|
36
|
+
return useMemo(() => {
|
|
37
|
+
if (locale) return { locale: resolveLocale(locale), messages: getMessages(locale) };
|
|
38
|
+
return context ?? { locale: "en", messages: getMessages("en") };
|
|
39
|
+
}, [context, locale]);
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AiContent, type AiContentProps } from "./AiContent.js";
|
|
2
|
+
export { AiLabel, type AiLabelProps } from "./AiLabel.js";
|
|
3
|
+
export { AiNotice, type AiNoticeProps } from "./AiNotice.js";
|
|
4
|
+
export { useWitnessMessages, WitnessProvider, type WitnessProviderProps } from "./context.js";
|
|
5
|
+
export { type UseAiDisclosureResult, useAiDisclosure } from "./useAiDisclosure.js";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createDisclosure, type Disclosure, type DisclosureOptions } from "@sweberdev/witness";
|
|
2
|
+
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
|
|
4
|
+
export interface UseAiDisclosureResult {
|
|
5
|
+
/** True until the current version was acknowledged. Always true during server rendering. */
|
|
6
|
+
needsAcknowledgement: boolean;
|
|
7
|
+
acknowledgedAt: string | null;
|
|
8
|
+
acknowledge: () => void;
|
|
9
|
+
/** Records that the notice was rendered (emits a `shown` event once). */
|
|
10
|
+
markShown: () => void;
|
|
11
|
+
reset: () => void;
|
|
12
|
+
disclosure: Disclosure;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* State for a custom AI disclosure UI. The server snapshot always needs acknowledgement, so
|
|
17
|
+
* the full notice is in the server HTML and collapses after hydration for people who already
|
|
18
|
+
* acknowledged it.
|
|
19
|
+
*/
|
|
20
|
+
export function useAiDisclosure(options: DisclosureOptions = {}): UseAiDisclosureResult {
|
|
21
|
+
const onEvent = useRef(options.onEvent);
|
|
22
|
+
onEvent.current = options.onEvent;
|
|
23
|
+
const { id, kind, version, locale, storage } = options;
|
|
24
|
+
|
|
25
|
+
const disclosure = useMemo(
|
|
26
|
+
() =>
|
|
27
|
+
createDisclosure({
|
|
28
|
+
...(id ? { id } : {}),
|
|
29
|
+
...(kind ? { kind } : {}),
|
|
30
|
+
...(version ? { version } : {}),
|
|
31
|
+
...(locale ? { locale } : {}),
|
|
32
|
+
...(storage ? { storage } : {}),
|
|
33
|
+
onEvent: (event) => onEvent.current?.(event),
|
|
34
|
+
}),
|
|
35
|
+
[id, kind, version, locale, storage],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const subscribe = useCallback(
|
|
39
|
+
(listener: () => void) => {
|
|
40
|
+
const unsubscribe = disclosure.subscribe(listener);
|
|
41
|
+
// Another tab acknowledged or reset: the browser tells us through a storage event.
|
|
42
|
+
const onStorage = (event: StorageEvent) => {
|
|
43
|
+
if (event.key === null || event.key.startsWith("witness:ack:")) listener();
|
|
44
|
+
};
|
|
45
|
+
if (typeof window !== "undefined") window.addEventListener("storage", onStorage);
|
|
46
|
+
return () => {
|
|
47
|
+
unsubscribe();
|
|
48
|
+
if (typeof window !== "undefined") window.removeEventListener("storage", onStorage);
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
[disclosure],
|
|
52
|
+
);
|
|
53
|
+
const acknowledgedAt = useSyncExternalStore(
|
|
54
|
+
subscribe,
|
|
55
|
+
() => disclosure.acknowledgedAt(),
|
|
56
|
+
() => null,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
needsAcknowledgement: acknowledgedAt === null,
|
|
61
|
+
acknowledgedAt,
|
|
62
|
+
acknowledge: disclosure.acknowledge,
|
|
63
|
+
markShown: disclosure.markShown,
|
|
64
|
+
reset: disclosure.reset,
|
|
65
|
+
disclosure,
|
|
66
|
+
};
|
|
67
|
+
}
|