From 9bd389203ec8518a46715252f6540dd3d411cc60 Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Sun, 11 Oct 2026 18:25:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=F0=9F=90=9B=20tint=20a=20found=20sc?= =?UTF-8?q?ene's=20timeline=20bar=20with=20the=20library's=20own=20colour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It read a variable only the app it came from defines, so on any other page the tint was dropped. Co-Authored-By: Claude Opus 5.5 --- packages/player/src/timeline.tsx | 102 +++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/packages/player/src/timeline.tsx b/packages/player/src/timeline.tsx index d8fe5be..d1fd88e 100644 --- a/packages/player/src/timeline.tsx +++ b/packages/player/src/timeline.tsx @@ -5,11 +5,48 @@ import { type SceneInstance, type SceneMark, SceneThumb, + type SlotClassNames, useLabels, WRAPPED_FORMATS, type WrappedConfig, } from "@react-wrapped/core"; -import { ChevronLeft, ChevronRight, cn, PencilLine, Switch } from "@react-wrapped/core/internal"; +import { ChevronLeft, ChevronRight, PencilLine, Switch, useSlots } from "@react-wrapped/core/internal"; + +/** Every part of the timeline a class can be given to, by its `data-wr-slot`. */ +export const TIMELINE_SLOTS = [ + // The root: the row and the preview that opens over it. + "timeline", + // The row of segments, the part that is clicked and dragged. + "timeline-track", + // One scene's share of the row. + "timeline-segment", + // The bar drawn inside a segment. + "timeline-segment-bar", + // The played part of a bar. + "timeline-segment-fill", + // What holds the preview over its segment. + "timeline-popover", + // The preview card. + "timeline-preview", + // The still of the scene in the card. + "timeline-preview-thumb", + // The scene's name. + "timeline-preview-title", + // How long the scene plays, or that it is off. + "timeline-preview-length", + // How rare a found scene is. + "timeline-preview-rarity", + // The creator's row of controls in the card. + "timeline-controls", + "timeline-move-earlier", + "timeline-move-later", + "timeline-edit", + // The switch that takes a scene out of the video, and the dot that slides in it. + "timeline-toggle", + "timeline-toggle-thumb", +] as const; + +export type TimelineSlot = (typeof TIMELINE_SLOTS)[number]; /** What the creator can do to the cut from the timeline. Left out for anyone just watching. */ export interface TimelineEditing { @@ -25,6 +62,9 @@ export interface TimelineEditing { * row is about 60px wide, so the sliver is thinner there. */ const offSegment = "wr:w-1.5 wr:flex-none wr:sm:w-3.5"; +/** The two arrows that move a scene along the cut. */ +const move = + "wr:grid wr:size-8 wr:cursor-pointer wr:place-items-center wr:rounded-lg wr:text-muted-foreground wr:hover:bg-muted wr:hover:text-foreground wr:disabled:pointer-events-none wr:disabled:opacity-35"; /** * The video's timeline, and the place the cut is managed from. Every scene is a segment as long as it plays. Hover one @@ -40,6 +80,8 @@ export function Timeline({ config, editing, onSeek, + className, + classNames, }: { /** The row of segments. The engine fills each segment's bar from here as the video plays. */ trackRef: RefObject; @@ -50,7 +92,11 @@ export function Timeline({ config: WrappedConfig; editing?: TimelineEditing; onSeek: (seconds: number) => void; + className?: string; + /** A class per part, by slot name. Added after the library's own classes on that part. */ + classNames?: SlotClassNames; }) { + const slot = useSlots(classNames); const wrapRef = useRef(null); const closing = useRef | null>(null); const [open, setOpen] = useState<{ key: string; x: number } | null>(null); @@ -148,13 +194,15 @@ export function Timeline({ const half = thumbWidth / 2 + 10; const wrapWidth = wrapRef.current?.offsetWidth ?? 0; const left = open ? Math.min(Math.max(open.x, half), Math.max(half, wrapWidth - half)) : 0; + // The still's box has always carried a class attribute, empty while its scene is on. It keeps one. + const thumb = slot("timeline-preview-thumb", !shownSpan && "wr:opacity-45 wr:grayscale"); return (
{ if (event.pointerType === "mouse") hideSoon(); }} @@ -165,7 +213,10 @@ export function Timeline({
{ event.currentTarget.setPointerCapture(event.pointerId); seekFrom(event); @@ -184,24 +235,25 @@ export function Timeline({
{span ? ( ) : null} @@ -212,15 +264,23 @@ export function Timeline({
{shown && open ? ( -
-
-
+
+
+

- {shown.label} - + {shown.label} + {shownSpan ? // Whole seconds: how the number is written (4s, 1:05) is the label's business. labels.timeline.length(Math.max(0, Math.round(shownSpan.length / speed))) @@ -228,12 +288,15 @@ export function Timeline({

{shown.drop ? ( -

+

{labels.rarity[shown.drop]}

) : null} {editing ? ( -
+
@@ -251,7 +314,7 @@ export function Timeline({ disabled={shownIndex >= scenes.length - 1} aria-label={labels.timeline.moveLater(shown.label)} title={labels.timeline.later} - className="wr:grid wr:size-8 wr:cursor-pointer wr:place-items-center wr:rounded-lg wr:text-muted-foreground wr:hover:bg-muted wr:hover:text-foreground wr:disabled:pointer-events-none wr:disabled:opacity-35" + {...slot("timeline-move-later", move)} > @@ -262,7 +325,10 @@ export function Timeline({ onClick={() => editing.onEdit(shown.key)} aria-label={labels.timeline.edit(shown.label)} title={labels.timeline.editHint} - className="wr:grid wr:size-8 wr:cursor-pointer wr:place-items-center wr:rounded-lg wr:text-muted-foreground wr:hover:bg-muted wr:hover:text-foreground" + {...slot( + "timeline-edit", + "wr:grid wr:size-8 wr:cursor-pointer wr:place-items-center wr:rounded-lg wr:text-muted-foreground wr:hover:bg-muted wr:hover:text-foreground", + )} > @@ -274,6 +340,8 @@ export function Timeline({ disabled={Boolean(shownSpan) && onCount === 1} onCheckedChange={(on) => editing.onToggle(shown.key, on)} aria-label={labels.timeline.toggle(shown.label)} + {...slot("timeline-toggle")} + thumb={slot("timeline-toggle-thumb")} />
) : null} From c9cc8ceb8f07852330752db7550e85feafb98af2 Mon Sep 17 00:00:00 2001 From: Maciej Pyrc Date: Sun, 11 Oct 2026 18:25:41 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20style=20any=20part?= =?UTF-8?q?=20by=20hand:=20slots,=20classNames=20and=20a=20styling=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every part of the chrome carries a stable data-wr-slot name and every component takes classNames per part; WrappedProvider takes them for the whole page. The Styling guide covers tokens, slots, and the one line a Tailwind app needs so its utilities win over the library's classes. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-consumer.mjs | 2 + DESIGN.md | 30 ++ README.md | 24 ++ UPSTREAM.md | 12 + apps/docs-e2e/tests/helpers.ts | 18 + apps/docs-e2e/tests/layers.spec.ts | 124 ++++++ apps/docs/src/demos/index.tsx | 7 + apps/docs/src/demos/own-style.tsx | 97 +++++ apps/docs/src/registry/extra-pages.ts | 12 + apps/docs/src/registry/graphics-pages.ts | 6 + apps/docs/src/registry/guide-pages.ts | 11 + apps/docs/src/registry/pages.ts | 9 +- apps/docs/src/registry/parts.ts | 1 + apps/docs/src/registry/player-pages.ts | 29 ++ apps/docs/src/registry/scanner-pages.ts | 6 + apps/docs/src/registry/styling-page.ts | 370 ++++++++++++++++++ packages/core/src/chips.tsx | 10 +- packages/core/src/index.ts | 1 + packages/core/src/internal/index.ts | 1 + packages/core/src/internal/modal.tsx | 86 ++++- packages/core/src/internal/slot-context.ts | 25 ++ packages/core/src/internal/slots.test.tsx | 171 +++++++++ packages/core/src/internal/slots.ts | 31 ++ packages/core/src/internal/switch.tsx | 10 +- packages/core/src/labels.tsx | 27 +- packages/core/src/slots.ts | 12 + packages/graphics/src/graphics-gallery.tsx | 123 ++++-- packages/graphics/src/slots.test.tsx | 126 +++++++ packages/kit/src/slots.test.tsx | 206 ++++++++++ packages/kit/src/wrapped-view.tsx | 90 ++++- packages/player/src/scene-editor.tsx | 77 +++- packages/player/src/share-actions.tsx | 65 +++- packages/player/src/slots.test.tsx | 375 +++++++++++++++++++ packages/player/src/wrapped-configurator.tsx | 121 ++++-- packages/player/src/wrapped-player.tsx | 79 +++- packages/scan/src/slots.test.tsx | 149 ++++++++ packages/scan/src/year-scan.tsx | 185 +++++++-- scripts/prefix-classes.mjs | 11 +- tools/consumer/src/main.tsx | 19 +- tools/consumer/ssr.mjs | 3 + 40 files changed, 2601 insertions(+), 160 deletions(-) create mode 100644 apps/docs-e2e/tests/layers.spec.ts create mode 100644 apps/docs/src/demos/own-style.tsx create mode 100644 apps/docs/src/registry/styling-page.ts create mode 100644 packages/core/src/internal/slot-context.ts create mode 100644 packages/core/src/internal/slots.test.tsx create mode 100644 packages/core/src/internal/slots.ts create mode 100644 packages/core/src/slots.ts create mode 100644 packages/graphics/src/slots.test.tsx create mode 100644 packages/kit/src/slots.test.tsx create mode 100644 packages/player/src/slots.test.tsx create mode 100644 packages/scan/src/slots.test.tsx diff --git a/.github/scripts/check-consumer.mjs b/.github/scripts/check-consumer.mjs index 58c3a9f..199e15b 100644 --- a/.github/scripts/check-consumer.mjs +++ b/.github/scripts/check-consumer.mjs @@ -52,6 +52,8 @@ try { const rendered = run("node", ["ssr.mjs"], project).trim(); if (!rendered.includes("has stage: true")) throw new Error(`The server render came out wrong: ${rendered}`); + // A class given for a slot has to arrive on that part, through the packed packages too. + if (!rendered.includes("has a restyled slot: true")) throw new Error(`A slot's class did not land: ${rendered}`); console.log(`ok ${rendered}`); } catch (error) { console.log(`FAIL\n${error.stdout ?? ""}${error.stderr ?? ""}${error.message ?? ""}`); diff --git a/DESIGN.md b/DESIGN.md index 339ac79..b16c9e6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -106,6 +106,36 @@ specific than a single class and will reach the library's headings. Render a Wra When code comes in from the app this library was cut from, its utility classes have no prefix yet: `node scripts/prefix-classes.mjs packages//src --write` adds it (see `UPSTREAM.md`). +## Slots: every part of the chrome has a name + +The tokens recolour the chrome. To change a shape, a size or a font on one part, a host needs to reach that part, so +every element of the chrome somebody might restyle is a slot: it carries `data-wr-slot="player-play"`, and it takes a +class for that name. + +- **In a component** an element becomes a slot by spreading `slot(...)` from `useSlots` + (`packages/core/src/internal/slots.ts`): ` +
+ +`; + +/** Serves a hand-built page from the site's origin, opens it, and reads the button's background and corners. */ +async function probe(page: Page, name: string, html: string) { + const address = `${BASE}__layers/${name}.html`; + await page.route(`**${address}`, (route) => route.fulfill({ contentType: "text/html", body: html })); + await page.goto(address); + const button = page.locator("#probe"); + // The library's stylesheet is in effect once the button has the round corners only it gives. + await expect + .poll(async () => (await stylesOf(button, ["border-top-left-radius"]))["border-top-left-radius"], { + message: "the library's stylesheet is loaded and styles the button", + }) + .not.toBe("0px"); + return stylesOf(button, ["background-color", "border-top-left-radius"]); +} + +test.describe("a Tailwind utility on a library element", () => { + test("loses to the library's own class while the library's stylesheet is loaded plainly", async ({ page }) => { + // Arrange: the library's stylesheet as a plain link, and the utility in its layer after it. + const stylesheet = libraryStylesheetAddress(); + const html = pageWith({ + head: ``, + added: "tw-red", + }); + + // Act + const styles = await probe(page, "plain", html); + + // Assert: the button keeps the library's foreground colour. + expect(styles["background-color"]).not.toBe(RED); + expect(styles["background-color"]).not.toBe(CLEAR); + }); + + test("wins once the library's stylesheet is imported into a layer before utilities", async ({ page }) => { + // Arrange: the same rules, with the library's stylesheet in `components`. The order of the layers is said + // first, in a statement, because nothing but that may stand before an import. + const stylesheet = libraryStylesheetAddress(); + const html = pageWith({ + head: ``, + added: "tw-red", + }); + + // Act + const styles = await probe(page, "layered", html); + + // Assert: the utility has the background, and the library still has everything the utility did not name. + expect(styles["background-color"]).toBe(RED); + expect(styles["border-top-left-radius"]).not.toBe("0px"); + }); +}); + +test.describe("a plain class on a library element", () => { + test("wins over the library's class when its stylesheet comes after the library's", async ({ page }) => { + // Arrange + const stylesheet = libraryStylesheetAddress(); + const html = pageWith({ + head: ``, + added: "mine", + }); + + // Act + const styles = await probe(page, "later", html); + + // Assert + expect(styles["background-color"]).toBe(BLUE); + }); + + test("loses when its stylesheet comes first, unless the selector names the slot too", async ({ page }) => { + // Arrange: two pages with the host's rule before the library's stylesheet. One is a bare class, the other + // has the slot's attribute in it as well. + const stylesheet = libraryStylesheetAddress(); + const early = (rule: string) => + pageWith({ head: ``, added: "mine" }); + + // Act + const bare = await probe(page, "early-bare", early(`.mine { background-color: ${BLUE} }`)); + const slotted = await probe( + page, + "early-slotted", + early(`.mine[data-wr-slot="player-play"] { background-color: ${BLUE} }`), + ); + + // Assert + expect(bare["background-color"]).not.toBe(BLUE); + expect(slotted["background-color"]).toBe(BLUE); + }); +}); diff --git a/apps/docs/src/demos/index.tsx b/apps/docs/src/demos/index.tsx index c169d7c..f197cd9 100644 --- a/apps/docs/src/demos/index.tsx +++ b/apps/docs/src/demos/index.tsx @@ -20,6 +20,7 @@ import { WaitingForTheYear } from "./loading"; import { MarkPicker } from "./marks"; import { OwnChrome } from "./own-chrome"; import { OwnColour } from "./own-colour"; +import { OwnStyle } from "./own-style"; import { OwnTransport } from "./own-transport"; import { PlayerRemote } from "./player-remote"; import { Rarities } from "./rarities"; @@ -167,6 +168,11 @@ function OwnChromePart() { return ; } +function OwnStylePart() { + const [config, change] = useOwnConfig(); + return ; +} + export const PARTS: Record ReactNode; look: boolean }> = { // These keep a whole config of their own, the way an app does, so the site's look controls do not reach them. "first-wrapped": { render: () => , look: false }, @@ -192,4 +198,5 @@ export const PARTS: Record ReactNode; look: boolean }> = marks: { render: () => , look: true }, "own-colour": { render: () => , look: true }, "own-chrome": { render: () => , look: true }, + "own-style": { render: () => , look: true }, }; diff --git a/apps/docs/src/demos/own-style.tsx b/apps/docs/src/demos/own-style.tsx new file mode 100644 index 0000000..a99df0a --- /dev/null +++ b/apps/docs/src/demos/own-style.tsx @@ -0,0 +1,97 @@ +import { type CSSProperties, useState } from "react"; + +import type { SceneInstance, SlotClassNames, WrappedConfig, WrappedSubject } from "@react-wrapped/core"; +import { + type ConfiguratorSlot, + type TimelineSlot, + WrappedConfigurator, + WrappedPlayer, + type WrappedPlayerSlot, +} from "@react-wrapped/player"; + +// Plain CSS classes of your own. This stylesheet comes after the library's, so on the same element a class here +// wins over the library's class. They read the chrome tokens, so they follow light and dark like everything else. +const CSS = ` +.orbit-frame { border-radius: 4px; } +.orbit-button { + border-radius: 6px; + background: var(--wr-ui-primary); + color: var(--wr-ui-primary-fg); + box-shadow: 0 3px 0 color-mix(in oklch, var(--wr-ui-primary) 62%, black); +} +.orbit-clock { font-family: ui-monospace, monospace; color: var(--wr-ui-fg); } +.orbit-bar { border-radius: 2px; background: var(--wr-ui-border); } +.orbit-label { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--wr-ui-muted-fg); } +.orbit-chip { + border-radius: 6px; + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--wr-ui-border); +} +/* The library styles a picked chip with the state in its selector, which outweighs a bare class. Name the state. */ +.orbit-chip[aria-checked="true"] { + background: var(--wr-ui-primary); + color: var(--wr-ui-primary-fg); + box-shadow: none; +} +.orbit-swatch { border-radius: 6px; } +`; + +// A class per slot. The player takes its own slots and the timeline's. +const PLAYER: SlotClassNames = { + "player-frame": "orbit-frame", + "player-play": "orbit-button", + "player-restart": "orbit-button", + "player-fullscreen": "orbit-button", + "player-clock": "orbit-clock", + "timeline-segment-bar": "orbit-bar", +}; + +const SETTINGS: SlotClassNames = { + "settings-label": "orbit-label", + "settings-chip": "orbit-chip", + "settings-swatch": "orbit-swatch", +}; + +// Side by side where there is room, stacked on a phone. +const COLUMNS: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 24, alignItems: "flex-start", marginTop: 14 }; +const ROOM: CSSProperties = { display: "grid", height: 520, flex: "1 1 260px", minWidth: 0 }; +const PANEL: CSSProperties = { flex: "1 1 300px", minWidth: 0 }; + +export function OwnStyle({ + subject, + seed, + scenes, + config, + onConfigChange, +}: { + subject: WrappedSubject; + seed: string; + scenes: readonly SceneInstance[]; + config: WrappedConfig; + onConfigChange: (next: WrappedConfig) => void; +}) { + const [styled, setStyled] = useState(true); + return ( +
+ + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/docs/src/registry/extra-pages.ts b/apps/docs/src/registry/extra-pages.ts index e31daee..e5f214e 100644 --- a/apps/docs/src/registry/extra-pages.ts +++ b/apps/docs/src/registry/extra-pages.ts @@ -22,6 +22,11 @@ const PROVIDER_PROPS: PropDocs[0]> = { 'How the controls write numbers, such as `"pl-PL"`. Left out, it is the provider\'s above, or `"en-US"`.', "optional", ], + classNames: [ + "SlotClassNames", + 'A class per slot, such as `"player-play"`, for every part under the provider. See [styling](/docs/guides/styling/).', + "optional", + ], children: ["ReactNode", "Everything that should read its words from this provider.", "optional"], }; @@ -64,6 +69,7 @@ const labels: DocPage = { "**A sentence with a number in it is a function.** `scan.hiding` gets the count and returns the sentence, so a language with three plural forms can have all three.", "**Providers nest.** One around your app can set the language, and one around a single player can reword that player. The inner one is merged over the outer one.", "**No provider is fine.** Without one, every part reads the English defaults.", + "**It restyles too.** The same provider takes `classNames`, a class per part for everything under it. That is covered in [styling](/docs/guides/styling/).", "**Your scenes are yours.** The text you pass to a scene is never touched. Labels cover only the words the library adds itself.", ], }, @@ -98,6 +104,7 @@ const labels: DocPage = { ], related: [ "/docs/guides/palette-and-chrome/", + "/docs/guides/styling/", "/docs/scanner/year-scan/", "/docs/player/wrapped-player/", "/docs/guides/from-parts/", @@ -123,6 +130,11 @@ const SHARE_PROPS: PropDocs[0]> = { "optional", ], className: ["string", "A class for the row's own box, for the card you put around it.", "optional"], + classNames: [ + "SlotClassNames", + 'A class per part, by slot name: `"share-copy"`, `"share-download"`. See [styling](/docs/guides/styling/).', + "optional", + ], }; const shareActions: DocPage = { diff --git a/apps/docs/src/registry/graphics-pages.ts b/apps/docs/src/registry/graphics-pages.ts index 4d180fb..bc6230d 100644 --- a/apps/docs/src/registry/graphics-pages.ts +++ b/apps/docs/src/registry/graphics-pages.ts @@ -79,6 +79,12 @@ const GRAPHIC_DOCS: PropDocs = { const GALLERY_PROPS: PropDocs[0]> = { graphics: ["readonly WrappedGraphic[]", "The tiles. Unlocked ones are shown first, whatever order you pass."], palette: ["WrappedPalette", "The video's palette: `resolvePalette(config)`. Each tile sits on its background."], + className: ["string", "A class for the gallery's root.", "optional"], + classNames: [ + "SlotClassNames", + 'A class per part, by slot name: `"gallery-tile"`, `"gallery-download"`. See [styling](/docs/guides/styling/).', + "optional", + ], }; const overview: DocPage = { diff --git a/apps/docs/src/registry/guide-pages.ts b/apps/docs/src/registry/guide-pages.ts index c181d89..7c4eb05 100644 --- a/apps/docs/src/registry/guide-pages.ts +++ b/apps/docs/src/registry/guide-pages.ts @@ -152,6 +152,11 @@ const overview: DocPage = { title: "Custom palette and chrome colours", text: "One accent colour for the video, and ten tokens for everything around it.", }, + { + path: "/docs/guides/styling/", + title: "Styling", + text: "A class of your own on any part of the chrome, and how to make Tailwind utilities win.", + }, { path: "/docs/guides/config-in-the-url/", title: "The config in the URL", @@ -503,6 +508,11 @@ const paletteAndChrome: DocPage = { caption: "The same parts with and without a set of tokens on the wrapper.", }, fileBlock(sourceOf("demos/own-chrome.tsx"), "own-chrome.tsx"), + { kind: "h2", text: "Past colour: slots" }, + { + kind: "p", + text: 'The tokens change colours, the radius and the font. To change the shape or the size of one part, such as the play button or a settings chip, give that part a class of your own. Every part of the chrome is a slot with a name: it carries `data-wr-slot="player-play"` and takes a class through `classNames`. The [styling guide](/docs/guides/styling/) lists every slot, and says how to make Tailwind utilities win over the library\'s classes.', + }, { kind: "h2", text: "Dark mode" }, { kind: "p", @@ -519,6 +529,7 @@ const paletteAndChrome: DocPage = { }, ], related: [ + "/docs/guides/styling/", "/docs/theming/", "/docs/player/configurator/", "/docs/installation/", diff --git a/apps/docs/src/registry/pages.ts b/apps/docs/src/registry/pages.ts index 0545863..af41c65 100644 --- a/apps/docs/src/registry/pages.ts +++ b/apps/docs/src/registry/pages.ts @@ -11,6 +11,7 @@ import { type PropDocs, propsTable } from "./props"; import { scannerPages } from "./scanner-pages"; import { scenePages } from "./scene-pages"; import { fileBlock } from "./shared"; +import { stylingPage } from "./styling-page"; import { TRANSITIONS } from "./transitions"; import type { WrappedView } from "@react-wrapped/kit"; @@ -499,6 +500,12 @@ const VIEW_PROPS: PropDocs[0], { subject: "The [player's handle](/docs/player/wrapped-player/): play, pause, seek.", "optional", ], + className: ["string", "A class for the page's root.", "optional"], + classNames: [ + "SlotClassNames", + 'A class per part, by slot name, for the page and everything on it: `"view-player-panel"`, `"player-play"`, `"settings-chip"`. See [styling](/docs/guides/styling/).', + "optional", + ], loading: [ "undefined", "Not passed with a year. Pass it in place of the year and the view is the [loading page](/docs/player/loading/).", @@ -603,7 +610,7 @@ export const SECTIONS: readonly Section[] = [ { id: "scanner", title: "Scanner", pages: [...scannerPages, extraPages.scanGate] }, { id: "graphics", title: "Graphics", pages: graphicsPages }, { id: "marks", title: "Marks", pages: marksPages }, - { id: "guides", title: "Guides", pages: [...guidePages, extraPages.labels] }, + { id: "guides", title: "Guides", pages: [...guidePages, extraPages.labels, stylingPage] }, ]; export const PAGES: readonly DocPage[] = SECTIONS.flatMap((section) => section.pages); diff --git a/apps/docs/src/registry/parts.ts b/apps/docs/src/registry/parts.ts index 6c48b71..6d4e4b9 100644 --- a/apps/docs/src/registry/parts.ts +++ b/apps/docs/src/registry/parts.ts @@ -21,6 +21,7 @@ export const PART_IDS = [ "marks", "own-colour", "own-chrome", + "own-style", "config-url", "labels", "share-actions", diff --git a/apps/docs/src/registry/player-pages.ts b/apps/docs/src/registry/player-pages.ts index b564f3b..ec4ef34 100644 --- a/apps/docs/src/registry/player-pages.ts +++ b/apps/docs/src/registry/player-pages.ts @@ -26,6 +26,8 @@ import type { * panel, the frame while a year loads, and the MP4. Every props table is keyed by the real props. */ +const STYLING = "See [styling](/docs/guides/styling/)."; + const INSTALL = code("bash", ["pnpm add @react-wrapped/player @react-wrapped/core gsap"]); const STYLES = code("tsx", [`import "@react-wrapped/core/styles.css";`, `import "@react-wrapped/player/styles.css";`]); @@ -48,6 +50,11 @@ const PLAYER_PROPS: PropDocs[0]> = { "optional", ], className: ["string", "A class for the player's root, for sizing it in your layout.", "optional"], + classNames: [ + "SlotClassNames", + `A class per part, by slot name: \`"player-play"\`, \`"timeline-segment-bar"\`. ${STYLING}`, + "optional", + ], }; const SUBJECT_PROPS: PropDocs = { @@ -103,6 +110,12 @@ const TIMELINE_PROPS: PropDocs[0]> = { "optional", ], onSeek: ["(seconds: number) => void", "Called while the viewer clicks or drags along the row."], + className: ["string", "A class for the timeline's root.", "optional"], + classNames: [ + "SlotClassNames", + `A class per part, by slot name: \`"timeline-segment-bar"\`, \`"timeline-preview"\`. ${STYLING}`, + "optional", + ], }; const EDITING_DOCS: PropDocs = { @@ -126,6 +139,12 @@ const EDITOR_PROPS: PropDocs[0]> = { "The texts that differ from the original, by field name, and the picked transition.", ], onClose: ["() => void", "The dialog was closed, by saving or by leaving."], + className: ["string", "A class for the dialog itself.", "optional"], + classNames: [ + "SlotClassNames", + `A class per part, by slot name: \`"editor-input"\`, \`"editor-save"\`. ${STYLING}`, + "optional", + ], }; const CUT_DOCS: PropDocs = { @@ -138,6 +157,11 @@ const CONFIGURATOR_PROPS: PropDocs[0]> = config: ["WrappedConfig", "What the panel shows as picked."], onChange: ["(next: WrappedConfig) => void", "Called with the whole next config on every change."], className: ["string", "A class for the panel's root.", "optional"], + classNames: [ + "SlotClassNames", + `A class per part, by slot name: \`"settings-chip"\`, \`"settings-swatch"\`. ${STYLING}`, + "optional", + ], }; const CONFIG_DOCS: PropDocs = { @@ -159,6 +183,11 @@ const LOADING_PROPS: PropDocs[0]> = { lines: ["readonly string[]", "What to read meanwhile. More than one take turns, 2.6 seconds each."], config: ["WrappedConfig", "The frame takes the format and palette the video will have."], className: ["string", "A class for the root, the same one you give the player.", "optional"], + classNames: [ + "SlotClassNames", + `The same object you give the player, so the frame and the controls look the same before the year as after. ${STYLING}`, + "optional", + ], }; const LOADING_SUBJECT: PropDocs = { diff --git a/apps/docs/src/registry/scanner-pages.ts b/apps/docs/src/registry/scanner-pages.ts index 2510135..e58a962 100644 --- a/apps/docs/src/registry/scanner-pages.ts +++ b/apps/docs/src/registry/scanner-pages.ts @@ -45,6 +45,12 @@ const SCAN_PROPS: PropDocs[0]> = { "A scan started, or came to rest: finished, stopped or skipped to its end.", "optional", ], + className: ["string", "A class for the panel.", "optional"], + classNames: [ + "SlotClassNames", + 'A class per part, by slot name: `"scan-start"`, `"scan-card"`. See [styling](/docs/guides/styling/).', + "optional", + ], }; const DROP_DOCS: PropDocs = { diff --git a/apps/docs/src/registry/styling-page.ts b/apps/docs/src/registry/styling-page.ts new file mode 100644 index 0000000..96c233c --- /dev/null +++ b/apps/docs/src/registry/styling-page.ts @@ -0,0 +1,370 @@ +import { sourceOf } from "../content/sources"; +import type { Block, DocPage } from "../content/types"; +import { code, fileBlock, UPDATED } from "./shared"; +import type { GraphicsGallerySlot } from "@react-wrapped/graphics"; +import type { WrappedViewSlot } from "@react-wrapped/kit"; +import type { + ConfiguratorSlot, + SceneEditorSlot, + ShareActionsSlot, + TimelineSlot, + WrappedPlayerSlot, +} from "@react-wrapped/player"; +import type { YearScanSlot } from "@react-wrapped/scan"; + +/* + * The styling guide: tokens, slots, and where the library's stylesheet has to stand for Tailwind's utilities to win. + * + * Every slot table is an object keyed by the slot names its component exports. A slot added to the library without + * a line here stops the typecheck, and so does a line left behind for a slot that is gone. + */ + +/** One line per slot of a component, as the table a page shows. */ +const slotTable = (docs: Record): Block => ({ + kind: "table", + head: ["Slot", "What it is"], + rows: Object.entries(docs).map(([slot, text]) => [`\`${slot}\``, text]), +}); + +const PLAYER_SLOTS: Record = { + player: "The root: the video and the controls under it. `className` lands here too.", + "player-room": "The space the frame is fitted into.", + "player-frame": "The frame around the video: its corners, its shadow, its outline.", + "player-transport": "The row of controls under the video.", + "player-play": "The play and pause button.", + "player-restart": "The button that plays from the start.", + "player-clock": "Where the playhead is and how long the video runs.", + "player-fullscreen": "The full screen button.", +}; + +const TIMELINE_SLOTS: Record = { + timeline: "The root: the row of segments and the preview that opens over it.", + "timeline-track": "The row of segments, the part that is clicked and dragged.", + "timeline-segment": "One scene's share of the row.", + "timeline-segment-bar": "The bar drawn inside a segment.", + "timeline-segment-fill": "The played part of a bar.", + "timeline-popover": "What holds the preview over its segment. Its `z-index` is set here.", + "timeline-preview": "The preview card.", + "timeline-preview-thumb": "The box around the still of the scene.", + "timeline-preview-title": "The scene's name.", + "timeline-preview-length": "How long the scene plays, or that it is off.", + "timeline-preview-rarity": "How rare a found scene is. Its colour is the palette's, set inline.", + "timeline-controls": "The creator's row of controls in the card.", + "timeline-move-earlier": "The arrow that moves the scene one place earlier.", + "timeline-move-later": "The arrow that moves it one place later.", + "timeline-edit": "The pencil that opens the scene editor.", + "timeline-toggle": "The switch that takes the scene out of the video.", + "timeline-toggle-thumb": "The dot that slides in that switch.", +}; + +const SETTINGS_SLOTS: Record = { + settings: "The root. `className` lands here too.", + "settings-field": "One setting: its name and what there is to pick.", + "settings-label": "A setting's name.", + "settings-palette": "The grid of palettes.", + "settings-swatch": "One palette in the grid. It is a radio: the picked one has `aria-checked`.", + "settings-swatch-colors": "The palette's five colours on its swatch.", + "settings-swatch-color": "One of the five.", + "settings-swatch-name": "The palette's name.", + "settings-accent": "The row for a colour of your own, shown while the custom palette is picked.", + "settings-accent-input": "The colour input in that row.", + "settings-accent-random": "The button that picks a random colour.", + "settings-chips": "A row of choices: dark or light, format, cell shape, speed.", + "settings-chip": "One choice. It is a radio: the picked one has `aria-checked`.", + "settings-divider": "The line above the switches.", + "settings-switch-row": "A setting that is on or off: its name and its switch.", + "settings-switch-label": "That setting's name.", + "settings-switch": "The switch. Its state is in `data-state`: `checked` or `unchecked`.", + "settings-switch-thumb": "The dot that slides in the switch.", +}; + +const SHARE_SLOTS: Record = { + share: "The root: the row's own box. `className` lands here too.", + "share-copy": "The button that copies the link.", + "share-download": "The button that makes the video file.", + "share-stop": "What the download button turns into while the file is made: it counts up, and stops when pressed.", + "share-progress": "The line under the buttons that says what is being made and how long is left.", + "share-problem": "The line under the buttons that says why a file could not be made.", +}; + +const EDITOR_SLOTS: Record = { + editor: "The dialog. Its backdrop is this element's `::backdrop`. `className` lands here too.", + "editor-header": "The top of the dialog: the icon, the title and the line under it.", + "editor-icon": "The disc with the pencil in it.", + "editor-title": "The dialog's title.", + "editor-description": "The line under the title.", + "editor-body": "Everything between the header and the buttons.", + "editor-field": "One thing to edit: its name and its input.", + "editor-label": "A field's name.", + "editor-input": "A text input, one line or several.", + "editor-cut": "The transition picker: the preview beside the choices.", + "editor-preview": "The box around the transition's preview.", + "editor-transitions": "The row of transitions.", + "editor-transition": "One transition. It is a radio: the picked one has `aria-checked`.", + "editor-footer": "The row of buttons at the bottom.", + "editor-reset": "The button that restores the original scene.", + "editor-save": "The button that saves.", +}; + +const SCAN_SLOTS: Record = { + scan: "The root: the panel. Its background and text colour are the palette's, set inline. `className` lands here too.", + "scan-header": "The top of the panel: the words on one side, the buttons on the other.", + "scan-headline": "The headline, which changes with what the scan is doing.", + "scan-about": "The line under the headline.", + "scan-actions": "The buttons in the header: yours from `actions`, then the scan's own.", + "scan-start": "The button that starts the scan, or picks it up again. Its colours are set inline.", + "scan-find-all": "Shown while a scan runs: sends it straight to its end.", + "scan-again": "Shown once everything is found: runs the scan again.", + "scan-grid": "The year: a cell per day.", + "scan-cell": "One day. Its colour is the palette's level for that day, set inline.", + "scan-tile": "The button over a cell something was found in.", + "scan-finds": "The list under the year, a badge per hidden scene.", + "scan-find": "A found scene's badge. Its colours are set inline, from its rarity.", + "scan-find-hidden": "The badge of a scene that is still hidden.", + "scan-find-art": "A badge's little picture.", + "scan-find-name": "A badge's name.", + "scan-find-day": "The day a badge was found on.", + "scan-flight": "What a found card flies in. It covers the page and is drawn at the end of `body`, outside the panel.", + "scan-backdrop": "The dimmed page behind a card.", + "scan-card": "The card's face. Its colours are set inline, from its rarity.", + "scan-card-art": "The picture on the card.", + "scan-card-name": "The name on the card.", + "scan-card-rarity": "How rare it is.", + "scan-card-line": "The line that says what it was earned for.", + "scan-card-day": "The day it was found on.", + "scan-card-back": "The card's back, shown before it turns over.", + "scan-card-button": "The button that covers the card: it turns it over, adds it to the video, or closes it.", +}; + +const GALLERY_SLOTS: Record = { + gallery: "The root: the heading, the line under it and the tiles. `className` lands here too.", + "gallery-heading": "The heading.", + "gallery-heading-accent": "The heading's second half, in the accent colour.", + "gallery-about": "The line under the heading.", + "gallery-grid": "The grid of tiles.", + "gallery-tile": "A graphic that can be taken. Its background and text colour are the palette's, set inline.", + "gallery-art": "The space the picture stands in.", + "gallery-image": "The picture.", + "gallery-placeholder": "The dot that waits where a picture is still being drawn.", + "gallery-title": "A tile's title.", + "gallery-line": "The line under the title.", + "gallery-actions": "The row of buttons on a tile.", + "gallery-download": "The download button. Its colours are set inline.", + "gallery-markdown": "The button that copies the Markdown snippet. Its colours are set inline.", + "gallery-problem": "Said under a tile whose picture did not come through.", + "gallery-locked": "A graphic the scan has not found yet.", + "gallery-lock": "The disc with the lock in it.", +}; + +const VIEW_SLOTS: Record = { + view: "The root: the grid the whole page is laid out on. `className` lands here too.", + "view-player-panel": "The card the player stands in.", + "view-side": "The creator's column beside the video: the share row over the settings.", + "view-settings-panel": + "The card the settings stand in. The share row above it is a card by itself: its slot is `share`.", + "view-viewer": "What holds your `viewer` content, under the video, for someone who is not the creator.", + "view-scan": "What holds the year scan.", + "view-gallery": "What holds the gallery.", +}; + +export const stylingPage: DocPage = { + path: "/docs/guides/styling/", + section: "guides", + title: "Styling", + tagline: "tokens, slots and Tailwind", + nav: "Styling", + description: + "Restyle the react-wrapped player, settings, scan and gallery by hand: CSS tokens for colour, a class per slot with classNames, and one layer line for Tailwind.", + lead: "Everything around the video can be restyled, at three levels. Tokens recolour all of it at once. Slots put a class of yours on one part: the play button, a timeline segment, a settings chip. And one line of CSS makes your Tailwind utilities win over the library's own classes. This guide goes through the three, cheapest first, and lists every slot there is.", + blocks: [ + { + kind: "preview", + demo: { type: "part", id: "own-style" }, + caption: "The same player and settings with and without a class per slot. Square buttons, outlined chips.", + }, + fileBlock(sourceOf("demos/own-style.tsx"), "own-style.tsx"), + { kind: "h2", text: "Tokens" }, + { + kind: "p", + text: "Start here. Ten custom properties named `--wr-ui-*` hold every colour and the corner radius of the chrome, and `--wr-font` holds its font. Set them on any element above the library's parts and everything under it follows, in light and dark. No component has to be touched. They are listed in [custom palette and chrome colours](/docs/guides/palette-and-chrome/).", + }, + code("css", [ + `:root {`, + ` --wr-ui-primary: oklch(0.62 0.2 285);`, + ` --wr-ui-radius: 6px;`, + ` --wr-font: "Your Font", sans-serif;`, + `}`, + ]), + { kind: "h2", text: "Slots" }, + { + kind: "p", + text: 'Tokens change a colour everywhere. To change the shape, the size or the type of one part, reach that part. Every element of the chrome you might want to restyle is a slot: it has a name, it carries that name as `data-wr-slot="player-play"`, and it takes a class for it. A class reaches a slot in three ways.', + }, + { kind: "h3", text: "On one component" }, + { + kind: "p", + text: "Every chrome component takes `classNames`: an object with a class per slot. `className` is still there and is the root's class. A component that renders another takes that one's slots too and hands them on, so the player takes the timeline's, and `WrappedView` takes all of them in one object.", + }, + code("tsx", [ + ``, + ]), + { kind: "h3", text: "For the whole page" }, + { + kind: "p", + text: "[`WrappedProvider`](/docs/guides/labels/) takes the same object and applies it to everything under it, so a design is written once. Keep the object the same between renders: a constant outside the component, or `useMemo`.", + }, + code("tsx", [ + `import { type SlotClassNames, WrappedProvider, type WrappedSlot } from "@react-wrapped/kit";`, + ``, + `// Typed against every slot there is, so a misspelt name does not compile.`, + `const CLASS_NAMES: SlotClassNames = {`, + ` "player-play": "my-play-button",`, + ` "settings-chip": "my-chip",`, + ` "scan-start": "my-play-button",`, + ` "gallery-tile": "my-tile",`, + `};`, + ``, + `export function Page({ children }: { children: React.ReactNode }) {`, + ` return {children};`, + `}`, + ]), + { kind: "h3", text: "From a stylesheet" }, + { + kind: "p", + text: "The attribute is there whether you pass a class or not, so a stylesheet can target a part with no React involved at all.", + }, + code("css", [ + `[data-wr-slot="player-play"] {`, + ` border-radius: 6px;`, + `}`, + ``, + `/* A picked chip, and only inside your own wrapper. */`, + `.my-settings [data-wr-slot="settings-chip"][aria-checked="true"] {`, + ` background: rebeccapurple;`, + `}`, + ]), + { kind: "h3", text: "How the classes add up" }, + { + kind: "list", + items: [ + "**The order on an element** is the library's own classes, then the provider's class for that slot, then the component's `classNames`. Nothing of the library's is taken away: your class is added after it.", + "**Providers nest** the way they do for labels. A slot named by an inner provider takes the inner class in place of the outer one, and every other slot keeps what the outer provider gave it.", + "**With no `classNames` anywhere** every part renders exactly the classes it did before. The only addition is the `data-wr-slot` attribute.", + '**A state needs the state in your selector.** The library styles hover, focus and a picked choice with selectors that have the state in them, and those outweigh a bare class. To restyle a picked chip write `.my-chip[aria-checked="true"]`, or with Tailwind `aria-checked:bg-violet-600`.', + "**The scan and the gallery wear the video's palette.** Their backgrounds and colours are inline styles, so the year looks the way it does in the video. A class changes their shape and type freely, and their colours only with `!important`. The tables below say which slots those are.", + "**Two things are drawn outside their component's box.** The scan's flying card is drawn at the end of `body`, and the scene editor is a dialog in the top layer. `classNames` and the provider reach both. A stylesheet selector that starts from your own wrapper does not.", + '**The video itself has no slots.** The frame\'s look is the palette and the scene, so an exported file is the same on any site. Restyle what is around it, and give the frame its corners through `"player-frame"`.', + ], + }, + { kind: "h2", text: "With Tailwind" }, + { + kind: "p", + text: "This needs one line of setup, and it is worth knowing why. The library's stylesheet is plain CSS, outside any cascade layer. Tailwind v4 puts its utilities in `@layer utilities`, and a rule in a layer loses to a rule outside one, whatever their order or weight. So out of the box a Tailwind class like `bg-red-500` passed to a slot does **not** win over the library's own class for the background.", + }, + { + kind: "p", + text: "The fix is to import the library's stylesheet into a layer that comes before utilities. In your app's CSS, after Tailwind:", + }, + code("css", [`@import "tailwindcss";`, `@import "@react-wrapped/kit/styles.css" layer(components);`], "app.css"), + { + kind: "p", + text: "From then on any utility you pass through `className` or `classNames` wins, states included:", + }, + code("tsx", [ + ``, + ]), + { + kind: "list", + items: [ + '**Load the stylesheet only this way.** Take the `import "@react-wrapped/kit/styles.css"` out of your JavaScript. A second copy outside the layer would still be on the page, and it would still win.', + "**The same line works for the single packages**: import `@react-wrapped/core/styles.css` and the others into the same layer, core first.", + "**Set the chrome tokens outside a layer**, or in a layer after `components`. The library's own values are now in `components`, so a `--wr-ui-*` set inside `@layer base` would lose to them.", + "**Your unlayered rules now outrank the library too.** That is the point of the move, and it includes element rules: a bare `button { ... }` outside any layer reaches the library's buttons. Tailwind's own reset is in `base`, before `components`, so it does not.", + "**Colours set inline** on the scan and the gallery still need the important modifier: `bg-violet-600!`.", + ], + }, + { kind: "h2", text: "Without Tailwind" }, + { + kind: "p", + text: "With plain CSS or CSS modules there is nothing to set up. Load the library's stylesheet before your own. A class of yours and a class of the library's then weigh the same, and yours wins by coming later. That is all the preview at the top of this page does.", + }, + code("tsx", [ + `import "@react-wrapped/kit/styles.css";`, + `// After the library's, so a class from here wins over the library's class on the same element.`, + `import "./app.css";`, + ]), + { + kind: "p", + text: "When you cannot control the order, as with some CSS module setups, write the slot into the selector. An attribute and a class together outweigh the library's single class, so the rule wins wherever it is loaded.", + }, + code("css", [ + `.playButton[data-wr-slot="player-play"] {`, + ` border-radius: 6px;`, + ` background: rebeccapurple;`, + `}`, + ]), + { kind: "h2", text: "Every slot" }, + { + kind: "p", + text: "Each component exports its slot names twice: as a type (`WrappedPlayerSlot`) for your own objects, and as a list (`WRAPPED_PLAYER_SLOTS`). `WrappedSlot` from `@react-wrapped/kit` is all of them together.", + }, + { kind: "h3", text: "WrappedPlayer" }, + { + kind: "p", + text: "`WrappedPlayerSlot`. [`WrappedPlayerLoading`](/docs/player/loading/) has the same slots, so the frame and the controls look the same before the year arrives as after.", + }, + slotTable(PLAYER_SLOTS), + { kind: "h3", text: "Timeline" }, + { + kind: "p", + text: "`TimelineSlot`. The [player](/docs/player/wrapped-player/) takes these as well. The preview and everything in it is on the page only while a segment is hovered, and the controls only for a creator.", + }, + slotTable(TIMELINE_SLOTS), + { kind: "h3", text: "WrappedConfigurator" }, + { kind: "p", text: "`ConfiguratorSlot`, on the [settings panel](/docs/player/configurator/)." }, + slotTable(SETTINGS_SLOTS), + { kind: "h3", text: "ShareActions" }, + { kind: "p", text: "`ShareActionsSlot`, on the [share row](/docs/player/share-actions/)." }, + slotTable(SHARE_SLOTS), + { kind: "h3", text: "SceneEditor" }, + { kind: "p", text: "`SceneEditorSlot`, on the [scene editor](/docs/player/scene-editor/)." }, + slotTable(EDITOR_SLOTS), + { kind: "h3", text: "YearScan" }, + { kind: "p", text: "`YearScanSlot`, on the [year scan](/docs/scanner/year-scan/)." }, + slotTable(SCAN_SLOTS), + { kind: "h3", text: "GraphicsGallery" }, + { kind: "p", text: "`GraphicsGallerySlot`, on the [gallery](/docs/graphics/gallery/)." }, + slotTable(GALLERY_SLOTS), + { kind: "h3", text: "WrappedView" }, + { + kind: "p", + text: "`WrappedViewSlot` is the page's own layout. The component takes every slot on this page in one `classNames` object and hands each part its own.", + }, + slotTable(VIEW_SLOTS), + ], + related: [ + "/docs/guides/palette-and-chrome/", + "/docs/guides/labels/", + "/docs/theming/", + "/docs/player/wrapped-player/", + "/docs/guides/from-parts/", + ], + updated: UPDATED, +}; diff --git a/packages/core/src/chips.tsx b/packages/core/src/chips.tsx index 1d5d796..231b38e 100644 --- a/packages/core/src/chips.tsx +++ b/packages/core/src/chips.tsx @@ -1,4 +1,5 @@ import { cn } from "./internal/cn"; +import type { SlotProps } from "./internal/slots"; const chip = "wr:inline-flex wr:h-9 wr:flex-none wr:cursor-pointer wr:items-center wr:justify-center wr:rounded-full wr:bg-muted wr:px-3.5 wr:text-[13.5px] wr:font-semibold wr:whitespace-nowrap wr:outline-none wr:transition-colors wr:hover:text-foreground wr:focus-visible:ring-[3px] wr:focus-visible:ring-ring/50 wr:aria-checked:bg-foreground wr:aria-checked:text-background"; @@ -9,17 +10,21 @@ export function Chips({ value, options, onChange, + slots, }: { label: string; value: T; options: readonly { value: T; label: string }[]; onChange: (value: T) => void; + /** What the component using the chips calls them: the row, and each chip in it. */ + slots?: { group?: SlotProps; chip?: SlotProps }; }) { return (
{options.map((option) => ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bbac8cd..8d0edfa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from "./platform-mark"; export * from "./primitives"; export * from "./rarity"; export * from "./rng"; +export * from "./slots"; export * from "./stage"; export * from "./types"; export * from "./use-wrapped-timeline"; diff --git a/packages/core/src/internal/index.ts b/packages/core/src/internal/index.ts index 623caa7..6daa97d 100644 --- a/packages/core/src/internal/index.ts +++ b/packages/core/src/internal/index.ts @@ -8,5 +8,6 @@ export * from "./cn"; export * from "./icons"; export * from "./modal"; export * from "./pill"; +export * from "./slots"; export * from "./switch"; export * from "./use-copied"; diff --git a/packages/core/src/internal/modal.tsx b/packages/core/src/internal/modal.tsx index c4fece4..76e5e37 100644 --- a/packages/core/src/internal/modal.tsx +++ b/packages/core/src/internal/modal.tsx @@ -1,6 +1,7 @@ import { type ComponentType, type ReactNode, useEffect, useRef } from "react"; import { cn } from "./cn"; +import type { SlotProps } from "./slots"; /** * A dialog on the browser's own ``: focus trapping, Escape and the backdrop come with the element, so the @@ -13,6 +14,7 @@ export function Modal({ title, description, children, + slots, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -20,6 +22,8 @@ export function Modal({ title: string; description?: string; children?: ReactNode; + /** What the component using the dialog calls its parts. */ + slots?: { root?: SlotProps; header?: SlotProps; icon?: SlotProps; title?: SlotProps; description?: SlotProps }; }) { const ref = useRef(null); useEffect(() => { @@ -45,20 +49,49 @@ export function Modal({ onClick={(event) => { if (event.target === ref.current) onOpenChange(false); }} - className="wr-root wr:m-auto wr:w-[min(92vw,640px)] wr:rounded-[28px] wr:bg-card wr:p-0 wr:text-foreground wr:ring-1 wr:ring-border wr:backdrop:bg-black/60" + {...slots?.root} + className={cn( + "wr-root wr:m-auto wr:w-[min(92vw,640px)] wr:rounded-[28px] wr:bg-card wr:p-0 wr:text-foreground wr:ring-1 wr:ring-border wr:backdrop:bg-black/60", + slots?.root?.className, + )} > {open ? ( <> -
+
{Icon ? ( - + ) : null}
-

{title}

+

+ {title} +

{description ? ( -

{description}

+

+ {description} +

) : null}
@@ -69,22 +102,51 @@ export function Modal({ ); } -export function ModalBody({ children, className }: { children: ReactNode; className?: string }) { - return
{children}
; +export function ModalBody({ + children, + className, + slot, +}: { + children: ReactNode; + className?: string; + slot?: SlotProps; +}) { + return ( +
+ {children} +
+ ); } -export function ModalFooter({ children }: { children: ReactNode }) { +export function ModalFooter({ children, slot }: { children: ReactNode; slot?: SlotProps }) { return ( -