speak-friend-react 0.0.0-stage → 0.1.0-alpha.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 +71 -2
- package/dist/guide.d.ts +25 -0
- package/dist/guide.js +78 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -0
- package/dist/provider.d.ts +17 -0
- package/dist/provider.js +42 -0
- package/dist/ring.d.ts +5 -0
- package/dist/ring.js +45 -0
- package/dist/styles.css +231 -0
- package/dist/wait-for-target.d.ts +4 -0
- package/dist/wait-for-target.js +27 -0
- package/package.json +61 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepKind Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# speak-friend-react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React bindings for [`speak-friend`](../core): a docked tour guide with a highlight ring. Built with a focus on accessibility.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { TourGuide, TourProvider, useTours } from "speak-friend-react";
|
|
7
|
+
import "speak-friend-react/styles.css";
|
|
8
|
+
|
|
9
|
+
<TourProvider tours={tours} store={store} context={{ roles }} navigate={(route) => router.push(route)}>
|
|
10
|
+
<App />
|
|
11
|
+
<TourGuide
|
|
12
|
+
labels={{
|
|
13
|
+
counter: (n, total) => `Step ${n} of ${total}`,
|
|
14
|
+
back: "Back",
|
|
15
|
+
next: "Next",
|
|
16
|
+
finish: "Finish",
|
|
17
|
+
close: "Close",
|
|
18
|
+
}}
|
|
19
|
+
/>
|
|
20
|
+
</TourProvider>;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Mark each step's target with `data-tour`:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<section data-tour="next-step">...</section>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Start a tour from anywhere under the provider:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
const tours = useTours();
|
|
33
|
+
await tours.start("welcome");
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Behaviour
|
|
37
|
+
|
|
38
|
+
- The guide docks at the bottom, on the `end` side by default (`position="start"` mirrors it). On narrow screens it
|
|
39
|
+
becomes a bottom sheet.
|
|
40
|
+
- A ring marks the target. The page is not dimmed and stays usable.
|
|
41
|
+
- Focus moves to the step title on every step. `announce="live"` keeps focus and announces the step instead.
|
|
42
|
+
- Escape closes the tour and returns focus to where it was. Starting again resumes from the same step.
|
|
43
|
+
- A step whose target is not on the page is skipped and leaves the count.
|
|
44
|
+
- While the guide is open, `scroll-padding-bottom` keeps focused elements clear of it.
|
|
45
|
+
|
|
46
|
+
## Props of `TourProvider`
|
|
47
|
+
|
|
48
|
+
`tours`, `store`, `context`, `navigate` and `maxDismissals` are passed to the engine. Two more:
|
|
49
|
+
|
|
50
|
+
| Prop | Default | Description |
|
|
51
|
+
| --------------- | --------------- | -------------------------------------------------------------------- |
|
|
52
|
+
| `targetTimeout` | `1500` | Milliseconds to wait for a step's target before skipping the step. |
|
|
53
|
+
| `onError` | `console.error` | Called when a step change fails, for example when the store rejects. |
|
|
54
|
+
|
|
55
|
+
## Props of `TourGuide`
|
|
56
|
+
|
|
57
|
+
| Prop | Default | Description |
|
|
58
|
+
| --------------- | -------- | ----------------------------------------------------------- |
|
|
59
|
+
| `labels` | required | Every string: `counter`, `back`, `next`, `finish`, `close`. |
|
|
60
|
+
| `position` | `end` | `start` or `end`. |
|
|
61
|
+
| `headingLevel` | `2` | Heading level of the step title. |
|
|
62
|
+
| `announce` | `focus` | `focus` or `live`. |
|
|
63
|
+
| `closeOnEscape` | `true` | |
|
|
64
|
+
| `className` | | Added to the guide. |
|
|
65
|
+
|
|
66
|
+
## Styling
|
|
67
|
+
|
|
68
|
+
`speak-friend-react/styles.css` defines the layout and a neutral light and dark theme. Override the custom properties to
|
|
69
|
+
match your app: `--sf-surface`, `--sf-ink`, `--sf-line`, `--sf-accent`, `--sf-accent-ink`, `--sf-primary`,
|
|
70
|
+
`--sf-primary-ink`, `--sf-primary-edge`, `--sf-done`, `--sf-halo`, `--sf-width`, `--sf-gap`, `--sf-z`.
|
|
71
|
+
|
|
72
|
+
The ring and the guide keep their borders in Windows high contrast mode, and animation is off with reduced motion.
|
package/dist/guide.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type JSX } from "react";
|
|
2
|
+
/** Every string the guide shows. The library has no text of its own. */
|
|
3
|
+
export interface GuideLabels {
|
|
4
|
+
/** Text for the step counter, for example `Step 2 of 4`. `index` is 1-based. */
|
|
5
|
+
counter: (index: number, total: number) => string;
|
|
6
|
+
back: string;
|
|
7
|
+
next: string;
|
|
8
|
+
/** Label of the next button on the last step. */
|
|
9
|
+
finish: string;
|
|
10
|
+
close: string;
|
|
11
|
+
}
|
|
12
|
+
export interface TourGuideProps {
|
|
13
|
+
labels: GuideLabels;
|
|
14
|
+
/** Horizontal side of the dock. Logical, so it mirrors in right-to-left layouts. Default `end`. */
|
|
15
|
+
position?: "start" | "end";
|
|
16
|
+
/** Heading level of the step title. Default 2. */
|
|
17
|
+
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
18
|
+
/** `focus` moves focus to the step title; `live` keeps focus and announces the step. Default `focus`. */
|
|
19
|
+
announce?: "focus" | "live";
|
|
20
|
+
/** Close the tour on Escape. Default true. */
|
|
21
|
+
closeOnEscape?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
/** The docked guide with its highlight ring. Renders only while a tour is running. */
|
|
25
|
+
export declare function TourGuide({ labels, position, headingLevel, announce, closeOnEscape, className, }: TourGuideProps): JSX.Element | null;
|
package/dist/guide.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { createElement, useEffect, useId, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { Ring } from "./ring.js";
|
|
5
|
+
import { useRun, useTourState, useTours } from "./provider.js";
|
|
6
|
+
import { findTarget } from "./wait-for-target.js";
|
|
7
|
+
const noSubscription = () => () => undefined;
|
|
8
|
+
const reducedMotion = () => typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
9
|
+
/** The docked guide with its highlight ring. Renders only while a tour is running. */
|
|
10
|
+
export function TourGuide({ labels, position = "end", headingLevel = 2, announce = "focus", closeOnEscape = true, className, }) {
|
|
11
|
+
const tours = useTours();
|
|
12
|
+
const run = useRun();
|
|
13
|
+
const state = useTourState();
|
|
14
|
+
const id = useId();
|
|
15
|
+
const titleRef = useRef(null);
|
|
16
|
+
const guideRef = useRef(null);
|
|
17
|
+
const opener = useRef(null);
|
|
18
|
+
// False on the server and during hydration, true afterwards: the guide only exists in the browser.
|
|
19
|
+
const mounted = useSyncExternalStore(noSubscription, () => true, () => false);
|
|
20
|
+
const running = state.status === "running" && state.step !== null;
|
|
21
|
+
const stepId = state.step?.id;
|
|
22
|
+
// Remember where focus was when the tour started and give it back when the tour ends.
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
if (!running)
|
|
25
|
+
return;
|
|
26
|
+
opener.current = document.activeElement;
|
|
27
|
+
return () => {
|
|
28
|
+
const el = opener.current;
|
|
29
|
+
if (el instanceof HTMLElement && el.isConnected)
|
|
30
|
+
el.focus({ preventScroll: true });
|
|
31
|
+
opener.current = null;
|
|
32
|
+
};
|
|
33
|
+
}, [running]);
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
if (running && announce === "focus")
|
|
36
|
+
titleRef.current?.focus({ preventScroll: true });
|
|
37
|
+
}, [running, stepId, announce]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!running || !state.step)
|
|
40
|
+
return;
|
|
41
|
+
findTarget(state.step.target)?.scrollIntoView({ block: "center", behavior: reducedMotion() ? "auto" : "smooth" });
|
|
42
|
+
}, [running, state.step]);
|
|
43
|
+
// Keep focused elements clear of the dock (WCAG 2.4.11).
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const guide = guideRef.current;
|
|
46
|
+
if (!running || !guide)
|
|
47
|
+
return;
|
|
48
|
+
const style = document.documentElement.style;
|
|
49
|
+
const before = style.getPropertyValue("scroll-padding-bottom");
|
|
50
|
+
const apply = () => style.setProperty("scroll-padding-bottom", `${guide.offsetHeight + 24}px`);
|
|
51
|
+
apply();
|
|
52
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(apply);
|
|
53
|
+
observer?.observe(guide);
|
|
54
|
+
return () => {
|
|
55
|
+
observer?.disconnect();
|
|
56
|
+
if (before)
|
|
57
|
+
style.setProperty("scroll-padding-bottom", before);
|
|
58
|
+
else
|
|
59
|
+
style.removeProperty("scroll-padding-bottom");
|
|
60
|
+
};
|
|
61
|
+
}, [running]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!running || !closeOnEscape)
|
|
64
|
+
return;
|
|
65
|
+
const onKey = (e) => {
|
|
66
|
+
if (e.key === "Escape" && !e.defaultPrevented && !e.isComposing)
|
|
67
|
+
run(tours.close());
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener("keydown", onKey);
|
|
70
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
71
|
+
}, [running, closeOnEscape, tours, run]);
|
|
72
|
+
if (!mounted || !running || !state.step)
|
|
73
|
+
return null;
|
|
74
|
+
const { step, index, total } = state;
|
|
75
|
+
const last = index === total - 1;
|
|
76
|
+
const counter = labels.counter(index + 1, total);
|
|
77
|
+
return (_jsxs(_Fragment, { children: [_jsx(Ring, { target: step.target }, step.id), createPortal(_jsxs("section", { ref: guideRef, role: "dialog", "aria-labelledby": id, className: className ? `sf-guide ${className}` : "sf-guide", "data-position": position, children: [_jsxs("div", { className: "sf-band", children: [_jsx("span", { className: "sf-count", children: counter }), _jsx("span", { className: "sf-progress", "aria-hidden": "true", children: Array.from({ length: total }, (_, i) => (_jsx("i", { className: i < index ? "sf-done" : i === index ? "sf-current" : undefined }, i))) })] }), _jsxs("div", { className: "sf-body", children: [createElement(`h${headingLevel}`, { id, ref: titleRef, tabIndex: -1, className: "sf-title" }, step.title), _jsx("p", { className: "sf-text", children: step.body }), _jsxs("div", { className: "sf-actions", children: [_jsx("button", { type: "button", className: "sf-button", onClick: () => run(tours.back()), disabled: index === 0, children: labels.back }), _jsx("button", { type: "button", className: "sf-button sf-primary", onClick: () => run(tours.next()), children: last ? labels.finish : labels.next }), _jsx("button", { type: "button", className: "sf-button sf-quiet", onClick: () => run(tours.close()), children: labels.close })] })] }), announce === "live" && (_jsx("div", { className: "sf-visually-hidden", role: "status", children: `${counter}: ${step.title}` }))] }), document.body)] }));
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Creates the tour controller and shares it with the components below. */
|
|
2
|
+
export { TourProvider, useTours, useTourState } from "./provider.js";
|
|
3
|
+
/** Props of `TourProvider`. */
|
|
4
|
+
export type { TourProviderProps } from "./provider.js";
|
|
5
|
+
/** The docked guide with its highlight ring; renders only while a tour is running. */
|
|
6
|
+
export { TourGuide } from "./guide.js";
|
|
7
|
+
/** Strings and props of `TourGuide`. */
|
|
8
|
+
export type { GuideLabels, TourGuideProps } from "./guide.js";
|
|
9
|
+
/** A ring around the element marked `data-tour`. */
|
|
10
|
+
export { Ring } from "./ring.js";
|
|
11
|
+
/** Resolves true when a `data-tour` target is on the page, waiting up to a timeout. */
|
|
12
|
+
export { waitForTarget } from "./wait-for-target.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Creates the tour controller and shares it with the components below. */
|
|
2
|
+
export { TourProvider, useTours, useTourState } from "./provider.js";
|
|
3
|
+
/** The docked guide with its highlight ring; renders only while a tour is running. */
|
|
4
|
+
export { TourGuide } from "./guide.js";
|
|
5
|
+
/** A ring around the element marked `data-tour`. */
|
|
6
|
+
export { Ring } from "./ring.js";
|
|
7
|
+
/** Resolves true when a `data-tour` target is on the page, waiting up to a timeout. */
|
|
8
|
+
export { waitForTarget } from "./wait-for-target.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type JSX, type ReactNode } from "react";
|
|
2
|
+
import { type Options, type Snapshot, type Tours } from "speak-friend";
|
|
3
|
+
export interface TourProviderProps<C> extends Omit<Options<C>, "isAvailable"> {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** How long to wait for a step's target to appear before skipping the step, in ms. Default 1500. */
|
|
6
|
+
targetTimeout?: number;
|
|
7
|
+
/** Called when a step change fails, for example when the progress store rejects. Default `console.error`. */
|
|
8
|
+
onError?: (error: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
/** Creates the tour controller and shares it with the components below. */
|
|
11
|
+
export declare function TourProvider<C>({ children, targetTimeout, onError, ...options }: TourProviderProps<C>): JSX.Element;
|
|
12
|
+
/** The tour controller: offered, start, next, back, close, restart, dismiss. */
|
|
13
|
+
export declare function useTours<C = unknown>(): Tours<C>;
|
|
14
|
+
/** Runs a step change and reports a failure through the provider's `onError`. */
|
|
15
|
+
export declare function useRun(): (action: Promise<void>) => void;
|
|
16
|
+
/** The current state; re-renders on every change. */
|
|
17
|
+
export declare function useTourState<C = unknown>(): Snapshot<C>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
|
3
|
+
import { createTours } from "speak-friend";
|
|
4
|
+
import { waitForTarget } from "./wait-for-target.js";
|
|
5
|
+
const TourContext = createContext(null);
|
|
6
|
+
const logError = (error) => console.error(error);
|
|
7
|
+
/** Creates the tour controller and shares it with the components below. */
|
|
8
|
+
export function TourProvider({ children, targetTimeout = 1500, onError = logError, ...options }) {
|
|
9
|
+
const { tours, store, context, navigate, maxDismissals } = options;
|
|
10
|
+
const value = useMemo(() => ({
|
|
11
|
+
tours: createTours({
|
|
12
|
+
tours,
|
|
13
|
+
store,
|
|
14
|
+
context,
|
|
15
|
+
navigate,
|
|
16
|
+
maxDismissals,
|
|
17
|
+
isAvailable: (step) => waitForTarget(step.target, targetTimeout),
|
|
18
|
+
}),
|
|
19
|
+
onError,
|
|
20
|
+
}), [tours, store, context, navigate, maxDismissals, targetTimeout, onError]);
|
|
21
|
+
return _jsx(TourContext.Provider, { value: value, children: children });
|
|
22
|
+
}
|
|
23
|
+
const useContextValue = () => {
|
|
24
|
+
const value = useContext(TourContext);
|
|
25
|
+
if (!value)
|
|
26
|
+
throw new Error("useTours must be used inside TourProvider");
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
/** The tour controller: offered, start, next, back, close, restart, dismiss. */
|
|
30
|
+
export function useTours() {
|
|
31
|
+
return useContextValue().tours;
|
|
32
|
+
}
|
|
33
|
+
/** Runs a step change and reports a failure through the provider's `onError`. */
|
|
34
|
+
export function useRun() {
|
|
35
|
+
const { onError } = useContextValue();
|
|
36
|
+
return (action) => void action.catch(onError);
|
|
37
|
+
}
|
|
38
|
+
/** The current state; re-renders on every change. */
|
|
39
|
+
export function useTourState() {
|
|
40
|
+
const t = useTours();
|
|
41
|
+
return useSyncExternalStore((notify) => t.subscribe(notify), t.snapshot, t.snapshot);
|
|
42
|
+
}
|
package/dist/ring.d.ts
ADDED
package/dist/ring.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { findTarget } from "./wait-for-target.js";
|
|
5
|
+
/** Follows the target's box through scrolling, resizing and layout changes. */
|
|
6
|
+
function useBox(target) {
|
|
7
|
+
const [box, setBox] = useState(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const el = findTarget(target);
|
|
10
|
+
if (!el)
|
|
11
|
+
return;
|
|
12
|
+
let frame = 0;
|
|
13
|
+
const measure = () => {
|
|
14
|
+
frame = 0;
|
|
15
|
+
const r = el.getBoundingClientRect();
|
|
16
|
+
setBox((p) => p && p.top === r.top && p.left === r.left && p.width === r.width && p.height === r.height
|
|
17
|
+
? p
|
|
18
|
+
: { top: r.top, left: r.left, width: r.width, height: r.height });
|
|
19
|
+
};
|
|
20
|
+
const schedule = () => {
|
|
21
|
+
if (!frame)
|
|
22
|
+
frame = requestAnimationFrame(measure);
|
|
23
|
+
};
|
|
24
|
+
measure();
|
|
25
|
+
window.addEventListener("scroll", schedule, { capture: true, passive: true });
|
|
26
|
+
window.addEventListener("resize", schedule);
|
|
27
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
|
|
28
|
+
observer?.observe(el);
|
|
29
|
+
return () => {
|
|
30
|
+
window.removeEventListener("scroll", schedule, { capture: true });
|
|
31
|
+
window.removeEventListener("resize", schedule);
|
|
32
|
+
observer?.disconnect();
|
|
33
|
+
if (frame)
|
|
34
|
+
cancelAnimationFrame(frame);
|
|
35
|
+
};
|
|
36
|
+
}, [target]);
|
|
37
|
+
return box;
|
|
38
|
+
}
|
|
39
|
+
/** A ring around the element marked `data-tour="<target>"`. It never blocks pointer events. */
|
|
40
|
+
export function Ring({ target }) {
|
|
41
|
+
const box = useBox(target);
|
|
42
|
+
if (!box || typeof document === "undefined")
|
|
43
|
+
return null;
|
|
44
|
+
return createPortal(_jsx("div", { className: "sf-ring", "aria-hidden": "true", style: { top: box.top, left: box.left, width: box.width, height: box.height } }), document.body);
|
|
45
|
+
}
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
.sf-guide,
|
|
2
|
+
.sf-ring {
|
|
3
|
+
--sf-surface: oklch(100% 0 0);
|
|
4
|
+
--sf-ink: oklch(23% 0.03 285);
|
|
5
|
+
--sf-line: oklch(62% 0.02 285);
|
|
6
|
+
--sf-accent: oklch(44% 0.19 290);
|
|
7
|
+
--sf-accent-ink: oklch(100% 0 0);
|
|
8
|
+
--sf-primary: oklch(87% 0.2 125);
|
|
9
|
+
--sf-primary-ink: oklch(19% 0.02 285);
|
|
10
|
+
--sf-primary-edge: oklch(53% 0.13 125);
|
|
11
|
+
--sf-done: oklch(53% 0.13 125);
|
|
12
|
+
--sf-halo: oklch(100% 0 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
.sf-guide,
|
|
17
|
+
.sf-ring {
|
|
18
|
+
--sf-surface: oklch(23% 0.035 285);
|
|
19
|
+
--sf-ink: oklch(95% 0.012 95);
|
|
20
|
+
--sf-line: oklch(62% 0.04 285);
|
|
21
|
+
--sf-accent: oklch(80% 0.11 300);
|
|
22
|
+
--sf-accent-ink: oklch(19% 0.02 285);
|
|
23
|
+
--sf-primary-edge: transparent;
|
|
24
|
+
--sf-done: oklch(81% 0.16 125);
|
|
25
|
+
--sf-halo: oklch(19% 0.02 285);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.sf-visually-hidden {
|
|
30
|
+
position: absolute;
|
|
31
|
+
width: 1px;
|
|
32
|
+
height: 1px;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
clip-path: inset(50%);
|
|
35
|
+
white-space: nowrap;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.sf-ring {
|
|
39
|
+
position: fixed;
|
|
40
|
+
z-index: var(--sf-z, 1000);
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
border-radius: var(--sf-ring-radius, 12px);
|
|
43
|
+
outline: 3px solid var(--sf-accent);
|
|
44
|
+
outline-offset: 4px;
|
|
45
|
+
box-shadow: 0 0 0 4px var(--sf-halo);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.sf-guide {
|
|
49
|
+
position: fixed;
|
|
50
|
+
z-index: calc(var(--sf-z, 1000) + 1);
|
|
51
|
+
inset-block-end: var(--sf-gap, 16px);
|
|
52
|
+
width: min(var(--sf-width, 340px), calc(100vw - 2 * var(--sf-gap, 16px)));
|
|
53
|
+
max-height: calc(100dvh - 2 * var(--sf-gap, 16px));
|
|
54
|
+
overflow: auto;
|
|
55
|
+
color: var(--sf-ink);
|
|
56
|
+
background: var(--sf-surface);
|
|
57
|
+
border: 2px solid var(--sf-accent);
|
|
58
|
+
border-radius: 16px;
|
|
59
|
+
box-shadow:
|
|
60
|
+
0 4px 8px oklch(0% 0 0 / 0.12),
|
|
61
|
+
0 24px 48px -12px oklch(0% 0 0 / 0.5);
|
|
62
|
+
font: inherit;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.sf-guide[data-position="end"] {
|
|
66
|
+
inset-inline-end: var(--sf-gap, 16px);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.sf-guide[data-position="start"] {
|
|
70
|
+
inset-inline-start: var(--sf-gap, 16px);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@media (max-width: 640px) {
|
|
74
|
+
.sf-guide {
|
|
75
|
+
inset-inline: 0;
|
|
76
|
+
inset-block-end: 0;
|
|
77
|
+
width: auto;
|
|
78
|
+
max-height: 70dvh;
|
|
79
|
+
padding-block-end: env(safe-area-inset-bottom, 0px);
|
|
80
|
+
border-width: 2px 0 0;
|
|
81
|
+
border-radius: 16px 16px 0 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.sf-band {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: space-between;
|
|
89
|
+
gap: 8px;
|
|
90
|
+
padding: 8px 16px;
|
|
91
|
+
color: var(--sf-accent-ink);
|
|
92
|
+
background: var(--sf-accent);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.sf-count {
|
|
96
|
+
font-size: 0.75rem;
|
|
97
|
+
font-weight: 700;
|
|
98
|
+
letter-spacing: 0.06em;
|
|
99
|
+
text-transform: uppercase;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.sf-progress {
|
|
103
|
+
display: flex;
|
|
104
|
+
gap: 4px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.sf-progress i {
|
|
108
|
+
width: 16px;
|
|
109
|
+
height: 4px;
|
|
110
|
+
border-radius: 2px;
|
|
111
|
+
background: color-mix(in srgb, var(--sf-accent-ink) 35%, transparent);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.sf-progress i.sf-done {
|
|
115
|
+
background: color-mix(in srgb, var(--sf-accent-ink) 70%, transparent);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.sf-progress i.sf-current {
|
|
119
|
+
background: var(--sf-accent-ink);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.sf-body {
|
|
123
|
+
padding: 12px 16px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.sf-title {
|
|
127
|
+
margin: 0 0 4px;
|
|
128
|
+
font-size: 1rem;
|
|
129
|
+
font-weight: 800;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.sf-title:focus {
|
|
133
|
+
outline: none;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.sf-title:focus-visible {
|
|
137
|
+
outline: 2px solid var(--sf-accent);
|
|
138
|
+
outline-offset: 4px;
|
|
139
|
+
border-radius: 4px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.sf-text {
|
|
143
|
+
margin: 0 0 12px;
|
|
144
|
+
font-size: 0.9rem;
|
|
145
|
+
line-height: 1.5;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.sf-actions {
|
|
149
|
+
display: flex;
|
|
150
|
+
flex-wrap: wrap;
|
|
151
|
+
gap: 8px;
|
|
152
|
+
align-items: center;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.sf-button {
|
|
156
|
+
min-height: 32px;
|
|
157
|
+
padding: 4px 12px;
|
|
158
|
+
font: inherit;
|
|
159
|
+
font-weight: 700;
|
|
160
|
+
color: inherit;
|
|
161
|
+
background: transparent;
|
|
162
|
+
border: 1px solid var(--sf-line);
|
|
163
|
+
border-radius: 8px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@media (pointer: coarse) {
|
|
168
|
+
.sf-button {
|
|
169
|
+
min-height: 44px;
|
|
170
|
+
padding-inline: 16px;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.sf-button:disabled {
|
|
175
|
+
opacity: 0.5;
|
|
176
|
+
cursor: default;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.sf-button:focus-visible {
|
|
180
|
+
outline: 3px solid var(--sf-accent);
|
|
181
|
+
outline-offset: 4px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.sf-primary {
|
|
185
|
+
color: var(--sf-primary-ink);
|
|
186
|
+
background: var(--sf-primary);
|
|
187
|
+
border: 2px solid var(--sf-primary-edge);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.sf-quiet {
|
|
191
|
+
margin-inline-start: auto;
|
|
192
|
+
border-color: transparent;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
196
|
+
.sf-guide {
|
|
197
|
+
animation: sf-in 0.18s ease-out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@keyframes sf-in {
|
|
201
|
+
from {
|
|
202
|
+
opacity: 0;
|
|
203
|
+
transform: translateY(8px);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@media (prefers-contrast: more) {
|
|
209
|
+
.sf-ring {
|
|
210
|
+
outline-width: 4px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.sf-guide {
|
|
214
|
+
border-width: 3px;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@media (forced-colors: active) {
|
|
219
|
+
.sf-ring {
|
|
220
|
+
outline-color: Highlight;
|
|
221
|
+
box-shadow: none;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.sf-guide {
|
|
225
|
+
border-color: CanvasText;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.sf-band {
|
|
229
|
+
border-block-end: 2px solid CanvasText;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The visible element marked `data-tour="<target>"`, or null. */
|
|
2
|
+
export declare const findTarget: (target: string) => HTMLElement | null;
|
|
3
|
+
/** Resolves true when the target is on the page, waiting up to `timeout` ms for it to appear. */
|
|
4
|
+
export declare function waitForTarget(target: string, timeout: number): Promise<boolean>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const escapeValue = (value) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
|
|
2
|
+
const find = (target) => {
|
|
3
|
+
const el = document.querySelector(`[data-tour="${escapeValue(target)}"]`);
|
|
4
|
+
return el && el.getClientRects().length > 0 ? el : null;
|
|
5
|
+
};
|
|
6
|
+
/** The visible element marked `data-tour="<target>"`, or null. */
|
|
7
|
+
export const findTarget = (target) => typeof document === "undefined" ? null : find(target);
|
|
8
|
+
/** Resolves true when the target is on the page, waiting up to `timeout` ms for it to appear. */
|
|
9
|
+
export function waitForTarget(target, timeout) {
|
|
10
|
+
if (typeof document === "undefined")
|
|
11
|
+
return Promise.resolve(false);
|
|
12
|
+
if (find(target))
|
|
13
|
+
return Promise.resolve(true);
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const done = (found) => {
|
|
16
|
+
observer.disconnect();
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
resolve(found);
|
|
19
|
+
};
|
|
20
|
+
const observer = new MutationObserver(() => {
|
|
21
|
+
if (find(target))
|
|
22
|
+
done(true);
|
|
23
|
+
});
|
|
24
|
+
const timer = setTimeout(() => done(find(target) !== null), timeout);
|
|
25
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
26
|
+
});
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speak-friend-react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "React bindings for speak-friend: an accessible docked tour guide with a highlight ring.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/DeepKind-Foundation/speak-friend.git",
|
|
9
|
+
"directory": "packages/react"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/DeepKind-Foundation/speak-friend/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/DeepKind-Foundation/speak-friend#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": [
|
|
17
|
+
"**/*.css"
|
|
18
|
+
],
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./styles.css": "./dist/styles.css"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": ">=18",
|
|
33
|
+
"react-dom": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"speak-friend": "0.1.0-alpha.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@testing-library/react": "16.3.0",
|
|
40
|
+
"@types/react": "19.1.8",
|
|
41
|
+
"@types/react-dom": "19.1.6",
|
|
42
|
+
"axe-core": "4.10.3",
|
|
43
|
+
"jsdom": "26.1.0",
|
|
44
|
+
"react": "19.1.0",
|
|
45
|
+
"react-dom": "19.1.0"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"tour",
|
|
49
|
+
"onboarding",
|
|
50
|
+
"walkthrough",
|
|
51
|
+
"accessibility",
|
|
52
|
+
"react"
|
|
53
|
+
],
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public",
|
|
56
|
+
"tag": "next"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsc -p tsconfig.build.json && cp src/styles.css dist/styles.css",
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"smoke": "node scripts/smoke.mjs"
|
|
63
|
+
}
|
|
6
64
|
}
|