@textcortex/slidewise 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/slidewise",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,8 +5,8 @@ import {
5
5
  type SlidewiseRootProps,
6
6
  type HistoryState,
7
7
  } from "./compound/SlidewiseRoot";
8
+ import { TopBar } from "./compound/topbar";
8
9
  import {
9
- TopBar,
10
10
  SlideRail,
11
11
  Canvas,
12
12
  BottomToolbar,
@@ -0,0 +1,30 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ const DirtyContext = createContext<boolean>(false);
4
+
5
+ export function DirtyProvider({
6
+ dirty,
7
+ children,
8
+ }: {
9
+ dirty: boolean;
10
+ children: ReactNode;
11
+ }) {
12
+ return <DirtyContext.Provider value={dirty}>{children}</DirtyContext.Provider>;
13
+ }
14
+
15
+ /**
16
+ * Reactive dirty flag. `true` when the deck has uncommitted edits since the
17
+ * last save / mount. Use this from host components anywhere under
18
+ * `<Slidewise.Root>` to render "Unsaved changes" UI without polling
19
+ * `api.isDirty()`.
20
+ *
21
+ * ```tsx
22
+ * function MyHeader() {
23
+ * const dirty = useDirty();
24
+ * return dirty ? <Badge>Unsaved</Badge> : null;
25
+ * }
26
+ * ```
27
+ */
28
+ export function useDirty(): boolean {
29
+ return useContext(DirtyContext);
30
+ }
@@ -4,6 +4,7 @@ import {
4
4
  useId,
5
5
  useImperativeHandle,
6
6
  useRef,
7
+ useState,
7
8
  type CSSProperties,
8
9
  type PropsWithChildren,
9
10
  type Ref,
@@ -20,6 +21,7 @@ import { PlayMode } from "@/components/editor/PlayMode";
20
21
  import { HostProvider } from "./HostContext";
21
22
  import { IconProvider, type SlidewiseIcons } from "./IconContext";
22
23
  import { ReadOnlyProvider } from "./ReadOnlyContext";
24
+ import { DirtyProvider } from "./DirtyContext";
23
25
 
24
26
  export interface SlidewiseRootProps {
25
27
  /**
@@ -137,6 +139,7 @@ function RootInner({
137
139
  const store = useEditorStore();
138
140
  const savedDeckRef = useRef<Deck>(deck);
139
141
  const dirtyRef = useRef(false);
142
+ const [dirty, setDirty] = useState(false);
140
143
  const onChangeRef = useRef(onChange);
141
144
  const onDirtyChangeRef = useRef(onDirtyChange);
142
145
  const onSaveRef = useRef(onSave);
@@ -176,6 +179,7 @@ function RootInner({
176
179
  savedDeckRef.current = deck;
177
180
  if (dirtyRef.current) {
178
181
  dirtyRef.current = false;
182
+ setDirty(false);
179
183
  onDirtyChangeRef.current?.(false);
180
184
  }
181
185
  }
@@ -208,6 +212,7 @@ function RootInner({
208
212
  const nextDirty = state.deck !== savedDeckRef.current;
209
213
  if (nextDirty !== dirtyRef.current) {
210
214
  dirtyRef.current = nextDirty;
215
+ setDirty(nextDirty);
211
216
  onDirtyChangeRef.current?.(nextDirty);
212
217
  }
213
218
  ensureGoogleFontsLoaded(instanceId, collectFontFamilies(state.deck));
@@ -263,15 +268,17 @@ function RootInner({
263
268
  return (
264
269
  <ReadOnlyProvider readOnly={readOnly}>
265
270
  <IconProvider icons={icons ?? {}}>
266
- <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
267
- <RootShell
268
- fontFamily={fontFamily}
269
- className={className}
270
- style={style}
271
- >
272
- {children}
273
- </RootShell>
274
- </HostProvider>
271
+ <DirtyProvider dirty={dirty}>
272
+ <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
273
+ <RootShell
274
+ fontFamily={fontFamily}
275
+ className={className}
276
+ style={style}
277
+ >
278
+ {children}
279
+ </RootShell>
280
+ </HostProvider>
281
+ </DirtyProvider>
275
282
  </IconProvider>
276
283
  </ReadOnlyProvider>
277
284
  );
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Public store hooks. These let host components anywhere inside
3
+ * `<Slidewise.Root>` read or write editor state without prop drilling.
4
+ *
5
+ * `useEditor` is the generic selector hook — pass any function that takes
6
+ * the editor state and returns the slice you care about. The convenience
7
+ * hooks below cover the common cases.
8
+ *
9
+ * Example:
10
+ *
11
+ * ```tsx
12
+ * import { useEditor, useSlides, useActiveSlide } from "@textcortex/slidewise";
13
+ *
14
+ * function HostHeader() {
15
+ * const slideCount = useSlides().length;
16
+ * const active = useActiveSlide();
17
+ * return <span>Slide {active.id} of {slideCount}</span>;
18
+ * }
19
+ * ```
20
+ */
21
+ import { useEditor } from "@/lib/StoreProvider";
22
+ import type { Slide, SlideElement } from "@/lib/types";
23
+
24
+ export { useEditor, useEditorStore } from "@/lib/StoreProvider";
25
+
26
+ /** All slides in the current deck, in display order. */
27
+ export function useSlides(): Slide[] {
28
+ return useEditor((s) => s.deck.slides);
29
+ }
30
+
31
+ /**
32
+ * The currently focused slide. Falls back to the first slide if the
33
+ * `currentSlideId` no longer exists (shouldn't happen, but defensive).
34
+ */
35
+ export function useActiveSlide(): Slide {
36
+ return useEditor((s) => {
37
+ const found = s.deck.slides.find((sl) => sl.id === s.currentSlideId);
38
+ return found ?? s.deck.slides[0];
39
+ });
40
+ }
41
+
42
+ /** Id of the currently focused slide. */
43
+ export function useActiveSlideId(): string {
44
+ return useEditor((s) => s.currentSlideId);
45
+ }
46
+
47
+ /** Ids of currently selected elements on the active slide. */
48
+ export function useSelection(): string[] {
49
+ return useEditor((s) => s.selectedIds);
50
+ }
51
+
52
+ /**
53
+ * Resolved selected element objects on the active slide. Returns `[]` when
54
+ * nothing is selected. Use this when you need to read element properties
55
+ * (position, text, fill) — not just their ids.
56
+ */
57
+ export function useSelectedElements(): SlideElement[] {
58
+ return useEditor((s) => {
59
+ const slide = s.deck.slides.find((sl) => sl.id === s.currentSlideId);
60
+ if (!slide) return [];
61
+ return slide.elements.filter((e) => s.selectedIds.includes(e.id));
62
+ });
63
+ }
64
+
65
+ /** Current theme ("light" or "dark"). */
66
+ export function useTheme(): "light" | "dark" {
67
+ return useEditor((s) => s.theme);
68
+ }
69
+
70
+ /** Current zoom scale (1 = 100%). */
71
+ export function useZoom(): number {
72
+ return useEditor((s) => s.zoom);
73
+ }
74
+
75
+ /** True when the editor is in present-mode. */
76
+ export function usePlaying(): boolean {
77
+ return useEditor((s) => s.playing);
78
+ }
79
+
80
+ /** Live history depth — useful for host-rendered Undo/Redo button state. */
81
+ export function useHistory(): {
82
+ canUndo: boolean;
83
+ canRedo: boolean;
84
+ undoSize: number;
85
+ redoSize: number;
86
+ } {
87
+ return useEditor((s) => ({
88
+ canUndo: s.history.length > 0,
89
+ canRedo: s.future.length > 0,
90
+ undoSize: s.history.length,
91
+ redoSize: s.future.length,
92
+ }));
93
+ }
@@ -5,7 +5,7 @@
5
5
  * thin wrapper rendering this same tree:
6
6
  *
7
7
  * ```tsx
8
- * <Slidewise.Root deck={deck} onChange={} onSave={}>
8
+ * <Slidewise.Root deck={deck} onChange={...} onSave={...}>
9
9
  * <Slidewise.TopBar />
10
10
  * <Slidewise.Body>
11
11
  * <Slidewise.SlideRail />
@@ -17,10 +17,19 @@
17
17
  * </Slidewise.Root>
18
18
  * ```
19
19
  *
20
- * Use the namespace import to keep call sites tidy:
20
+ * For full control over the top bar (host buttons mixed in, subparts
21
+ * reordered, individual buttons skinned), drop down to its subparts:
21
22
  *
22
23
  * ```tsx
23
- * import * as Slidewise from "@textcortex/slidewise";
24
+ * <Slidewise.TopBar.Root>
25
+ * <MyExitButton />
26
+ * <Slidewise.TopBar.Spacer />
27
+ * <Slidewise.TopBar.Group>
28
+ * <Slidewise.TopBar.Undo />
29
+ * <Slidewise.TopBar.Redo />
30
+ * </Slidewise.TopBar.Group>
31
+ * <Slidewise.TopBar.Save />
32
+ * </Slidewise.TopBar.Root>
24
33
  * ```
25
34
  */
26
35
  export {
@@ -30,7 +39,6 @@ export {
30
39
  type HistoryState,
31
40
  } from "./SlidewiseRoot";
32
41
  export {
33
- TopBar,
34
42
  SlideRail,
35
43
  Canvas,
36
44
  BottomToolbar,
@@ -39,6 +47,21 @@ export {
39
47
  CanvasFrame,
40
48
  type RegionProps,
41
49
  } from "./parts";
50
+
51
+ export { TopBar, type TopBarProps, type TopBarSlotId } from "./topbar";
52
+ export type {
53
+ TopBarRootProps,
54
+ TopBarTitleProps,
55
+ TopBarUndoProps,
56
+ TopBarRedoProps,
57
+ TopBarSaveProps,
58
+ TopBarPlayProps,
59
+ TopBarThemeToggleProps,
60
+ TopBarExportProps,
61
+ TopBarSpacerProps,
62
+ TopBarGroupProps,
63
+ } from "./topbar";
64
+
42
65
  export {
43
66
  useHostCallbacks,
44
67
  type SlidewiseHostCallbacks,
@@ -49,3 +72,22 @@ export {
49
72
  type SlidewiseIcons,
50
73
  } from "./IconContext";
51
74
  export { ReadOnlyProvider, useReadOnly } from "./ReadOnlyContext";
75
+ export { DirtyProvider, useDirty } from "./DirtyContext";
76
+
77
+ /**
78
+ * Store hooks. Use these from host components anywhere under
79
+ * `<Slidewise.Root>` to read or write editor state without prop drilling.
80
+ */
81
+ export {
82
+ useEditor,
83
+ useEditorStore,
84
+ useSlides,
85
+ useActiveSlide,
86
+ useActiveSlideId,
87
+ useSelection,
88
+ useSelectedElements,
89
+ useTheme,
90
+ useZoom,
91
+ usePlaying,
92
+ useHistory,
93
+ } from "./hooks";
@@ -1,14 +1,16 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
- import { TopBar as TopBarInternal } from "@/components/editor/TopBar";
3
2
  import { SlideRail as SlideRailInternal } from "@/components/editor/SlideRail";
4
3
  import { Canvas as CanvasInternal } from "@/components/editor/Canvas";
5
4
  import { BottomToolbar as BottomToolbarInternal } from "@/components/editor/BottomToolbar";
6
- import { useHostCallbacks } from "./HostContext";
7
5
 
8
6
  /**
9
7
  * Region-level compound parts. Each consumes the editor store via context,
10
8
  * so any part can be omitted, wrapped, or replaced. None of these accept
11
9
  * deck/onChange/onSave props — those live on `<Slidewise.Root>`.
10
+ *
11
+ * Note: `<Slidewise.TopBar>` is defined separately in `./topbar/` because
12
+ * it itself decomposes into subparts (`TopBar.Root`, `TopBar.Title`,
13
+ * `TopBar.Undo`, etc.) and ships a `hide` prop for per-button removal.
12
14
  */
13
15
 
14
16
  export interface RegionProps {
@@ -16,19 +18,6 @@ export interface RegionProps {
16
18
  style?: CSSProperties;
17
19
  }
18
20
 
19
- /**
20
- * The default top bar (title input, undo/redo, save, play, theme toggle,
21
- * export). Reads host callbacks from context, so the Save and Export
22
- * buttons fire the host's `onSave` / `onExport` from `<Slidewise.Root>`.
23
- *
24
- * Omit it from the tree to hide the whole bar; or render your own toolbar
25
- * alongside `<Slidewise.Canvas>` for full control.
26
- */
27
- export function TopBar(_props: RegionProps = {}) {
28
- const { onSave, onExport } = useHostCallbacks();
29
- return <TopBarInternal onSave={onSave} onExport={onExport} />;
30
- }
31
-
32
21
  /**
33
22
  * Left-side slide thumbnail rail with add/duplicate/delete.
34
23
  */
@@ -0,0 +1,65 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { Download } from "lucide-react";
3
+ import { useEditorStore } from "@/lib/StoreProvider";
4
+ import { useHostCallbacks } from "../HostContext";
5
+ import { useIcons } from "../IconContext";
6
+ import { primaryBtnStyle, primaryHoverHandlers } from "./styles";
7
+
8
+ /**
9
+ * Export button. Calls the host's `onExport` (from `<Slidewise.Root
10
+ * onExport>`) with the current deck. If no host callback is registered,
11
+ * falls back to downloading a `.slidewise.json` of the deck.
12
+ *
13
+ * Visually emphasized vs the chrome buttons — uses `--primary-bg` so hosts
14
+ * retheming the primary surface get a consistent affordance.
15
+ */
16
+ export interface TopBarExportProps {
17
+ className?: string;
18
+ style?: CSSProperties;
19
+ ariaLabel?: string;
20
+ label?: string;
21
+ children?: ReactNode;
22
+ }
23
+
24
+ export function Export({
25
+ className,
26
+ style,
27
+ ariaLabel = "Export",
28
+ label = "Export",
29
+ children,
30
+ }: TopBarExportProps = {}) {
31
+ const store = useEditorStore();
32
+ const { onExport: onExportHost } = useHostCallbacks();
33
+ const icons = useIcons();
34
+
35
+ const onClick = () => {
36
+ const deck = store.getState().deck;
37
+ if (onExportHost) {
38
+ onExportHost(deck);
39
+ return;
40
+ }
41
+ const blob = new Blob([JSON.stringify(deck, null, 2)], {
42
+ type: "application/json",
43
+ });
44
+ const url = URL.createObjectURL(blob);
45
+ const a = document.createElement("a");
46
+ a.href = url;
47
+ a.download = `${(deck.title || "deck").replace(/[^a-z0-9-_]+/gi, "-")}.slidewise.json`;
48
+ a.click();
49
+ URL.revokeObjectURL(url);
50
+ };
51
+
52
+ return (
53
+ <button
54
+ type="button"
55
+ className={className}
56
+ aria-label={ariaLabel}
57
+ onClick={onClick}
58
+ style={{ ...primaryBtnStyle(), ...style }}
59
+ {...primaryHoverHandlers()}
60
+ >
61
+ {children ?? icons.export ?? <Download size={14} />}
62
+ {label}
63
+ </button>
64
+ );
65
+ }
@@ -0,0 +1,36 @@
1
+ import type { CSSProperties, PropsWithChildren } from "react";
2
+
3
+ /**
4
+ * Visual cluster wrapper for a related set of buttons (e.g. Undo + Redo).
5
+ * Just a flex row with a small inner gap — keeps clusters visually together
6
+ * separate from the bar's default 10px gap.
7
+ *
8
+ * ```tsx
9
+ * <Slidewise.TopBar.Group>
10
+ * <Slidewise.TopBar.Undo />
11
+ * <Slidewise.TopBar.Redo />
12
+ * </Slidewise.TopBar.Group>
13
+ * ```
14
+ */
15
+ export interface TopBarGroupProps {
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ /** Gap between children inside the group. Default 2px. */
19
+ gap?: number;
20
+ }
21
+
22
+ export function Group({
23
+ className,
24
+ style,
25
+ gap = 2,
26
+ children,
27
+ }: PropsWithChildren<TopBarGroupProps>) {
28
+ return (
29
+ <div
30
+ className={className}
31
+ style={{ display: "flex", alignItems: "center", gap, ...style }}
32
+ >
33
+ {children}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,42 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { Play as PlayIcon } from "lucide-react";
3
+ import { useEditor } from "@/lib/StoreProvider";
4
+ import { useIcons } from "../IconContext";
5
+ import { chromeBtnStyle, hoverHandlers } from "./styles";
6
+
7
+ /**
8
+ * Play button. Enters slideshow / play mode. Always rendered (read-only
9
+ * viewers should still be able to present).
10
+ */
11
+ export interface TopBarPlayProps {
12
+ className?: string;
13
+ style?: CSSProperties;
14
+ ariaLabel?: string;
15
+ label?: string;
16
+ children?: ReactNode;
17
+ }
18
+
19
+ export function Play({
20
+ className,
21
+ style,
22
+ ariaLabel = "Play",
23
+ label = "Play",
24
+ children,
25
+ }: TopBarPlayProps = {}) {
26
+ const play = useEditor((s) => s.play);
27
+ const icons = useIcons();
28
+
29
+ return (
30
+ <button
31
+ type="button"
32
+ className={className}
33
+ aria-label={ariaLabel}
34
+ onClick={play}
35
+ style={{ ...chromeBtnStyle(), ...style }}
36
+ {...hoverHandlers()}
37
+ >
38
+ {children ?? icons.play ?? <PlayIcon size={14} />}
39
+ {label}
40
+ </button>
41
+ );
42
+ }
@@ -0,0 +1,50 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { Redo2 } from "lucide-react";
3
+ import { useEditor } from "@/lib/StoreProvider";
4
+ import { useIcons } from "../IconContext";
5
+ import { useReadOnly } from "../ReadOnlyContext";
6
+ import { iconBtnStyle, hoverHandlers } from "./styles";
7
+
8
+ /**
9
+ * Redo button. Calls `store.redo()`. Hidden in read-only mode. Disables
10
+ * itself when the redo stack is empty.
11
+ */
12
+ export interface TopBarRedoProps {
13
+ className?: string;
14
+ style?: CSSProperties;
15
+ ariaLabel?: string;
16
+ children?: ReactNode;
17
+ }
18
+
19
+ export function Redo({
20
+ className,
21
+ style,
22
+ ariaLabel = "Redo",
23
+ children,
24
+ }: TopBarRedoProps = {}) {
25
+ const redo = useEditor((s) => s.redo);
26
+ const canRedo = useEditor((s) => s.future.length > 0);
27
+ const icons = useIcons();
28
+ const readOnly = useReadOnly();
29
+ if (readOnly) return null;
30
+
31
+ return (
32
+ <button
33
+ type="button"
34
+ className={className}
35
+ title={ariaLabel}
36
+ aria-label={ariaLabel}
37
+ disabled={!canRedo}
38
+ onClick={redo}
39
+ style={{
40
+ ...iconBtnStyle(),
41
+ cursor: canRedo ? "pointer" : "default",
42
+ opacity: canRedo ? 1 : 0.4,
43
+ ...style,
44
+ }}
45
+ {...hoverHandlers()}
46
+ >
47
+ {children ?? icons.redo ?? <Redo2 size={16} />}
48
+ </button>
49
+ );
50
+ }
@@ -0,0 +1,51 @@
1
+ import type { CSSProperties, PropsWithChildren } from "react";
2
+
3
+ /**
4
+ * Container for the TopBar subparts. Owns the bar's height, padding, theme
5
+ * background, and shadow. Hosts replace this when they want a different
6
+ * shell shape (taller bar, different alignment, etc.); otherwise just render
7
+ * subparts inside it.
8
+ *
9
+ * ```tsx
10
+ * <Slidewise.TopBar.Root>
11
+ * <Slidewise.TopBar.Title />
12
+ * <Slidewise.TopBar.Spacer />
13
+ * <Slidewise.TopBar.Save />
14
+ * </Slidewise.TopBar.Root>
15
+ * ```
16
+ */
17
+ export interface TopBarRootProps {
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ }
21
+
22
+ export function Root({
23
+ className,
24
+ style,
25
+ children,
26
+ }: PropsWithChildren<TopBarRootProps>) {
27
+ return (
28
+ <div
29
+ className={
30
+ className ? `slidewise-topbar ${className}` : "slidewise-topbar"
31
+ }
32
+ style={{
33
+ height: 56,
34
+ display: "flex",
35
+ alignItems: "center",
36
+ padding: "0 14px",
37
+ gap: 10,
38
+ background: "var(--slidewise-bar-bg, var(--app-bg))",
39
+ borderBottom: "1px solid var(--border)",
40
+ boxShadow: "var(--topbar-shadow)",
41
+ fontFamily: "Inter, system-ui, sans-serif",
42
+ position: "relative",
43
+ zIndex: 10,
44
+ color: "var(--ink)",
45
+ ...style,
46
+ }}
47
+ >
48
+ {children}
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,80 @@
1
+ import { useState, type CSSProperties, type ReactNode } from "react";
2
+ import { Save as SaveIcon } from "lucide-react";
3
+ import { useEditorStore } from "@/lib/StoreProvider";
4
+ import { useHostCallbacks } from "../HostContext";
5
+ import { useIcons } from "../IconContext";
6
+ import { useReadOnly } from "../ReadOnlyContext";
7
+ import { chromeBtnStyle, hoverHandlers } from "./styles";
8
+
9
+ /**
10
+ * Save button. Calls the host's `onSave` (from `<Slidewise.Root onSave>`)
11
+ * with the current deck. If no host callback is registered, falls back to
12
+ * `localStorage.setItem("slidewise-deck", ...)` so the dev shell works
13
+ * without wiring.
14
+ *
15
+ * Cycles through idle → saving → saved labels around the click; hosts that
16
+ * want their own loading affordance render their own button using
17
+ * `useHostCallbacks().onSave` directly.
18
+ */
19
+ export interface TopBarSaveProps {
20
+ className?: string;
21
+ style?: CSSProperties;
22
+ ariaLabel?: string;
23
+ labels?: { idle?: string; saving?: string; saved?: string };
24
+ children?: ReactNode;
25
+ }
26
+
27
+ export function Save({
28
+ className,
29
+ style,
30
+ ariaLabel = "Save",
31
+ labels,
32
+ children,
33
+ }: TopBarSaveProps = {}) {
34
+ const store = useEditorStore();
35
+ const { onSave: onSaveHost } = useHostCallbacks();
36
+ const icons = useIcons();
37
+ const readOnly = useReadOnly();
38
+ const [phase, setPhase] = useState<"idle" | "saving" | "saved">("idle");
39
+
40
+ if (readOnly) return null;
41
+
42
+ const onClick = async () => {
43
+ setPhase("saving");
44
+ const deck = store.getState().deck;
45
+ try {
46
+ if (onSaveHost) {
47
+ await onSaveHost(deck);
48
+ } else {
49
+ try {
50
+ localStorage.setItem("slidewise-deck", JSON.stringify(deck));
51
+ } catch {}
52
+ }
53
+ setTimeout(() => setPhase("saved"), 320);
54
+ setTimeout(() => setPhase("idle"), 1600);
55
+ } catch {
56
+ setPhase("idle");
57
+ }
58
+ };
59
+
60
+ const text =
61
+ phase === "saving"
62
+ ? (labels?.saving ?? "Saving…")
63
+ : phase === "saved"
64
+ ? (labels?.saved ?? "Saved")
65
+ : (labels?.idle ?? "Save");
66
+
67
+ return (
68
+ <button
69
+ type="button"
70
+ className={className}
71
+ aria-label={ariaLabel}
72
+ onClick={onClick}
73
+ style={{ ...chromeBtnStyle(), ...style }}
74
+ {...hoverHandlers()}
75
+ >
76
+ {children ?? icons.save ?? <SaveIcon size={14} />}
77
+ {text}
78
+ </button>
79
+ );
80
+ }
@@ -0,0 +1,27 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ /**
4
+ * Pushes subsequent subparts to the far edge of `<TopBar.Root>`.
5
+ *
6
+ * ```tsx
7
+ * <Slidewise.TopBar.Root>
8
+ * <MyExitButton />
9
+ * <Slidewise.TopBar.Spacer />
10
+ * <Slidewise.TopBar.Save />
11
+ * </Slidewise.TopBar.Root>
12
+ * ```
13
+ */
14
+ export interface TopBarSpacerProps {
15
+ className?: string;
16
+ style?: CSSProperties;
17
+ }
18
+
19
+ export function Spacer({ className, style }: TopBarSpacerProps = {}) {
20
+ return (
21
+ <div
22
+ className={className}
23
+ aria-hidden="true"
24
+ style={{ flex: 1, minWidth: 0, ...style }}
25
+ />
26
+ );
27
+ }