@vvedantb/ui 0.7.2 → 0.9.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/README.md +89 -1
- package/app/README.md +93 -0
- package/app/src/index.ts +31 -0
- package/app/src/page-motion/PageMotionProvider.tsx +44 -0
- package/app/src/page-title/createPageTitleContext.tsx +99 -0
- package/app/src/theme/createThemeProvider.tsx +275 -0
- package/app/src/time/compactRelativeTime.ts +89 -0
- package/package.json +7 -3
- package/shell/src/SidebarAccountMenu.tsx +78 -2
- package/shell/src/SidebarRail.tsx +20 -0
- package/shell/src/index.ts +2 -2
- package/src/index.ts +52 -2
- package/src/marketing/PricingModal.tsx +126 -0
- package/src/marketing/SiteFooter.tsx +135 -0
- package/src/marketing/createLandingKit.tsx +214 -0
- package/src/marketing/landing.css +271 -0
- package/src/marketing/landing.tsx +217 -0
- package/src/marketing/marketing-classes.ts +115 -0
package/README.md
CHANGED
|
@@ -4,12 +4,13 @@ Shared React 19 web UI. Source-exported: no build step.
|
|
|
4
4
|
|
|
5
5
|
| Import | What it is |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
7
|
+
| `@vvedantb/ui` | The only JavaScript entry. Primitives (Radix-based), AI elements, audio (`playChime`, `useReplyChime`, `playTone`), `toast` and `SonnerToaster`, the composer (`InputArea`, deprecated 0.3 `Composer`), shell (`SidebarHeader`, `PillTabs`, `SessionsHoverCard`, and the app shell: `AppShell`, `AppSidebar`, `SidebarRail`, `RailTile`, `SidebarPanel`, `SidebarAccountMenu`, `SignOutDialog` and related helpers), app helpers (confirm skip, update watcher, settings chrome, `useStableAuth`, and from 0.8.0 `createThemeProvider`, `createPageTitleContext`, `PageMotionProvider` and `formatCompactRelativeTime`), marketing pieces from 0.9.0 (`createLandingKit` and the `Landing*` parts, `SiteFooter`, `PricingModal`), `cn`, and the native tokens `tokens` and `Tokens`. See `app/README.md` in the repository for the app helpers. |
|
|
8
8
|
| `@vvedantb/ui/tokens` | `tokens.css`: colour, radius, shadow, ease and font tokens for Tailwind v4. CSS. |
|
|
9
9
|
| `@vvedantb/ui/tokens/tokens.css` | Same file as `@vvedantb/ui/tokens`. CSS. |
|
|
10
10
|
| `@vvedantb/ui/tokens/css/*.css` | `index`, `base`, `dialog`, `modal`, `tabs`, `tabs-pills`, `utilities`, `scrollbar-thin`, `animations`, `compat-shadcn-rgb`. CSS. |
|
|
11
11
|
| `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | Same file as `tokens/css/compat-shadcn-rgb.css`. CSS. |
|
|
12
12
|
| `@vvedantb/ui/tokens/tailwind-preset` | Tailwind preset (`.mjs`) for `@config "@vvedantb/ui/tokens/tailwind-preset"`. Kept as a build-tool entry: Tailwind needs a resolvable package path, and it is not a React API. |
|
|
13
|
+
| `@vvedantb/ui/landing.css` | Landing page classes (`landing-atmosphere`, `landing-grain`, `landing-pulse-dot`, ambient graph and preview animations, smooth `#top` scrolling). CSS. |
|
|
13
14
|
| `@vvedantb/ui/package.json` | Package metadata. |
|
|
14
15
|
|
|
15
16
|
Removed in 0.4.0-rc.1: the `/cn`, `/sonner`, `/audio`, `/shell`, `/app`, `/composer` and `/tokens/native` subpaths. Import from `@vvedantb/ui`. See [`MIGRATION.md`](MIGRATION.md).
|
|
@@ -792,6 +793,93 @@ Rows (hover, active, selected, drop target) use `bg-sidebar-accent` instead of `
|
|
|
792
793
|
|
|
793
794
|
Rail tiles keep `surface-tertiary`, so in dark mode the active rail tile is a step lighter than the active row. An app that imports `tokens.css` and defines its own `--sidebar-accent` overrides the fill; it must be a full colour value there (use `compat-shadcn-rgb.css` for `R G B` channels).
|
|
794
795
|
|
|
796
|
+
## Landing kit, site footer and pricing dialog (0.9.0)
|
|
797
|
+
|
|
798
|
+
Marketing pieces the apps copied between each other. Each one renders the same
|
|
799
|
+
DOM and class strings as the app copies it replaces. Copy, links, logos, prices
|
|
800
|
+
and auth buttons come from the app; the package imports no router, no Clerk and
|
|
801
|
+
no Motion.
|
|
802
|
+
|
|
803
|
+
Import the stylesheet once (from CSS or TypeScript):
|
|
804
|
+
|
|
805
|
+
```css
|
|
806
|
+
@import "@vvedantb/ui/landing.css";
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
The components live under `src/`, which the usual `@source` line already scans.
|
|
810
|
+
|
|
811
|
+
### Landing kit
|
|
812
|
+
|
|
813
|
+
`createLandingKit({ motion })` takes `motion` from `motion/react` and returns
|
|
814
|
+
the animated parts. Call it once at module scope.
|
|
815
|
+
|
|
816
|
+
```tsx
|
|
817
|
+
import { motion } from "motion/react";
|
|
818
|
+
import { createLandingKit } from "@vvedantb/ui";
|
|
819
|
+
|
|
820
|
+
export const { LandingReveal, LandingRevealItem, LandingHero, LandingNav, LandingCta } =
|
|
821
|
+
createLandingKit({ motion });
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
| Part | Props |
|
|
825
|
+
| --- | --- |
|
|
826
|
+
| `LandingReveal` | `className`, `delay` (seconds, default `0`). Staggers its children in once, when 20% is in view. |
|
|
827
|
+
| `LandingRevealItem` | `className`. One revealed child (`landingItemVariants`). |
|
|
828
|
+
| `LandingHero` | `eyebrow`, `title`, `description` (nodes), `actions` (the button row; it renders even when empty), `capabilities` (strings). |
|
|
829
|
+
| `LandingNav` | `brand`, `homeLabel` (home link `aria-label`), `links`, `github` (`href`, `label`, `icon`), `actions`, `menu`. |
|
|
830
|
+
| `LandingCta` | `title`, `description`, `action`. |
|
|
831
|
+
|
|
832
|
+
Plain parts, no Motion:
|
|
833
|
+
|
|
834
|
+
| Part | Props |
|
|
835
|
+
| --- | --- |
|
|
836
|
+
| `LandingPageLayout` | `nav`, `heroBackdrop` (before the hero, inside the atmosphere block), `hero`, `children` (sections), `footer`. Root is `#top`. |
|
|
837
|
+
| `LandingSection` | `id`, `className`. |
|
|
838
|
+
| `LandingSectionHeading` | `eyebrow`, `heading`, `intro`, `className`. |
|
|
839
|
+
| `LandingLattice` | `className` (grid columns). Cells share 1px borders. |
|
|
840
|
+
| `LandingFeatureCard` | `icon` (a component, rendered `size={18} stroke={1.5}`), `title`, `description`. |
|
|
841
|
+
| `LandingNavMenu` | `links`, `github` (`href`, `icon`). Overflow menu below `md`. |
|
|
842
|
+
| `LandingFooter` | `brand`, `tagline`, `links`, `actions` (after the links), `github` (`href`, `label`, `icon`). |
|
|
843
|
+
|
|
844
|
+
Constants: `landingShellClass`, `landingPanelClass`, `landingItemVariants`, and
|
|
845
|
+
for the footer's `actions` slot `landingFooterLinkClass` (a text link) and
|
|
846
|
+
`landingFooterButtonClass` (a `Button variant="link"`).
|
|
847
|
+
|
|
848
|
+
### `SiteFooter`
|
|
849
|
+
|
|
850
|
+
Brand, tagline and social icon buttons; link columns; then copyright and legal
|
|
851
|
+
links under a separator.
|
|
852
|
+
|
|
853
|
+
| Prop | |
|
|
854
|
+
| --- | --- |
|
|
855
|
+
| `brand`, `tagline` | Nodes. |
|
|
856
|
+
| `socials` | `{ href, icon, label? }[]`. `icon` is a component that takes `className`. Links open in a new tab. |
|
|
857
|
+
| `columns` | `{ heading, links }[]`. A link is `{ href, label, external?, image?: { src, alt } }`. |
|
|
858
|
+
| `name`, `year` | "Copyright © {year} {name}". `year` defaults to the current year. |
|
|
859
|
+
| `legalLinks` | `{ href, label }[]`, open in a new tab. |
|
|
860
|
+
| `variant` | `"muted"` (default, `text-muted`), `"muted-foreground"`, or `"touch"` (`text-muted-foreground`, `max-sm:hit-target` links, safe-area bottom padding). |
|
|
861
|
+
|
|
862
|
+
### `PricingModal`
|
|
863
|
+
|
|
864
|
+
One-plan upgrade dialog. Props: `open`, `onClose`, `title` (accessible, hidden),
|
|
865
|
+
`description` (hidden; when set the title is an `sr-only` `DialogTitle`,
|
|
866
|
+
otherwise it sits in `VisuallyHidden`), `logo` (`dark` and `light` image URLs,
|
|
867
|
+
shown in that colour mode), `wordmark`, `price`, `tagline`,
|
|
868
|
+
`taglineFontClassName`, `features`, `ctaLabel` (default "Get Pro"),
|
|
869
|
+
`renderCheckout(button)` (wrap the button in the billing provider's checkout),
|
|
870
|
+
`closeButton` (an extra compact close button, default `false`) and `tone`
|
|
871
|
+
(`"muted"` default or `"muted-foreground"`).
|
|
872
|
+
|
|
873
|
+
### Account menu items and rail collapse tile
|
|
874
|
+
|
|
875
|
+
`SidebarAccountMenu` takes three optional props. Without them it renders as before.
|
|
876
|
+
|
|
877
|
+
- `themeToggle={{ isDark, onToggle }}`: a "Light mode" / "Dark mode" item after `children`.
|
|
878
|
+
- `signOut={{ onSignOut, description? }}`: a separator and a danger "Sign out" item, last. It opens `SignOutDialog`; Alt-click skips the dialog and `ConfirmSkipHint` shows while Alt is held. The menu owns the dialog state. If `onSignOut` rejects, the dialog buttons enable again.
|
|
879
|
+
- `itemIconSize`: `size` of those two items' icons. Default: the icon's own size.
|
|
880
|
+
|
|
881
|
+
`RailCollapseTile({ collapsed, onToggle })`: the rail tile that shows or hides the sidebar panel ("Show sidebar" with the expand icon, or "Hide sidebar").
|
|
882
|
+
|
|
795
883
|
## Toaster
|
|
796
884
|
|
|
797
885
|
```tsx
|
package/app/README.md
CHANGED
|
@@ -98,3 +98,96 @@ pnpm --filter @vvedantb/ui test
|
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
|
|
101
|
+
|
|
102
|
+
## Theme
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
import { createThemeProvider } from "@vvedantb/ui";
|
|
106
|
+
|
|
107
|
+
// One kit per app, at module level.
|
|
108
|
+
export const { ThemeProvider, useTheme } = createThemeProvider();
|
|
109
|
+
|
|
110
|
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
|
111
|
+
<App />
|
|
112
|
+
</ThemeProvider>;
|
|
113
|
+
|
|
114
|
+
const { theme, resolvedTheme, setTheme, isDark, toggleTheme, mounted } = useTheme();
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`createThemeProvider` is a port of next-themes 0.4.6. An app that uses next-themes can switch without a change in behaviour: the same localStorage writes, `<html>` classes and attributes, `style.colorScheme`, `prefers-color-scheme` listener, cross-tab `storage` sync and transition blocking. Parity tests run both side by side.
|
|
118
|
+
|
|
119
|
+
- Props, as in next-themes: `storageKey` (`"theme"`), `defaultTheme` (`"system"` with `enableSystem`, else `"light"`), `enableSystem` (`true`), `enableColorScheme` (`true`), `attribute` (`"data-theme"`; `"class"`, any `data-*`, or an array), `themes` (`["light", "dark"]`), `value` (theme name to written value), `forcedTheme`, `disableTransitionOnChange` (`false`) and `nonce`.
|
|
120
|
+
- State: `theme`, `setTheme` (a value or a function of the current theme), `resolvedTheme`, `systemTheme`, `forcedTheme` and `themes`, as in next-themes. Added: `mounted` (after the provider's first effect), `isDark` (`mounted && resolvedTheme === "dark"`) and `toggleTheme` (from `resolvedTheme`).
|
|
121
|
+
- `setTheme` is a new function each time `theme` changes and stable otherwise, as in next-themes, so effects that list it re-run at the same times.
|
|
122
|
+
- next-themes reads the DOM options (`attribute`, `themes`, `value`, `enableSystem`, `enableColorScheme`, `defaultTheme`, `disableTransitionOnChange`, `nonce`) once, on the first render, and so does this port. If `enableSystem` starts `false` and later turns `true`, a stored `"system"` is written as the class `system`. `forcedTheme` follows props.
|
|
123
|
+
- A nested `ThemeProvider` from the same kit renders only its children.
|
|
124
|
+
- `createThemeProvider({ missingProviderMessage })`: `useTheme()` outside the provider throws that message. Without it, `useTheme()` returns an empty state with no-op setters, as next-themes does.
|
|
125
|
+
- No inline script is rendered. next-themes renders one for server rendering; React does not run it in a client-rendered app. Keep a pre-paint script in `index.html` if the app has one.
|
|
126
|
+
|
|
127
|
+
For an app with its own theme state (for example a theme synced from the server), two helpers:
|
|
128
|
+
|
|
129
|
+
- `getSystemTheme()`: `"dark"` or `"light"` from `prefers-color-scheme`; `"light"` without `window`.
|
|
130
|
+
- `applyThemeClass(theme)`: removes `light` and `dark` from `<html>`, then adds `theme`. No-op without `document`.
|
|
131
|
+
|
|
132
|
+
## Page title
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { createPageTitleContext } from "@vvedantb/ui";
|
|
136
|
+
|
|
137
|
+
export const { PageTitleProvider, usePageTitle, usePageTitleSync } = createPageTitleContext({
|
|
138
|
+
missingProviderMessage: "usePageTitle must be used within a PageTitleProvider",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The shell reads it:
|
|
142
|
+
const { pageTitle } = usePageTitle();
|
|
143
|
+
// A page publishes it while mounted:
|
|
144
|
+
usePageTitleSync(title);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- `usePageTitle()` returns `{ pageTitle, setPageTitle, mobileActionsSlot, setMobileActionsSlot }`. `mobileActionsSlot` is an element a mobile top bar registers (pass `setMobileActionsSlot` as its `ref`) so pages can portal actions into it.
|
|
148
|
+
- `usePageTitleSync(title)` sets the title while the caller is mounted and clears it on unmount. Titles that are not strings publish `""`.
|
|
149
|
+
- `missingProviderMessage`: `usePageTitle()` outside the provider throws it. Without it, `usePageTitle()` returns an empty title and no-op setters.
|
|
150
|
+
- `documentTitle` (off by default; without it `document.title` is never touched): `{ suffix?, separator? = " · ", fallback? }`. While a page title is set, `document.title` is `` `${pageTitle}${separator}${suffix}` `` (just the page title without a suffix). While it is empty, `fallback`, else the title at mount. The mount-time title comes back on unmount.
|
|
151
|
+
|
|
152
|
+
## Page motion
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { MotionConfig } from "motion/react";
|
|
156
|
+
import { PageMotionProvider, useDisablePageMotion } from "@vvedantb/ui";
|
|
157
|
+
|
|
158
|
+
<PageMotionProvider disabled={flags?.disablePageMotion === true} motionConfig={MotionConfig}>
|
|
159
|
+
<App />
|
|
160
|
+
</PageMotionProvider>;
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- Sets `html[data-page-motion]` to `"off"` or `"on"` in a layout effect (CSS can skip page and route transitions on `"off"`). The attribute stays after unmount.
|
|
164
|
+
- Renders `motionConfig` with `reducedMotion` `"always"` when disabled, else `"never"`.
|
|
165
|
+
- `useDisablePageMotion()`: the `disabled` value, `false` outside the provider.
|
|
166
|
+
- This module does not import `motion`. Pass `MotionConfig`, or an app component that wraps it, defined at module level, for example with `LazyMotion`:
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
function LazyMotionConfig({ reducedMotion, children }: PageMotionConfigProps) {
|
|
170
|
+
return (
|
|
171
|
+
<LazyMotion features={domAnimation}>
|
|
172
|
+
<MotionConfig reducedMotion={reducedMotion}>{children}</MotionConfig>
|
|
173
|
+
</LazyMotion>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Compact relative time
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { formatCompactRelativeTime } from "@vvedantb/ui";
|
|
182
|
+
|
|
183
|
+
formatCompactRelativeTime(createdAt, { justNow: "now" }); // "now", "5m", "3h", "2d", "4mo", "1y"
|
|
184
|
+
formatCompactRelativeTime(updatedAt, { calendar: true }); // "42s", ..., calendar months and years
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Pure TypeScript, no dependencies. Options:
|
|
188
|
+
|
|
189
|
+
- `now`: clock in ms, default `Date.now()`.
|
|
190
|
+
- `justNow`: returned for anything under a minute old, future times included. Without it the result is whole seconds (`"42s"`), and a future time gives `"0s"` or a negative count.
|
|
191
|
+
- `calendar`: `false` (default) counts elapsed time: a day is 24 hours, a month 30 days, a year 12 such months. `true` counts as `dayjs().diff(date, unit)` does: days by the local calendar across DST changes, and calendar months and years.
|
|
192
|
+
|
|
193
|
+
Strings are parsed with `new Date(string)`. dayjs parses a date-only string such as `"2026-03-01"` as local midnight, `new Date` as UTC midnight; numbers, `Date` objects and ISO strings with a time give the same result either way. An invalid date gives `"NaNy"` (`"0mo"` with `calendar`).
|
package/app/src/index.ts
CHANGED
|
@@ -41,3 +41,34 @@ export {
|
|
|
41
41
|
type ClerkAuthSlice,
|
|
42
42
|
type StableAuthOptions,
|
|
43
43
|
} from "./auth/useStableAuth.js";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
applyThemeClass,
|
|
47
|
+
createThemeProvider,
|
|
48
|
+
getSystemTheme,
|
|
49
|
+
type ThemeAttribute,
|
|
50
|
+
type ThemeProviderKit,
|
|
51
|
+
type ThemeProviderOptions,
|
|
52
|
+
type ThemeProviderProps,
|
|
53
|
+
type ThemeState,
|
|
54
|
+
} from "./theme/createThemeProvider.js";
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
createPageTitleContext,
|
|
58
|
+
type DocumentTitleOptions,
|
|
59
|
+
type PageTitleContextKit,
|
|
60
|
+
type PageTitleContextOptions,
|
|
61
|
+
type PageTitleValue,
|
|
62
|
+
} from "./page-title/createPageTitleContext.js";
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
PageMotionProvider,
|
|
66
|
+
useDisablePageMotion,
|
|
67
|
+
type PageMotionConfigProps,
|
|
68
|
+
type PageMotionProviderProps,
|
|
69
|
+
} from "./page-motion/PageMotionProvider.js";
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
formatCompactRelativeTime,
|
|
73
|
+
type CompactRelativeTimeOptions,
|
|
74
|
+
} from "./time/compactRelativeTime.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createContext, use, useLayoutEffect, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** The props `MotionConfig` from `motion/react` takes here. */
|
|
4
|
+
export type PageMotionConfigProps = {
|
|
5
|
+
reducedMotion: "always" | "never";
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type PageMotionProviderProps = {
|
|
10
|
+
/** Page motion is off, for example from a user setting. */
|
|
11
|
+
disabled: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Pass `MotionConfig` from `motion/react`, or a component of the app that
|
|
14
|
+
* wraps it (for example in `LazyMotion`). Injected so this package does not
|
|
15
|
+
* import `motion`. Define it at module level, not inline.
|
|
16
|
+
*/
|
|
17
|
+
motionConfig: ComponentType<PageMotionConfigProps>;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const PageMotionContext = createContext(false);
|
|
22
|
+
|
|
23
|
+
/** Whether page motion is off (`false` outside `PageMotionProvider`). */
|
|
24
|
+
export function useDisablePageMotion(): boolean {
|
|
25
|
+
return use(PageMotionContext);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stamps `html[data-page-motion]` (`"off"` or `"on"`) in a layout effect so CSS
|
|
30
|
+
* page and route transitions can skip, and sets Motion's `reducedMotion` to
|
|
31
|
+
* `"always"` when disabled and `"never"` otherwise. The attribute stays after
|
|
32
|
+
* unmount.
|
|
33
|
+
*/
|
|
34
|
+
export function PageMotionProvider({ disabled, motionConfig: MotionConfig, children }: PageMotionProviderProps) {
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
document.documentElement.dataset.pageMotion = disabled ? "off" : "on";
|
|
37
|
+
}, [disabled]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<PageMotionContext value={disabled}>
|
|
41
|
+
<MotionConfig reducedMotion={disabled ? "always" : "never"}>{children}</MotionConfig>
|
|
42
|
+
</PageMotionContext>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createContext, use, useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type PageTitleValue = {
|
|
4
|
+
pageTitle: string;
|
|
5
|
+
setPageTitle: (title: string) => void;
|
|
6
|
+
/** Element a mobile top bar registers (as a ref) so pages can portal actions into it. */
|
|
7
|
+
mobileActionsSlot: HTMLElement | null;
|
|
8
|
+
setMobileActionsSlot: (slot: HTMLElement | null) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type DocumentTitleOptions = {
|
|
12
|
+
/** Appended after the page title, for example the app name. */
|
|
13
|
+
suffix?: string;
|
|
14
|
+
/** Between page title and suffix. Default `" · "`. */
|
|
15
|
+
separator?: string;
|
|
16
|
+
/** Title while no page title is set. Default: `document.title` when the provider mounted. */
|
|
17
|
+
fallback?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PageTitleContextOptions = {
|
|
21
|
+
/**
|
|
22
|
+
* `usePageTitle()` outside the provider throws this message. When unset it
|
|
23
|
+
* returns an empty title and no-op setters instead.
|
|
24
|
+
*/
|
|
25
|
+
missingProviderMessage?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Also write `document.title` as `pageTitle + separator + suffix`, or
|
|
28
|
+
* `fallback` while the page title is empty. Off by default: the provider
|
|
29
|
+
* does not touch `document.title`. The mount-time title comes back on unmount.
|
|
30
|
+
*/
|
|
31
|
+
documentTitle?: DocumentTitleOptions;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PageTitleContextKit = {
|
|
35
|
+
PageTitleProvider: (props: { children: ReactNode }) => ReactNode;
|
|
36
|
+
usePageTitle: () => PageTitleValue;
|
|
37
|
+
/** Publishes `title` while the caller is mounted; non-string titles publish `""`. Clears on unmount. */
|
|
38
|
+
usePageTitleSync: (title: ReactNode) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const noop = () => {};
|
|
42
|
+
|
|
43
|
+
function formatDocumentTitle(pageTitle: string, suffix: string | undefined, separator: string): string {
|
|
44
|
+
return suffix ? `${pageTitle}${separator}${suffix}` : pageTitle;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One page title context per app: the shell reads `pageTitle`, pages publish theirs. */
|
|
48
|
+
export function createPageTitleContext(options: PageTitleContextOptions = {}): PageTitleContextKit {
|
|
49
|
+
const { missingProviderMessage, documentTitle } = options;
|
|
50
|
+
const Context = createContext<PageTitleValue | null>(null);
|
|
51
|
+
const fallbackValue: PageTitleValue = {
|
|
52
|
+
pageTitle: "",
|
|
53
|
+
setPageTitle: noop,
|
|
54
|
+
mobileActionsSlot: null,
|
|
55
|
+
setMobileActionsSlot: noop,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function useDocumentTitle(pageTitle: string): void {
|
|
59
|
+
const [mountTitle] = useState(() => (documentTitle && typeof document !== "undefined" ? document.title : ""));
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!documentTitle) return;
|
|
62
|
+
const { suffix, separator = " · ", fallback = mountTitle } = documentTitle;
|
|
63
|
+
document.title = pageTitle ? formatDocumentTitle(pageTitle, suffix, separator) : fallback;
|
|
64
|
+
}, [pageTitle, mountTitle]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!documentTitle) return;
|
|
67
|
+
return () => {
|
|
68
|
+
document.title = mountTitle;
|
|
69
|
+
};
|
|
70
|
+
}, [mountTitle]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function PageTitleProvider({ children }: { children: ReactNode }) {
|
|
74
|
+
const [pageTitle, setPageTitle] = useState("");
|
|
75
|
+
const [mobileActionsSlot, setMobileActionsSlot] = useState<HTMLElement | null>(null);
|
|
76
|
+
useDocumentTitle(pageTitle);
|
|
77
|
+
return (
|
|
78
|
+
<Context value={{ pageTitle, setPageTitle, mobileActionsSlot, setMobileActionsSlot }}>{children}</Context>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function usePageTitle(): PageTitleValue {
|
|
83
|
+
const value = use(Context);
|
|
84
|
+
if (value !== null) return value;
|
|
85
|
+
if (missingProviderMessage !== undefined) throw new Error(missingProviderMessage);
|
|
86
|
+
return fallbackValue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function usePageTitleSync(title: ReactNode): void {
|
|
90
|
+
const { setPageTitle } = usePageTitle();
|
|
91
|
+
const isStringTitle = typeof title === "string";
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setPageTitle(isStringTitle ? title : "");
|
|
94
|
+
return () => setPageTitle("");
|
|
95
|
+
}, [title, isStringTitle, setPageTitle]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { PageTitleProvider, usePageTitle, usePageTitleSync };
|
|
99
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { createContext, use, useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** `"class"` or a `data-*` attribute on `<html>`. */
|
|
4
|
+
export type ThemeAttribute = "class" | `data-${string}`;
|
|
5
|
+
|
|
6
|
+
export type ThemeProviderProps = {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
/** localStorage key. Default `"theme"`. */
|
|
9
|
+
storageKey?: string;
|
|
10
|
+
/** Used when nothing is stored. Default `"system"` with `enableSystem`, else `"light"`. */
|
|
11
|
+
defaultTheme?: string;
|
|
12
|
+
/** Resolve `"system"` from `prefers-color-scheme`. Default `true`. */
|
|
13
|
+
enableSystem?: boolean;
|
|
14
|
+
/** Set `style.colorScheme` on `<html>` for light and dark. Default `true`. */
|
|
15
|
+
enableColorScheme?: boolean;
|
|
16
|
+
/** Where the theme goes on `<html>`. Default `"data-theme"`. */
|
|
17
|
+
attribute?: ThemeAttribute | ThemeAttribute[];
|
|
18
|
+
/** Theme names. Default `["light", "dark"]`. */
|
|
19
|
+
themes?: string[];
|
|
20
|
+
/** Theme name to the class or attribute value written. */
|
|
21
|
+
value?: Record<string, string>;
|
|
22
|
+
/** Shown on `<html>` instead of the stored theme, which is left unchanged. */
|
|
23
|
+
forcedTheme?: string;
|
|
24
|
+
/** Turn CSS transitions off for one frame while the theme changes. Default `false`. */
|
|
25
|
+
disableTransitionOnChange?: boolean;
|
|
26
|
+
/** Nonce for the style element `disableTransitionOnChange` adds. */
|
|
27
|
+
nonce?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ThemeState = {
|
|
31
|
+
/** Stored theme, `"system"` included. `undefined` only without `window`. */
|
|
32
|
+
theme: string | undefined;
|
|
33
|
+
/** Stores the theme. A function gets the theme of the render it came from. */
|
|
34
|
+
setTheme: (theme: string | ((theme: string | undefined) => string)) => void;
|
|
35
|
+
forcedTheme: string | undefined;
|
|
36
|
+
/** `theme`, with `"system"` resolved to `systemTheme`. */
|
|
37
|
+
resolvedTheme: string | undefined;
|
|
38
|
+
/** `themes` plus `"system"` with `enableSystem`. */
|
|
39
|
+
themes: string[];
|
|
40
|
+
/** `prefers-color-scheme` with `enableSystem`. Until the first effect runs it is `theme` unless that is `"system"` (as in next-themes). */
|
|
41
|
+
systemTheme: string | undefined;
|
|
42
|
+
/** `false` until the provider's first effect has run. */
|
|
43
|
+
mounted: boolean;
|
|
44
|
+
/** `mounted && resolvedTheme === "dark"`. */
|
|
45
|
+
isDark: boolean;
|
|
46
|
+
/** `setTheme(resolvedTheme === "dark" ? "light" : "dark")`. */
|
|
47
|
+
toggleTheme: () => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type ThemeProviderOptions = {
|
|
51
|
+
/** `useTheme()` outside the provider throws this message. When unset it returns an empty state with no-op setters. */
|
|
52
|
+
missingProviderMessage?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ThemeProviderKit = {
|
|
56
|
+
ThemeProvider: (props: ThemeProviderProps) => ReactNode;
|
|
57
|
+
useTheme: () => ThemeState;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const COLOR_SCHEMES = ["light", "dark"];
|
|
61
|
+
const MEDIA = "(prefers-color-scheme: dark)";
|
|
62
|
+
const DISABLE_TRANSITIONS_CSS =
|
|
63
|
+
"*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}";
|
|
64
|
+
|
|
65
|
+
const noop = () => {};
|
|
66
|
+
|
|
67
|
+
const emptyState: ThemeState = {
|
|
68
|
+
theme: undefined,
|
|
69
|
+
setTheme: noop,
|
|
70
|
+
forcedTheme: undefined,
|
|
71
|
+
resolvedTheme: undefined,
|
|
72
|
+
themes: [],
|
|
73
|
+
systemTheme: undefined,
|
|
74
|
+
mounted: false,
|
|
75
|
+
isDark: false,
|
|
76
|
+
toggleTheme: noop,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** `prefers-color-scheme`, or `"light"` without `window`. */
|
|
80
|
+
export function getSystemTheme(): "light" | "dark" {
|
|
81
|
+
if (typeof window === "undefined") return "light";
|
|
82
|
+
return window.matchMedia(MEDIA).matches ? "dark" : "light";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Replaces the `light` / `dark` class on `<html>` with `theme`. No-op without `document`. */
|
|
86
|
+
export function applyThemeClass(theme: string): void {
|
|
87
|
+
if (typeof document === "undefined") return;
|
|
88
|
+
document.documentElement.classList.remove("light", "dark");
|
|
89
|
+
document.documentElement.classList.add(theme);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function systemThemeOf(media: { matches: boolean }): "light" | "dark" {
|
|
93
|
+
return media.matches ? "dark" : "light";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readTheme(storageKey: string, fallback: string): string | undefined {
|
|
97
|
+
if (typeof window === "undefined") return undefined;
|
|
98
|
+
let stored: string | undefined;
|
|
99
|
+
try {
|
|
100
|
+
stored = localStorage.getItem(storageKey) || undefined;
|
|
101
|
+
} catch {
|
|
102
|
+
// Storage can be unavailable (privacy modes); fall back.
|
|
103
|
+
}
|
|
104
|
+
return stored || fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function disableTransitions(nonce: string | undefined): () => void {
|
|
108
|
+
const style = document.createElement("style");
|
|
109
|
+
if (nonce) style.setAttribute("nonce", nonce);
|
|
110
|
+
style.appendChild(document.createTextNode(DISABLE_TRANSITIONS_CSS));
|
|
111
|
+
document.head.appendChild(style);
|
|
112
|
+
return () => {
|
|
113
|
+
// Force a style recalculation before the transitions come back.
|
|
114
|
+
window.getComputedStyle(document.body);
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
document.head.removeChild(style);
|
|
117
|
+
}, 1);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type DomOptions = Required<
|
|
122
|
+
Pick<ThemeProviderProps, "attribute" | "themes" | "enableSystem" | "enableColorScheme" | "defaultTheme" | "disableTransitionOnChange">
|
|
123
|
+
> &
|
|
124
|
+
Pick<ThemeProviderProps, "value" | "nonce">;
|
|
125
|
+
|
|
126
|
+
function applyTheme(theme: string | undefined, options: DomOptions): void {
|
|
127
|
+
if (!theme) return;
|
|
128
|
+
const resolved = theme === "system" && options.enableSystem ? systemThemeOf(window.matchMedia(MEDIA)) : theme;
|
|
129
|
+
const written = options.value ? options.value[resolved] : resolved;
|
|
130
|
+
const restoreTransitions = options.disableTransitionOnChange ? disableTransitions(options.nonce) : null;
|
|
131
|
+
const root = document.documentElement;
|
|
132
|
+
const removable = options.value ? Object.values(options.value) : options.themes;
|
|
133
|
+
for (const attribute of Array.isArray(options.attribute) ? options.attribute : [options.attribute]) {
|
|
134
|
+
if (attribute === "class") {
|
|
135
|
+
root.classList.remove(...removable);
|
|
136
|
+
if (written) root.classList.add(written);
|
|
137
|
+
} else if (attribute.startsWith("data-")) {
|
|
138
|
+
if (written) root.setAttribute(attribute, written);
|
|
139
|
+
else root.removeAttribute(attribute);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (options.enableColorScheme) {
|
|
143
|
+
const fallback = COLOR_SCHEMES.includes(options.defaultTheme) ? options.defaultTheme : null;
|
|
144
|
+
// `null` clears the property in browsers; assigned as-is, as next-themes does.
|
|
145
|
+
Reflect.set(root.style, "colorScheme", COLOR_SCHEMES.includes(resolved) ? resolved : fallback);
|
|
146
|
+
}
|
|
147
|
+
restoreTransitions?.();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A theme provider with next-themes 0.4.6 semantics, plus `mounted`, `isDark`
|
|
152
|
+
* and `toggleTheme`. Each call makes its own context, so an app keeps one kit.
|
|
153
|
+
*
|
|
154
|
+
* Parity notes: the options that shape DOM writes (`attribute`, `themes`,
|
|
155
|
+
* `value`, `enableSystem`, `enableColorScheme`, `defaultTheme`,
|
|
156
|
+
* `disableTransitionOnChange`, `nonce`) are read once, on the first render, as
|
|
157
|
+
* in next-themes; `forcedTheme`, and `enableSystem` for the system listener and
|
|
158
|
+
* the returned state, follow props. A provider nested in another one from the
|
|
159
|
+
* same kit renders only its children. No inline script is rendered: in a
|
|
160
|
+
* client-rendered app React never runs it.
|
|
161
|
+
*/
|
|
162
|
+
export function createThemeProvider(options: ThemeProviderOptions = {}): ThemeProviderKit {
|
|
163
|
+
const { missingProviderMessage } = options;
|
|
164
|
+
const Context = createContext<ThemeState | undefined>(undefined);
|
|
165
|
+
|
|
166
|
+
/** next-themes hands out a new `setTheme` exactly when `theme` changes; apps list it in effect deps. */
|
|
167
|
+
function useSetTheme(
|
|
168
|
+
theme: string | undefined,
|
|
169
|
+
storageKey: string,
|
|
170
|
+
setThemeState: (theme: string) => void,
|
|
171
|
+
): ThemeState["setTheme"] {
|
|
172
|
+
const make = (): ThemeState["setTheme"] => (next) => {
|
|
173
|
+
const resolved = typeof next === "function" ? next(theme) : next;
|
|
174
|
+
setThemeState(resolved);
|
|
175
|
+
try {
|
|
176
|
+
localStorage.setItem(storageKey, resolved);
|
|
177
|
+
} catch {
|
|
178
|
+
// Storage can be unavailable (privacy modes).
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const [entry, setEntry] = useState(() => ({ theme, setTheme: make() }));
|
|
182
|
+
if (entry.theme === theme) return entry.setTheme;
|
|
183
|
+
const next = { theme, setTheme: make() };
|
|
184
|
+
setEntry(next);
|
|
185
|
+
return next.setTheme;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function Provider({
|
|
189
|
+
forcedTheme,
|
|
190
|
+
disableTransitionOnChange = false,
|
|
191
|
+
enableSystem = true,
|
|
192
|
+
enableColorScheme = true,
|
|
193
|
+
storageKey = "theme",
|
|
194
|
+
themes = COLOR_SCHEMES,
|
|
195
|
+
defaultTheme = enableSystem ? "system" : "light",
|
|
196
|
+
attribute = "data-theme",
|
|
197
|
+
value,
|
|
198
|
+
nonce,
|
|
199
|
+
children,
|
|
200
|
+
}: ThemeProviderProps) {
|
|
201
|
+
const [theme, setThemeState] = useState(() => readTheme(storageKey, defaultTheme));
|
|
202
|
+
const [systemTheme, setSystemTheme] = useState<string | undefined>(() =>
|
|
203
|
+
theme === "system" ? systemThemeOf(window.matchMedia(MEDIA)) : theme,
|
|
204
|
+
);
|
|
205
|
+
const [mounted, setMounted] = useState(false);
|
|
206
|
+
const [domOptions] = useState<DomOptions>(() => ({
|
|
207
|
+
attribute,
|
|
208
|
+
themes,
|
|
209
|
+
value,
|
|
210
|
+
enableSystem,
|
|
211
|
+
enableColorScheme,
|
|
212
|
+
defaultTheme,
|
|
213
|
+
disableTransitionOnChange,
|
|
214
|
+
nonce,
|
|
215
|
+
}));
|
|
216
|
+
const setTheme = useSetTheme(theme, storageKey, setThemeState);
|
|
217
|
+
|
|
218
|
+
useEffect(() => setMounted(true), []);
|
|
219
|
+
|
|
220
|
+
// Listener and handler are rebuilt whenever `theme` or `forcedTheme` changes.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const handleMedia = (media: { matches: boolean }) => {
|
|
223
|
+
setSystemTheme(systemThemeOf(media));
|
|
224
|
+
if (theme === "system" && enableSystem && !forcedTheme) applyTheme("system", domOptions);
|
|
225
|
+
};
|
|
226
|
+
const media = window.matchMedia(MEDIA);
|
|
227
|
+
media.addListener(handleMedia);
|
|
228
|
+
handleMedia(media);
|
|
229
|
+
return () => media.removeListener(handleMedia);
|
|
230
|
+
}, [theme, forcedTheme]);
|
|
231
|
+
|
|
232
|
+
// Other tabs: follow a new value, or store the default when the key is removed.
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const handleStorage = (event: StorageEvent) => {
|
|
235
|
+
if (event.key !== storageKey) return;
|
|
236
|
+
if (event.newValue) setThemeState(event.newValue);
|
|
237
|
+
else setTheme(defaultTheme);
|
|
238
|
+
};
|
|
239
|
+
window.addEventListener("storage", handleStorage);
|
|
240
|
+
return () => window.removeEventListener("storage", handleStorage);
|
|
241
|
+
}, [setTheme]);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
applyTheme(forcedTheme ?? theme, domOptions);
|
|
245
|
+
}, [forcedTheme, theme]);
|
|
246
|
+
|
|
247
|
+
const resolvedTheme = theme === "system" ? systemTheme : theme;
|
|
248
|
+
const state: ThemeState = {
|
|
249
|
+
theme,
|
|
250
|
+
setTheme,
|
|
251
|
+
forcedTheme,
|
|
252
|
+
resolvedTheme,
|
|
253
|
+
themes: enableSystem ? [...themes, "system"] : themes,
|
|
254
|
+
systemTheme: enableSystem ? systemTheme : undefined,
|
|
255
|
+
mounted,
|
|
256
|
+
isDark: mounted && resolvedTheme === "dark",
|
|
257
|
+
toggleTheme: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"),
|
|
258
|
+
};
|
|
259
|
+
return <Context value={state}>{children}</Context>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function ThemeProvider(props: ThemeProviderProps) {
|
|
263
|
+
if (use(Context)) return <>{props.children}</>;
|
|
264
|
+
return <Provider {...props} />;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function useTheme(): ThemeState {
|
|
268
|
+
const state = use(Context);
|
|
269
|
+
if (state) return state;
|
|
270
|
+
if (missingProviderMessage !== undefined) throw new Error(missingProviderMessage);
|
|
271
|
+
return emptyState;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ThemeProvider, useTheme };
|
|
275
|
+
}
|