@vvedantb/ui 0.7.1 → 0.8.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 +98 -5
- 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 +12 -11
- package/shell/src/Sidebar.tsx +17 -1
- package/shell/src/SidebarPanel.tsx +65 -4
- package/shell/src/app-shell-classes.ts +68 -7
- package/shell/src/index.ts +6 -0
- package/src/index.ts +6 -0
- package/tokens/src/css/compat-shadcn-rgb.css +4 -0
- package/tokens/src/tokens.css +5 -0
- package/app/README.md +0 -100
- package/tokens/README.md +0 -98
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ 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`), `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. |
|
|
@@ -299,7 +299,7 @@ import {
|
|
|
299
299
|
- `SidebarPanel`: legacy (still supported) `headerTrailing`, `onClose`, `closeLabel` and `closeButtonClassName`; compose `Sidebar.Panel` parts instead. `title`, `headerTrailing`, `onClose` (adds the close button; pass it on the mobile drawer only), `closeLabel`, `closeButtonClassName`, `collapsed` (`md:hidden`), `className`, `children`.
|
|
300
300
|
- `SidebarAccountMenu`: legacy (still supported) `badge`, `triggerProps`, `contentProps` and `stackedLabel`; compose `Sidebar.Account*` parts instead. `name`, `email` (two-line label when set), `stackedLabel` (two-line layout even without `email`, default `false`), `imageUrl` (initials without it), `align` (`"center"`), `side` (`"right"`), `sideOffset` (8), `collisionPadding`, `contentClassName`, `children` (menu items). 0.5.2 adds `badge` (unread dot beside the trigger, in a `relative` wrapper around the menu, so the trigger's dimming and press scale skip it: `true` shows it, `false` fades it out, omitted renders no wrapper and no dot), `triggerProps` (spread onto the trigger `Button` after the defaults; `className` merges last) and `contentProps` (spread onto `DropdownMenuContent`, for example `fitViewport` or `aria-label`; no `className`, use `contentClassName`). `SidebarAccountMenuPlaceholder` is the tile-sized loading box.
|
|
301
301
|
- `SignOutDialog`: legacy (still supported) `question` and `title`; compose `ConfirmDialog` parts with `alert={false}` for other copy. `open`, `onOpenChange` (not called with `false` while `pending`), `pending`, `onConfirm`, `description`, `contentClassName`, `title` (`"Sign out"`), `question` (`"Sign out of your account?"`).
|
|
302
|
-
- Class helpers: `railTileClass`, `railTileStateClass(active)`, `railBadgeClass`, `sidebarNavRowClass(isIconOnly)`, `sidebarNavLinkTextClass(isActive)`, `sidebarNavItemClass({ active, iconOnly })`, `sidebarNavIconClass(active)`, `sidebarSectionLabelClass`. See [Menu item corners and sidebar nav rows (0.7.1)](#menu-item-corners-and-sidebar-nav-rows-071).
|
|
302
|
+
- Class helpers: `railTileClass`, `railTileStateClass(active)`, `railBadgeClass`, `sidebarNavRowClass(isIconOnly)`, `sidebarNavLinkTextClass(isActive)`, `sidebarNavItemClass({ active, iconOnly, selected, dragging, dropTarget })`, `sidebarNavIconClass(active)`, `sidebarSectionLabelClass`, and the row parts `sidebarNavItemLeadingClass`, `sidebarNavItemLabelClass`, `sidebarNavItemSubtitleClass`, `sidebarNavItemTrailingClass`, `sidebarNavItemActionsClass` and `sidebarNavItemIndentStyle(indent)`. See [Menu item corners and sidebar nav rows (0.7.1)](#menu-item-corners-and-sidebar-nav-rows-071) and [Sidebar nav row parts (0.7.2)](#sidebar-nav-row-parts-072).
|
|
303
303
|
|
|
304
304
|
### Header actions and account badge (0.5.2)
|
|
305
305
|
|
|
@@ -434,7 +434,8 @@ Convention: compound parts are static members of a namespace object, `<Namespace
|
|
|
434
434
|
| `PanelHeader` | Padded header row for `PanelTitle` and `PanelActions`. |
|
|
435
435
|
| `PanelTitle` / `PanelActions` | The `<h2>` and the trailing actions. |
|
|
436
436
|
| `PanelClose` | Ghost close button. `label` (`"Close navigation"`). Without `onClick` it closes the drawer. |
|
|
437
|
-
| `NavItem` | Panel nav row (`sidebarNavItemClass`). `active` (fill, `font-medium`, `aria-current="page"`), `iconOnly`, `icon` (element; gets `sidebarNavIconClass`), `asChild` for a router link. A `<button type="button">` without `asChild`. |
|
|
437
|
+
| `NavItem` | Panel nav row (`sidebarNavItemClass`). `active` (fill, `font-medium`, `aria-current="page"`), `iconOnly`, `icon` (element; gets `sidebarNavIconClass`), `asChild` for a router link. A `<button type="button">` without `asChild`. 0.7.2: `selected`, `indent`, `dragging`, `dropTarget`; see [Sidebar nav row parts (0.7.2)](#sidebar-nav-row-parts-072). |
|
|
438
|
+
| `NavItemLeading` / `NavItemLabel` / `NavItemTrailing` / `NavItemActions` | Row parts (0.7.2): leading slot, truncated label, always-visible trailing content and hover-revealed actions. `<span>`, or the child with `asChild`. |
|
|
438
439
|
| `AccountMenu` | Dropdown root. `badge` adds the unread dot (`AccountBadge`) in a `relative` wrapper. |
|
|
439
440
|
| `AccountTrigger` | Rail-tile-sized avatar button. `userName` (default `title` and `aria-label`, and the initials), `imageUrl`, plus `Button` props; native `name` goes to the button. |
|
|
440
441
|
| `AccountAvatar` | Avatar image or initials. `userName`, `imageUrl`. |
|
|
@@ -693,12 +694,104 @@ Colour mapping. The package has no sidebar colour tokens, so the rows use the ne
|
|
|
693
694
|
|
|
694
695
|
| Role | Reference look (light / dark, sRGB) | Package token (light / dark, sRGB) |
|
|
695
696
|
| --- | --- | --- |
|
|
696
|
-
| Hover and active fill | 233 234 236 / 28 28 28 | `surface-tertiary`: 234 234 234 / about 39 39 39. Light matches; dark is a step lighter. It is the rail tile's active fill too, so a rail tile and a nav row stay the same. |
|
|
697
|
+
| Hover and active fill | 233 234 236 / 28 28 28 | `surface-tertiary`: 234 234 234 / about 39 39 39. Light matches; dark is a step lighter. It is the rail tile's active fill too, so a rail tile and a nav row stay the same. From 0.7.2 the rows use `sidebar-accent` (234 234 234 / 28 28 28); see below. |
|
|
697
698
|
| Active text and icon | 9 9 11 / 250 250 250 | `foreground`: 24 24 24 / 252 252 252 |
|
|
698
699
|
| Idle text (80%) | 24 24 25 / 251 252 253 | `foreground` at 80% |
|
|
699
700
|
| Idle icon | 100 101 103 / 159 160 162 | `muted-foreground` (`--muted`): 114 114 114 / 160 160 160 |
|
|
700
701
|
| Focus ring (35%) | 9 9 11 / 250 250 250 | `foreground` at 35% |
|
|
701
702
|
|
|
703
|
+
## Sidebar nav row parts (0.7.2)
|
|
704
|
+
|
|
705
|
+
One row style for every sidebar list: plain nav links and rich rows (checkbox, avatar, two-line label, nested folders, `...` menus, drag and drop, rename inputs). `Sidebar.NavItem` is the row; the parts go inside it.
|
|
706
|
+
|
|
707
|
+
Rich row (the link is the row; the parts are its children):
|
|
708
|
+
|
|
709
|
+
```tsx
|
|
710
|
+
<Sidebar.NavItem asChild active={isActive} selected={selected} indent={1} dragging={isDragged} dropTarget={isOver}>
|
|
711
|
+
<Link to="/chat/$id" params={{ id }} draggable onDragStart={onDragStart}>
|
|
712
|
+
<Sidebar.NavItemLeading>
|
|
713
|
+
<Checkbox checked={selected} onClick={(event) => event.stopPropagation()} aria-label={`Select ${title}`} />
|
|
714
|
+
</Sidebar.NavItemLeading>
|
|
715
|
+
<Sidebar.NavItemLabel>{title}</Sidebar.NavItemLabel>
|
|
716
|
+
<Sidebar.NavItemTrailing>{generating ? <Spinner size={14} decorative /> : count}</Sidebar.NavItemTrailing>
|
|
717
|
+
<Sidebar.NavItemActions>
|
|
718
|
+
<DropdownMenu>
|
|
719
|
+
<DropdownMenuTrigger asChild>
|
|
720
|
+
<Button
|
|
721
|
+
variant="ghost"
|
|
722
|
+
size="icon-xs"
|
|
723
|
+
aria-label="Row actions"
|
|
724
|
+
onClick={(event) => {
|
|
725
|
+
event.stopPropagation();
|
|
726
|
+
event.preventDefault();
|
|
727
|
+
}}
|
|
728
|
+
>
|
|
729
|
+
<IconDots size={16} />
|
|
730
|
+
</Button>
|
|
731
|
+
</DropdownMenuTrigger>
|
|
732
|
+
<DropdownMenuContent>...</DropdownMenuContent>
|
|
733
|
+
</DropdownMenu>
|
|
734
|
+
</Sidebar.NavItemActions>
|
|
735
|
+
</Link>
|
|
736
|
+
</Sidebar.NavItem>
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
Simple row (unchanged from 0.7.1):
|
|
740
|
+
|
|
741
|
+
```tsx
|
|
742
|
+
<Sidebar.NavItem asChild active={active} icon={<IconHome />}>
|
|
743
|
+
<Link to="/home"><span className="truncate">Home</span></Link>
|
|
744
|
+
</Sidebar.NavItem>
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
`Sidebar.NavItem` props added in 0.7.2:
|
|
748
|
+
|
|
749
|
+
| Prop | Effect |
|
|
750
|
+
| --- | --- |
|
|
751
|
+
| `selected` | The active fill (`bg-sidebar-accent text-foreground`) without `font-medium` or `aria-current`. `data-selected="true"`. |
|
|
752
|
+
| `indent` | Nesting level from 0. Left padding is `calc(1rem + indent * 12px)`: 16px, 28px, 40px, ... Set as inline `paddingLeft`, so any depth works and a `style.paddingLeft` you pass wins. Ignored with `iconOnly`. |
|
|
753
|
+
| `dragging` | `opacity-40 cursor-grabbing`. `data-dragging="true"`. |
|
|
754
|
+
| `dropTarget` | Fill plus `ring-2 ring-inset ring-focus`. `data-drop-target="true"`. |
|
|
755
|
+
|
|
756
|
+
`active` also sets `data-active="true"`. Each attribute is left off when its prop is false. The styles key off the attributes (`data-[selected=true]:`, `data-[dragging=true]:`, `data-[drop-target=true]:`), so a row that sets them itself (for example from a tree library's drag state) gets the same look. `sidebarNavItemClass` takes the same `selected`, `dragging` and `dropTarget` options for rows that use the class only.
|
|
757
|
+
|
|
758
|
+
Parts. Each renders a `<span>` with its class (`className` merges last), or merges onto its single child with `asChild`:
|
|
759
|
+
|
|
760
|
+
| Part | Class | Use |
|
|
761
|
+
| --- | --- | --- |
|
|
762
|
+
| `NavItemLeading` | `sidebarNavItemLeadingClass`: `flex shrink-0 items-center` | Checkbox, icon, avatar, status dot, folder chevron. Put `sidebarNavIconClass(active)` on an icon. A 40px avatar makes the row taller; add `py-2` to the row if you want more room. |
|
|
763
|
+
| `NavItemLabel` | `sidebarNavItemLabelClass`: `min-w-0 flex-1 truncate` | The title. For two lines, put `<span className="block truncate">` and `<span className={sidebarNavItemSubtitleClass}>` (`block truncate text-xs text-muted-foreground`) inside. For rename, `asChild` on the input. |
|
|
764
|
+
| `NavItemTrailing` | `sidebarNavItemTrailingClass`: `ml-auto flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground` | Always visible: counts, unread dots, spinners, badges. |
|
|
765
|
+
| `NavItemActions` | `sidebarNavItemActionsClass` (below) | Hover-revealed `...` button. |
|
|
766
|
+
|
|
767
|
+
Actions sit in the row flow at the end (`ml-auto`), so an `asChild` link row needs no `relative`. The part is `h-6` with `-my-1`, so a 24px or 28px icon button does not change the 30px row height. It reserves its width while hidden, so the label truncates a little earlier. For the older overlay look, pass `className="absolute right-3"` to the part and `relative` to the row. Visibility:
|
|
768
|
+
|
|
769
|
+
- `opacity-0` by default, `transition-opacity duration-75`.
|
|
770
|
+
- `group-hover:opacity-100` and `group-focus-within:opacity-100`: the row is a `group`.
|
|
771
|
+
- `has-[[data-state=open]]:opacity-100`: a Radix menu trigger inside it is open.
|
|
772
|
+
- `data-[state=open]:opacity-100`: the part itself is the trigger (`asChild`).
|
|
773
|
+
|
|
774
|
+
The part adds no handlers. Call `stopPropagation()` (and `preventDefault()` inside a link) on the button so it does not run the row click.
|
|
775
|
+
|
|
776
|
+
Other rich-row needs:
|
|
777
|
+
|
|
778
|
+
- Folder rows and drop zones: render the row as a `<div>` or `<button>` (`asChild` on a `<div>` when the row holds its own buttons) and wrap it in your drop zone. Set `dropTarget` while a drag is over it.
|
|
779
|
+
- Indent guide lines: absolutely positioned spans in the row; add `relative` to the row.
|
|
780
|
+
- Context menus and hover cards: wrap `Sidebar.NavItem` as before; `ContextMenuTrigger asChild` can wrap it directly.
|
|
781
|
+
|
|
782
|
+
Classes a rich row no longer needs beside the parts: `rounded-lg`, `rounded-md`, `text-sm` (and `text-sm` on the title span), `px-3 py-1.5`, `gap-3`, `py-2`/`gap-3` for padding alone, `pl-7`/`pl-8` (use `indent`), `bg-surface-tertiary` for active, hover or selected, `ring-2 ring-inset ring-focus` and `opacity-40 cursor-grabbing` for drag states, `opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity` on the menu button, `mr-2` on a checkbox (the row `gap-2` spaces it), `truncate flex-1 min-w-0` on the title, `text-xs text-muted` on counts, and app row helpers that set any of these (a local list-item class, an outer `group py-px` wrapper, a `ui-sidebar-row` class).
|
|
783
|
+
|
|
784
|
+
### Fill colour: `sidebar-accent`
|
|
785
|
+
|
|
786
|
+
Rows (hover, active, selected, drop target) use `bg-sidebar-accent` instead of `bg-surface-tertiary`.
|
|
787
|
+
|
|
788
|
+
| | Light | Dark |
|
|
789
|
+
| --- | --- | --- |
|
|
790
|
+
| `tokens.css` `--sidebar-accent` | `var(--surface-tertiary)` (about 234 234 234; unchanged) | `oklch(22.64% 0 0)` (28 28 28; was about 39 39 39) |
|
|
791
|
+
| `compat-shadcn-rgb.css` `--color-sidebar-accent` | `rgb(var(--sidebar-accent, var(--secondary)))`: the app's `--sidebar-accent` channels if defined, else `--secondary` as before | same |
|
|
792
|
+
|
|
793
|
+
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
|
+
|
|
702
795
|
## Toaster
|
|
703
796
|
|
|
704
797
|
```tsx
|
|
@@ -747,7 +840,7 @@ Components use `text-muted-foreground`, `primary` and `--color-*` theme variable
|
|
|
747
840
|
Use this when the app defines shadcn-style variables as space-separated channels (`--background: 244 245 246`) and reads them as `rgb(var(--background) / 0.5)`.
|
|
748
841
|
|
|
749
842
|
1. Import `compat-shadcn-rgb.css` **instead of** `tokens.css`. Never import both.
|
|
750
|
-
2. The app keeps `--background`, `--foreground`, `--card`, `--popover`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring` (each with its `-foreground` where shadcn has one) and `--radius`. The compat file does not define them. It only references them, so the app can change `--primary`, `--ring`, `--radius` or `--font-sans` at runtime.
|
|
843
|
+
2. The app keeps `--background`, `--foreground`, `--card`, `--popover`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring` (each with its `-foreground` where shadcn has one) and `--radius`. The compat file does not define them. It only references them, so the app can change `--primary`, `--ring`, `--radius` or `--font-sans` at runtime. `--sidebar-accent` is optional: sidebar nav rows use it when set, else `--secondary` (0.7.2).
|
|
751
844
|
3. Add the `@source` line for the package sources.
|
|
752
845
|
|
|
753
846
|
```css
|
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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type CompactRelativeTimeOptions = {
|
|
2
|
+
/** Clock in ms. Defaults to `Date.now()`. */
|
|
3
|
+
now?: number;
|
|
4
|
+
/**
|
|
5
|
+
* Returned for anything under a minute old, including future times. When
|
|
6
|
+
* unset the result is whole seconds (`"42s"`), and a future time gives zero
|
|
7
|
+
* or a negative count (`"0s"`, `"-300s"`).
|
|
8
|
+
*/
|
|
9
|
+
justNow?: string;
|
|
10
|
+
/**
|
|
11
|
+
* `false` (default): elapsed time only. A day is 24 hours, a month 30 days,
|
|
12
|
+
* a year 12 of those months.
|
|
13
|
+
* `true`: days count local calendar days across a DST change, and months and
|
|
14
|
+
* years are calendar months, as `dayjs().diff(date, unit)` counts them.
|
|
15
|
+
*/
|
|
16
|
+
calendar?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const MS_PER_MINUTE = 60_000;
|
|
20
|
+
const MS_PER_DAY = 86_400_000;
|
|
21
|
+
|
|
22
|
+
/** Rounds toward zero; `-0` becomes `0`. */
|
|
23
|
+
function truncate(n: number): number {
|
|
24
|
+
return n < 0 ? Math.ceil(n) || 0 : Math.floor(n);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Local UTC offset in minutes, rounded to 15 minutes. */
|
|
28
|
+
function utcOffset(ms: number): number {
|
|
29
|
+
return -Math.round(new Date(ms).getTimezoneOffset() / 15) * 15;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Adds calendar months in local time and clamps the day to the target month. */
|
|
33
|
+
function addMonths(ms: number, months: number): number {
|
|
34
|
+
const date = new Date(ms);
|
|
35
|
+
const day = date.getDate();
|
|
36
|
+
date.setDate(1);
|
|
37
|
+
date.setMonth(date.getMonth() + months);
|
|
38
|
+
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
|
39
|
+
date.setDate(Math.min(day, daysInMonth));
|
|
40
|
+
return date.getTime();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fractional calendar months from `a` to `b`, negated (the moment.js formula). */
|
|
44
|
+
function monthDiff(a: number, b: number): number {
|
|
45
|
+
if (new Date(a).getDate() < new Date(b).getDate()) return -monthDiff(b, a);
|
|
46
|
+
const from = new Date(a);
|
|
47
|
+
const to = new Date(b);
|
|
48
|
+
const whole = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
|
|
49
|
+
const anchor = addMonths(a, whole);
|
|
50
|
+
const before = b - anchor < 0;
|
|
51
|
+
const anchor2 = addMonths(a, whole + (before ? -1 : 1));
|
|
52
|
+
return +(-(whole + (b - anchor) / (before ? anchor - anchor2 : anchor2 - anchor)) || 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Time since `date` as one compact unit: `"now"` or `"42s"`, then `"5m"`,
|
|
57
|
+
* `"3h"`, `"2d"`, `"4mo"`, `"1y"`. Strings are parsed with `new Date(string)`.
|
|
58
|
+
* An invalid date gives `"NaNy"`, or `"0mo"` with `calendar`.
|
|
59
|
+
*/
|
|
60
|
+
export function formatCompactRelativeTime(
|
|
61
|
+
date: string | number | Date,
|
|
62
|
+
options: CompactRelativeTimeOptions = {},
|
|
63
|
+
): string {
|
|
64
|
+
const now = options.now ?? Date.now();
|
|
65
|
+
const then = new Date(date).getTime();
|
|
66
|
+
const diff = now - then;
|
|
67
|
+
|
|
68
|
+
const seconds = truncate(diff / 1000);
|
|
69
|
+
if (seconds < 60) return options.justNow ?? `${seconds}s`;
|
|
70
|
+
const minutes = truncate(diff / MS_PER_MINUTE);
|
|
71
|
+
if (minutes < 60) return `${minutes}m`;
|
|
72
|
+
const hours = truncate(diff / (60 * MS_PER_MINUTE));
|
|
73
|
+
if (hours < 24) return `${hours}h`;
|
|
74
|
+
|
|
75
|
+
if (!options.calendar) {
|
|
76
|
+
const days = Math.floor(hours / 24);
|
|
77
|
+
if (days < 30) return `${days}d`;
|
|
78
|
+
const months = Math.floor(days / 30);
|
|
79
|
+
if (months < 12) return `${months}mo`;
|
|
80
|
+
return `${Math.floor(months / 12)}y`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const zoneDelta = (utcOffset(then) - utcOffset(now)) * MS_PER_MINUTE;
|
|
84
|
+
const days = truncate((diff - zoneDelta) / MS_PER_DAY);
|
|
85
|
+
if (days < 30) return `${days}d`;
|
|
86
|
+
const months = monthDiff(now, then);
|
|
87
|
+
if (truncate(months) < 12) return `${truncate(months)}mo`;
|
|
88
|
+
return `${truncate(months / 12)}y`;
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -52,15 +52,6 @@
|
|
|
52
52
|
"sideEffects": [
|
|
53
53
|
"./src/ui/sonner.css"
|
|
54
54
|
],
|
|
55
|
-
"scripts": {
|
|
56
|
-
"check:tokens": "node tokens/scripts/check-component-tokens.mjs",
|
|
57
|
-
"build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
|
|
58
|
-
"typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
|
|
59
|
-
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs",
|
|
60
|
-
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
61
|
-
"analyze": "node tokens/verify/analyze.mjs",
|
|
62
|
-
"prepack": "node tokens/scripts/check-exports.mjs"
|
|
63
|
-
},
|
|
64
55
|
"peerDependencies": {
|
|
65
56
|
"@clerk/clerk-react": ">=5",
|
|
66
57
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
@@ -140,10 +131,12 @@
|
|
|
140
131
|
"class-variance-authority": "^0.7.1",
|
|
141
132
|
"clsx": "^2.1.1",
|
|
142
133
|
"cmdk": "^1.1.1",
|
|
134
|
+
"dayjs": "1.11.19",
|
|
143
135
|
"embla-carousel-react": "^8.6.0",
|
|
144
136
|
"happy-dom": "^18.0.0",
|
|
145
137
|
"motion": "^12.0.0",
|
|
146
138
|
"nanoid": "^5.1.0",
|
|
139
|
+
"next-themes": "0.4.6",
|
|
147
140
|
"radix-ui": "^1.4.0",
|
|
148
141
|
"react": "^19.1.0",
|
|
149
142
|
"react-dom": "^19.1.0",
|
|
@@ -171,5 +164,13 @@
|
|
|
171
164
|
"zod": {
|
|
172
165
|
"optional": true
|
|
173
166
|
}
|
|
167
|
+
},
|
|
168
|
+
"scripts": {
|
|
169
|
+
"check:tokens": "node tokens/scripts/check-component-tokens.mjs",
|
|
170
|
+
"build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
|
|
171
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tokens/tsconfig.json",
|
|
172
|
+
"test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
|
|
173
|
+
"verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
|
|
174
|
+
"analyze": "node tokens/verify/analyze.mjs"
|
|
174
175
|
}
|
|
175
|
-
}
|
|
176
|
+
}
|
package/shell/src/Sidebar.tsx
CHANGED
|
@@ -9,7 +9,18 @@ import {
|
|
|
9
9
|
AccountSeparator,
|
|
10
10
|
AccountTrigger,
|
|
11
11
|
} from "./SidebarAccountMenu";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
NavItem,
|
|
14
|
+
NavItemActions,
|
|
15
|
+
NavItemLabel,
|
|
16
|
+
NavItemLeading,
|
|
17
|
+
NavItemTrailing,
|
|
18
|
+
Panel,
|
|
19
|
+
PanelActions,
|
|
20
|
+
PanelClose,
|
|
21
|
+
PanelHeader,
|
|
22
|
+
PanelTitle,
|
|
23
|
+
} from "./SidebarPanel";
|
|
13
24
|
import { Rail, RailBody, RailFooter, RailHeader, RailItem, RailSeparator } from "./SidebarRail";
|
|
14
25
|
|
|
15
26
|
export type {
|
|
@@ -36,6 +47,7 @@ export type {
|
|
|
36
47
|
SidebarPanelHeaderProps,
|
|
37
48
|
SidebarPanelRootProps,
|
|
38
49
|
SidebarPanelTitleProps,
|
|
50
|
+
SidebarNavItemPartProps,
|
|
39
51
|
SidebarNavItemProps,
|
|
40
52
|
} from "./SidebarPanel";
|
|
41
53
|
export type {
|
|
@@ -70,6 +82,10 @@ export const Sidebar = {
|
|
|
70
82
|
PanelActions,
|
|
71
83
|
PanelClose,
|
|
72
84
|
NavItem,
|
|
85
|
+
NavItemLeading,
|
|
86
|
+
NavItemLabel,
|
|
87
|
+
NavItemTrailing,
|
|
88
|
+
NavItemActions,
|
|
73
89
|
AccountMenu,
|
|
74
90
|
AccountTrigger,
|
|
75
91
|
AccountAvatar,
|
|
@@ -3,7 +3,17 @@ import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
|
3
3
|
import { IconX } from "@tabler/icons-react";
|
|
4
4
|
import { Button, type ButtonProps } from "../../src/ui/button";
|
|
5
5
|
import { cn } from "../../src/utils/cn";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
panelCloseButtonClass,
|
|
8
|
+
sidebarHeaderRowClass,
|
|
9
|
+
sidebarNavIconClass,
|
|
10
|
+
sidebarNavItemActionsClass,
|
|
11
|
+
sidebarNavItemClass,
|
|
12
|
+
sidebarNavItemIndentStyle,
|
|
13
|
+
sidebarNavItemLabelClass,
|
|
14
|
+
sidebarNavItemLeadingClass,
|
|
15
|
+
sidebarNavItemTrailingClass,
|
|
16
|
+
} from "./app-shell-classes";
|
|
7
17
|
import { useOptionalSidebar } from "./SidebarChrome";
|
|
8
18
|
|
|
9
19
|
export type SidebarPanelProps = {
|
|
@@ -134,23 +144,36 @@ export function PanelClose({ label = "Close navigation", onClick, className, chi
|
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
export type SidebarNavItemProps = ComponentPropsWithoutRef<"button"> & {
|
|
137
|
-
/** Current route: static fill, `font-medium`, `aria-current="page"`. */
|
|
147
|
+
/** Current route: static fill, `font-medium`, `aria-current="page"`, `data-active="true"`. */
|
|
138
148
|
active?: boolean;
|
|
139
149
|
/** Collapsed panel: centred icon, no horizontal padding. */
|
|
140
150
|
iconOnly?: boolean;
|
|
141
|
-
/** Leading icon element; gets `sidebarNavIconClass(active)` merged onto it. */
|
|
151
|
+
/** Leading icon element; gets `sidebarNavIconClass(active)` merged onto it. For other leading content use `Sidebar.NavItemLeading`. */
|
|
142
152
|
icon?: ReactElement;
|
|
143
153
|
/** Render the single child element (for example a router link) as the row. */
|
|
144
154
|
asChild?: boolean;
|
|
155
|
+
/** Picked (for example in a multi-select list): the active fill without `font-medium`. Sets `data-selected="true"`. */
|
|
156
|
+
selected?: boolean;
|
|
157
|
+
/** Nesting level from 0. Each level adds 12px to the 16px left padding. Ignored with `iconOnly`. */
|
|
158
|
+
indent?: number;
|
|
159
|
+
/** Row being dragged. Sets `data-dragging="true"`. */
|
|
160
|
+
dragging?: boolean;
|
|
161
|
+
/** Row under a drag. Sets `data-drop-target="true"`. */
|
|
162
|
+
dropTarget?: boolean;
|
|
145
163
|
};
|
|
146
164
|
|
|
147
|
-
/** Panel nav row (`sidebarNavItemClass`). Renders a native `<button>`, or its child with `asChild`. */
|
|
165
|
+
/** Panel nav row (`sidebarNavItemClass`). Renders a native `<button>`, or its child with `asChild`. Compose `NavItemLeading`, `NavItemLabel`, `NavItemTrailing` and `NavItemActions` inside. */
|
|
148
166
|
export function NavItem({
|
|
149
167
|
active = false,
|
|
150
168
|
iconOnly = false,
|
|
151
169
|
icon,
|
|
152
170
|
asChild = false,
|
|
171
|
+
selected = false,
|
|
172
|
+
indent = 0,
|
|
173
|
+
dragging = false,
|
|
174
|
+
dropTarget = false,
|
|
153
175
|
className,
|
|
176
|
+
style,
|
|
154
177
|
children,
|
|
155
178
|
...props
|
|
156
179
|
}: SidebarNavItemProps) {
|
|
@@ -160,6 +183,11 @@ export function NavItem({
|
|
|
160
183
|
type={asChild ? undefined : "button"}
|
|
161
184
|
{...props}
|
|
162
185
|
aria-current={active ? "page" : undefined}
|
|
186
|
+
data-active={active || undefined}
|
|
187
|
+
data-selected={selected || undefined}
|
|
188
|
+
data-dragging={dragging || undefined}
|
|
189
|
+
data-drop-target={dropTarget || undefined}
|
|
190
|
+
style={indent > 0 && !iconOnly ? { ...sidebarNavItemIndentStyle(indent), ...style } : style}
|
|
163
191
|
className={cn(sidebarNavItemClass({ active, iconOnly }), className)}
|
|
164
192
|
>
|
|
165
193
|
{icon ? <Slot className={sidebarNavIconClass(active)}>{icon}</Slot> : null}
|
|
@@ -167,3 +195,36 @@ export function NavItem({
|
|
|
167
195
|
</Row>
|
|
168
196
|
);
|
|
169
197
|
}
|
|
198
|
+
|
|
199
|
+
export type SidebarNavItemPartProps = ComponentPropsWithoutRef<"span"> & {
|
|
200
|
+
/** Merge the part classes onto the single child element instead of a `<span>`. */
|
|
201
|
+
asChild?: boolean;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
function NavItemPart({ partClass, asChild = false, className, ...props }: SidebarNavItemPartProps & { partClass: string }) {
|
|
205
|
+
const Part = asChild ? Slot : "span";
|
|
206
|
+
return <Part {...props} className={cn(partClass, className)} />;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Leading slot of a nav row: checkbox, icon, avatar or status dot (`sidebarNavItemLeadingClass`). */
|
|
210
|
+
export function NavItemLeading(props: SidebarNavItemPartProps) {
|
|
211
|
+
return <NavItemPart {...props} partClass={sidebarNavItemLeadingClass} />;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Truncated label of a nav row (`sidebarNavItemLabelClass`). `asChild` on a rename input keeps the layout. */
|
|
215
|
+
export function NavItemLabel(props: SidebarNavItemPartProps) {
|
|
216
|
+
return <NavItemPart {...props} partClass={sidebarNavItemLabelClass} />;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Always-visible trailing content of a nav row: counts, badges, spinners (`sidebarNavItemTrailingClass`). */
|
|
220
|
+
export function NavItemTrailing(props: SidebarNavItemPartProps) {
|
|
221
|
+
return <NavItemPart {...props} partClass={sidebarNavItemTrailingClass} />;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Hover-revealed actions of a nav row, such as a `...` menu (`sidebarNavItemActionsClass`).
|
|
226
|
+
* Adds no handlers: stop propagation (and prevent default inside a link) on the button.
|
|
227
|
+
*/
|
|
228
|
+
export function NavItemActions(props: SidebarNavItemPartProps) {
|
|
229
|
+
return <NavItemPart {...props} partClass={sidebarNavItemActionsClass} />;
|
|
230
|
+
}
|
|
@@ -28,13 +28,14 @@ export const sidebarSectionLabelClass =
|
|
|
28
28
|
"sidebar-section-label px-4 py-1 text-[11px] leading-4 font-medium tracking-[-0.01em]";
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Nav row state: idle rows are `foreground/80` and get the
|
|
32
|
-
* the active row is `font-medium text-foreground` on a static
|
|
31
|
+
* Nav row state: idle rows are `foreground/80` and get the `sidebar-accent`
|
|
32
|
+
* fill on hover; the active row is `font-medium text-foreground` on a static
|
|
33
|
+
* fill.
|
|
33
34
|
*/
|
|
34
35
|
export function sidebarNavLinkTextClass(isActive: boolean): string {
|
|
35
36
|
return isActive
|
|
36
|
-
? "bg-
|
|
37
|
-
: "font-normal text-foreground/80 hover:bg-
|
|
37
|
+
? "bg-sidebar-accent font-medium text-foreground"
|
|
38
|
+
: "font-normal text-foreground/80 hover:bg-sidebar-accent hover:text-foreground";
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -53,13 +54,73 @@ export type SidebarNavItemClassOptions = {
|
|
|
53
54
|
active?: boolean;
|
|
54
55
|
/** Collapsed panel: centred icon, no horizontal padding. */
|
|
55
56
|
iconOnly?: boolean;
|
|
57
|
+
/** Picked (for example in a multi-select list): the active fill, without `font-medium`. Same as `data-selected="true"`. */
|
|
58
|
+
selected?: boolean;
|
|
59
|
+
/** Row being dragged: `opacity-40 cursor-grabbing`. Same as `data-dragging="true"`. */
|
|
60
|
+
dragging?: boolean;
|
|
61
|
+
/** Row under a drag: fill plus an inset `ring-focus` ring. Same as `data-drop-target="true"`. */
|
|
62
|
+
dropTarget?: boolean;
|
|
56
63
|
};
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
// Row states an app can also set as data attributes on the row itself.
|
|
66
|
+
const sidebarNavItemDataStateClass =
|
|
67
|
+
"data-[selected=true]:bg-sidebar-accent data-[selected=true]:text-foreground data-[dragging=true]:cursor-grabbing data-[dragging=true]:opacity-40 data-[drop-target=true]:bg-sidebar-accent data-[drop-target=true]:text-foreground data-[drop-target=true]:ring-2 data-[drop-target=true]:ring-inset data-[drop-target=true]:ring-focus";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whole panel nav row: `sidebarNavRowClass` plus `sidebarNavLinkTextClass`,
|
|
71
|
+
* plus `data-selected`, `data-dragging` and `data-drop-target` (`="true"`)
|
|
72
|
+
* styles. The `selected`, `dragging` and `dropTarget` options apply the same
|
|
73
|
+
* styles without the attributes.
|
|
74
|
+
*/
|
|
75
|
+
export function sidebarNavItemClass({
|
|
76
|
+
active = false,
|
|
77
|
+
iconOnly = false,
|
|
78
|
+
selected = false,
|
|
79
|
+
dragging = false,
|
|
80
|
+
dropTarget = false,
|
|
81
|
+
}: SidebarNavItemClassOptions = {}): string {
|
|
82
|
+
return cn(
|
|
83
|
+
sidebarNavRowClass(iconOnly),
|
|
84
|
+
sidebarNavLinkTextClass(active),
|
|
85
|
+
sidebarNavItemDataStateClass,
|
|
86
|
+
selected && "bg-sidebar-accent text-foreground",
|
|
87
|
+
dragging && "cursor-grabbing opacity-40",
|
|
88
|
+
dropTarget && "bg-sidebar-accent text-foreground ring-2 ring-inset ring-focus",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Nested row padding: each `indent` level adds 12px to the row's 16px
|
|
94
|
+
* (`px-4`) left padding. Put it on the row's `style`.
|
|
95
|
+
*/
|
|
96
|
+
export function sidebarNavItemIndentStyle(indent: number): { paddingLeft: string } {
|
|
97
|
+
return { paddingLeft: `calc(1rem + ${indent * 12}px)` };
|
|
61
98
|
}
|
|
62
99
|
|
|
100
|
+
/** Row part: leading checkbox, icon, avatar or status dot. */
|
|
101
|
+
export const sidebarNavItemLeadingClass = "flex shrink-0 items-center";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Row part: the label, which takes the free width and truncates. For two
|
|
105
|
+
* lines, put `block truncate` spans inside (subtitle: `sidebarNavItemSubtitleClass`).
|
|
106
|
+
*/
|
|
107
|
+
export const sidebarNavItemLabelClass = "min-w-0 flex-1 truncate";
|
|
108
|
+
|
|
109
|
+
/** Second, muted line inside the label. */
|
|
110
|
+
export const sidebarNavItemSubtitleClass = "block truncate text-xs text-muted-foreground";
|
|
111
|
+
|
|
112
|
+
/** Row part: always-visible trailing content (counts, badges, spinners). */
|
|
113
|
+
export const sidebarNavItemTrailingClass = "ml-auto flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Row part: actions such as a `...` menu button. In the row flow at the end,
|
|
117
|
+
* 24px tall without growing the row. Hidden until the row is hovered or has
|
|
118
|
+
* focus, or a menu trigger inside it (or the part itself) is
|
|
119
|
+
* `data-state="open"`.
|
|
120
|
+
*/
|
|
121
|
+
export const sidebarNavItemActionsClass =
|
|
122
|
+
"-my-1 ml-auto flex h-6 shrink-0 items-center opacity-0 transition-opacity duration-75 group-hover:opacity-100 group-focus-within:opacity-100 has-[[data-state=open]]:opacity-100 data-[state=open]:opacity-100";
|
|
123
|
+
|
|
63
124
|
/** Nav row icon (put it on the `<svg>`): 19px, muted until active. */
|
|
64
125
|
export function sidebarNavIconClass(active: boolean): string {
|
|
65
126
|
return cn("size-[19px] shrink-0", active ? "text-foreground" : "text-muted-foreground");
|
package/shell/src/index.ts
CHANGED
|
@@ -46,7 +46,13 @@ export {
|
|
|
46
46
|
railTileClass,
|
|
47
47
|
railTileStateClass,
|
|
48
48
|
sidebarNavIconClass,
|
|
49
|
+
sidebarNavItemActionsClass,
|
|
49
50
|
sidebarNavItemClass,
|
|
51
|
+
sidebarNavItemIndentStyle,
|
|
52
|
+
sidebarNavItemLabelClass,
|
|
53
|
+
sidebarNavItemLeadingClass,
|
|
54
|
+
sidebarNavItemSubtitleClass,
|
|
55
|
+
sidebarNavItemTrailingClass,
|
|
50
56
|
sidebarNavLinkTextClass,
|
|
51
57
|
sidebarNavRowClass,
|
|
52
58
|
sidebarSectionLabelClass,
|
package/src/index.ts
CHANGED
|
@@ -159,7 +159,13 @@ export {
|
|
|
159
159
|
railTileClass,
|
|
160
160
|
railTileStateClass,
|
|
161
161
|
sidebarNavIconClass,
|
|
162
|
+
sidebarNavItemActionsClass,
|
|
162
163
|
sidebarNavItemClass,
|
|
164
|
+
sidebarNavItemIndentStyle,
|
|
165
|
+
sidebarNavItemLabelClass,
|
|
166
|
+
sidebarNavItemLeadingClass,
|
|
167
|
+
sidebarNavItemSubtitleClass,
|
|
168
|
+
sidebarNavItemTrailingClass,
|
|
163
169
|
sidebarNavLinkTextClass,
|
|
164
170
|
sidebarNavRowClass,
|
|
165
171
|
sidebarSectionLabelClass,
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* --accent, --accent-foreground, --destructive, --destructive-foreground,
|
|
24
24
|
* --border, --input, --ring and --radius (all colours as "R G B").
|
|
25
25
|
*
|
|
26
|
+
* `--color-sidebar-accent` (sidebar nav row fill) reads the app's
|
|
27
|
+
* `--sidebar-accent` when it is defined, else `--secondary`.
|
|
28
|
+
*
|
|
26
29
|
* `--color-card` and `--color-muted` are references to `--card` and `--muted`.
|
|
27
30
|
* `SonnerToaster sonnerVars` reads them, along with `--color-border` and
|
|
28
31
|
* `--color-foreground`. An app that already maps those names keeps its own
|
|
@@ -44,6 +47,7 @@
|
|
|
44
47
|
--color-surface-secondary-foreground: rgb(var(--foreground));
|
|
45
48
|
--color-surface-tertiary: rgb(var(--secondary));
|
|
46
49
|
--color-surface-tertiary-foreground: rgb(var(--secondary-foreground));
|
|
50
|
+
--color-sidebar-accent: rgb(var(--sidebar-accent, var(--secondary)));
|
|
47
51
|
--color-overlay: rgb(var(--popover));
|
|
48
52
|
--color-overlay-foreground: rgb(var(--popover-foreground));
|
|
49
53
|
--color-field-background: rgb(var(--card));
|
package/tokens/src/tokens.css
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
--color-surface-tertiary: var(--surface-tertiary);
|
|
57
57
|
--color-surface-tertiary-foreground: var(--surface-tertiary-foreground);
|
|
58
58
|
--color-surface-card: var(--surface-card);
|
|
59
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
59
60
|
--color-warning: var(--warning);
|
|
60
61
|
--color-warning-foreground: var(--warning-foreground);
|
|
61
62
|
|
|
@@ -155,6 +156,8 @@
|
|
|
155
156
|
--surface-tertiary: oklch(93.73% 0 0);
|
|
156
157
|
--surface-tertiary-foreground: oklch(21.03% 0 0);
|
|
157
158
|
--surface-card: oklch(98% 0 0);
|
|
159
|
+
/* Sidebar nav row hover, active and selected fill. */
|
|
160
|
+
--sidebar-accent: var(--surface-tertiary);
|
|
158
161
|
--warning: oklch(78.19% 0.159 72.33);
|
|
159
162
|
--warning-foreground: oklch(21.03% 0.0059 72.33);
|
|
160
163
|
--radius: 1rem;
|
|
@@ -202,6 +205,8 @@
|
|
|
202
205
|
--surface-tertiary: oklch(27.21% 0.0023 0);
|
|
203
206
|
--surface-tertiary-foreground: oklch(99.11% 0 0);
|
|
204
207
|
--surface-card: oklch(25.7% 0.0023 0);
|
|
208
|
+
/* sRGB 28 28 28: a step darker than --surface-tertiary. */
|
|
209
|
+
--sidebar-accent: oklch(22.64% 0 0);
|
|
205
210
|
--warning: oklch(82.03% 0.1392 76.34);
|
|
206
211
|
--warning-foreground: oklch(21.03% 0.0059 76.34);
|
|
207
212
|
--radius: 1rem;
|
package/app/README.md
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
# App helpers (`@vvedantb/ui`)
|
|
2
|
-
|
|
3
|
-
Shared web React helpers that were duplicated across apps. Source-exported as the `./app` subpath of `@vvedantb/ui`.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```sh
|
|
8
|
-
npm install @vvedantb/ui
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Peers
|
|
12
|
-
|
|
13
|
-
Required: `react` and `react-dom` (>=19). Optional peers of `@vvedantb/ui`, needed only for the app helpers: `@clerk/clerk-react` (`useStableAuth`), `@tanstack/react-hotkeys` and `zod`.
|
|
14
|
-
|
|
15
|
-
`sonner` is not a dependency. The update watcher takes the toast API as an option. The app source uses a local class join, which the root does not export (the root `cn` is the clsx and tailwind-merge one). Tailwind must scan `node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}` so the class names are generated, and the app needs the `@vvedantb/ui/tokens` names (`bg-surface-card`, `divide-separator`, `text-muted-foreground` and so on).
|
|
16
|
-
|
|
17
|
-
```css
|
|
18
|
-
@source "../node_modules/@vvedantb/ui/app/src/**/*.{ts,tsx}";
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Confirm skip (Alt-click)
|
|
22
|
-
|
|
23
|
-
```tsx
|
|
24
|
-
import { ConfirmSkipHint, skipConfirmTitle, useRequestConfirm } from "@vvedantb/ui";
|
|
25
|
-
|
|
26
|
-
const { request } = useRequestConfirm();
|
|
27
|
-
|
|
28
|
-
<DropdownMenuItem
|
|
29
|
-
title={skipConfirmTitle("Delete")}
|
|
30
|
-
onSelect={(event) => request(() => setOpen(true), () => remove(), event)}
|
|
31
|
-
>
|
|
32
|
-
Delete <ConfirmSkipHint />
|
|
33
|
-
</DropdownMenuItem>
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
- `useRequestConfirm` does not subscribe to Alt. It reads `getAltHeld()` when the request happens, so the menu owner does not re-render when Alt goes down. It returns `{ request }` only.
|
|
37
|
-
- `ConfirmSkipHint` stays mounted and gains a `hidden` class when Alt is not held, so an open Radix menu keeps a stable child tree.
|
|
38
|
-
- The Alt store resets when the last subscriber leaves, clears on blur and tab hide, and cancels a bare Alt keydown only inside `[role='menu']` or `[data-radix-menu-content]`.
|
|
39
|
-
- Also exported: `useAltHeld`, `getAltHeld`, `createAltHeldStore`, `bindToWindow`, `shouldSkipConfirm`, `requestConfirm`.
|
|
40
|
-
|
|
41
|
-
## Update available watcher
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
import { toast } from "sonner";
|
|
45
|
-
import { startUpdateAvailableWatcher } from "@vvedantb/ui";
|
|
46
|
-
|
|
47
|
-
useEffect(
|
|
48
|
-
() =>
|
|
49
|
-
startUpdateAvailableWatcher({
|
|
50
|
-
origin: window.location.origin,
|
|
51
|
-
toast,
|
|
52
|
-
closeConvex: () => void convex.close(),
|
|
53
|
-
enabled: !import.meta.env.DEV,
|
|
54
|
-
}),
|
|
55
|
-
[],
|
|
56
|
-
);
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Options: `origin`, `toast`, `closeConvex?`, `pollMs?` (default 60000), `toastId?` (default `app-update-available`), `enabled?` (default true), `reload?` (default `window.location.reload`). On a changed asset fingerprint it shows a sticky "Update available" toast. Refresh calls `closeConvex` (errors ignored), then reloads. `fingerprintFromHtml` and `fetchDeployFingerprint` are exported too. The fingerprint looks for Vite `/assets/*.js` URLs in the index HTML.
|
|
60
|
-
|
|
61
|
-
## Settings chrome
|
|
62
|
-
|
|
63
|
-
```tsx
|
|
64
|
-
import { SettingsPage, SettingsSection, SettingsToggleRow } from "@vvedantb/ui";
|
|
65
|
-
|
|
66
|
-
<SettingsPage
|
|
67
|
-
title="Notifications"
|
|
68
|
-
layout={({ title, headerRight, toolbar, tabs, fillHeight, children }) => (
|
|
69
|
-
<PageContainer title={title} rightSection={headerRight} toolbar={toolbar} tabs={tabs} noScroll={fillHeight}>
|
|
70
|
-
{children}
|
|
71
|
-
</PageContainer>
|
|
72
|
-
)}
|
|
73
|
-
>
|
|
74
|
-
<SettingsSection title="Email" bodyVariant="list">
|
|
75
|
-
<SettingsToggleRow title="Weekly digest" action={<Switch />} />
|
|
76
|
-
</SettingsSection>
|
|
77
|
-
</SettingsPage>
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
Exports: `SettingsSection`, `SettingsField`, `SettingsStack`, `SettingsToggleRow`, `SettingsEmptyState` (icon is `ComponentType<{ size?: number; className?: string }>`), `SettingsPage`. `SettingsField` `layout`: `"stacked"` (default) puts the label above the control. `"horizontal"` renders one row with the label and description on the left and the control on the right. Without `layout`, `SettingsPage` renders a plain `div` with a heading and the optional slots.
|
|
81
|
-
|
|
82
|
-
## useStableAuth
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
import { useStableAuth } from "@vvedantb/ui"; // 2000 ms debounce, wraps Clerk useAuth
|
|
86
|
-
import { createUseStableAuth } from "@vvedantb/ui";
|
|
87
|
-
|
|
88
|
-
const useAuth = createUseStableAuth(useClerkAuth, { debounceMs: 3000 });
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
After a signed-in session, a drop to signed-out reports `isLoaded: false` for the debounce window so Convex does not cascade. State is module-level. `resetStableAuthSession()` clears it and is for tests only.
|
|
92
|
-
|
|
93
|
-
## Develop
|
|
94
|
-
|
|
95
|
-
```sh
|
|
96
|
-
pnpm --filter @vvedantb/ui typecheck
|
|
97
|
-
pnpm --filter @vvedantb/ui test
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
Source is in `app/src` and tests in `app/tests`. Publish steps are in the root `README.md`.
|
package/tokens/README.md
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# `@vvedantb/ui/tokens`
|
|
2
|
-
|
|
3
|
-
Design tokens and shared Tailwind v4 CSS for the vvedantb web apps. The CSS and Tailwind preset are subpaths of `@vvedantb/ui`; the native JS tokens are named exports of the root (`tokens`, `Tokens`). Source-exported: no build step. Paths in the `File` column are relative to `packages/ui/tokens/`.
|
|
4
|
-
|
|
5
|
-
## Exports
|
|
6
|
-
|
|
7
|
-
| Export | File | What it is |
|
|
8
|
-
| --- | --- | --- |
|
|
9
|
-
| `@vvedantb/ui/tokens`, `@vvedantb/ui/tokens/tokens.css` | `src/tokens.css` | Colour, radius, shadow, ease and font tokens (`@theme`), light/dark variables, `dark` variant, the radius utilities `rounded-surface` (`clamp(0.75rem, var(--radius), 1rem)`), `rounded-menu-item` (`min(var(--radius), 0.5rem)`) and `rounded-control` (`min(var(--radius), 0.625rem)`) (0.7.1; `rounded-surface` was in `utilities.css` before), `*` border and `body` defaults. In 0.2.1 it also defines shadcn-style aliases (`primary`, `muted-foreground`, `secondary`, `destructive`, `card`, `popover`, `input`, `ring`) that point at the same tokens. |
|
|
10
|
-
| `tokens` and `Tokens` from `@vvedantb/ui` | `src/native.ts` | JS colours, radii and spacing for React Native. `@vvedantb/native-ui` keeps its own copy in `src/tokens.ts`; a test keeps the two equal. |
|
|
11
|
-
| `@vvedantb/ui/tokens/css/base.css` | `src/css/base.css` | `html` touch/accent, `::selection`, control transitions, pointer cursor, range accent (`@layer base`). |
|
|
12
|
-
| `@vvedantb/ui/tokens/css/dialog.css` | `src/css/dialog.css` | Unlayered `max-width: none` for `@vvedantb/ui` compact (bottom-sheet) dialogs. |
|
|
13
|
-
| `@vvedantb/ui/tokens/css/modal.css` | `src/css/modal.css` | `.t-modal`, `.is-open`, `.is-closing` and `t-modal-presence-wait` (`@layer components`). |
|
|
14
|
-
| `@vvedantb/ui/tokens/css/tabs.css` | `src/css/tabs.css` | `.t-tab`, active colour, `.t-tabs-pill`, small-screen `.t-tabs` scrolling. |
|
|
15
|
-
| `@vvedantb/ui/tokens/css/tabs-pills.css` | `src/css/tabs-pills.css` | `--tabs-*` colour variables, `.t-tabs--segmented`, `.t-tabs--pills` and pill hover / active fills. |
|
|
16
|
-
| `@vvedantb/ui/tokens/css/utilities.css` | `src/css/utilities.css` | `@utility hit-target` and `@utility reveal-on-hover`. `rounded-surface` moved to `tokens.css` in 0.7.1. |
|
|
17
|
-
| `@vvedantb/ui/tokens/css/scrollbar-thin.css` | `src/css/scrollbar-thin.css` | `.scrollbar-thin` (`@layer utilities`, always emitted). |
|
|
18
|
-
| `@vvedantb/ui/tokens/css/animations.css` | `src/css/animations.css` | `animate-aurora`, `animate-fade-in-up`, `animate-indeterminate` as `@theme` (CSS-first apps). |
|
|
19
|
-
| `@vvedantb/ui/tokens/css/compat-shadcn-rgb.css`, `@vvedantb/ui/tokens/compat/shadcn-rgb.css` | `src/css/compat-shadcn-rgb.css` | Replaces `tokens.css` for apps with shadcn-style `R G B` channel variables. Defines only the UI-only `--color-*` names as `rgb(var(--x) / alpha)` references, `--radius-field`, `--scrollbar`, `--field-radius`, the same three radius utilities as `tokens.css` and a `glass-panel-strong` fallback. Import it **or** `tokens.css`, never both. |
|
|
20
|
-
| `@vvedantb/ui/tokens/css/index.css` | `src/css/index.css` | base + dialog + utilities + scrollbar-thin + animations, for new CSS-first apps. |
|
|
21
|
-
| `@vvedantb/ui/tokens/tailwind-preset` | `src/tailwind-preset.mjs` (+ `.d.mts`) | Tailwind preset for apps that load a JS config with `@config`. |
|
|
22
|
-
|
|
23
|
-
Every file was extracted because the same rule (same at-rule context, selector and body) sits in 3 or more of the five apps. The measured duplication is in `verify/ANALYSIS.md`, and the proof that each app compiles to the same CSS after migrating is in `verify/RESULTS.md`. Blocks that only 2 apps share, or that drift in a way that would change an app's CSS, stay in the apps; `verify/ANALYSIS.md` lists them.
|
|
24
|
-
|
|
25
|
-
## Use
|
|
26
|
-
|
|
27
|
-
CSS-first app (no `tailwind.config`):
|
|
28
|
-
|
|
29
|
-
```css
|
|
30
|
-
@import "tailwindcss";
|
|
31
|
-
@import "@vvedantb/ui/tokens";
|
|
32
|
-
@import "@vvedantb/ui/tokens/css/animations.css";
|
|
33
|
-
@import "@vvedantb/ui/tokens/css/base.css";
|
|
34
|
-
/* ...app @layer base rules... */
|
|
35
|
-
@import "@vvedantb/ui/tokens/css/modal.css";
|
|
36
|
-
@import "@vvedantb/ui/tokens/css/tabs.css";
|
|
37
|
-
/* ...app @layer components rules... */
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
App with a JS config (`@config "../tailwind.config.ts"`):
|
|
41
|
-
|
|
42
|
-
```ts
|
|
43
|
-
import { tokensPreset } from "@vvedantb/ui/tokens/tailwind-preset";
|
|
44
|
-
|
|
45
|
-
export default {
|
|
46
|
-
presets: [tokensPreset()],
|
|
47
|
-
// or tokensPreset({ color: (name) => `color-mix(in oklch, var(--${name}) calc(<alpha-value> * 100%), transparent)` })
|
|
48
|
-
content: ["./src/**/*.{ts,tsx}"],
|
|
49
|
-
theme: { extend: { /* app-only colours, fonts, animations */ } },
|
|
50
|
-
plugins: [tailwindcssAnimate],
|
|
51
|
-
};
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
The preset holds `darkMode: "class"`, the shared colour map (`border`, `surface.*`, `danger.*`, `field.*`, shadcn aliases `primary`/`secondary`/`destructive`/`card`/`popover`/`input` ...), `borderRadius` lg/md/sm/field, `boxShadow` soft/panel/insetSoft, `ease-smooth`, `screens` xs to 2xl in px, and the aurora / fade-in-up / indeterminate / accordion keyframes. It does not hold plugins, `content`, fonts, `accent`/`muted`/`ring`/`sidebar` (they differ per app) or accordion timings.
|
|
55
|
-
|
|
56
|
-
Do not import `css/animations.css` in an app that uses the preset: the preset already defines the same keyframes and animations.
|
|
57
|
-
|
|
58
|
-
### Where to put the imports
|
|
59
|
-
|
|
60
|
-
Tailwind inlines an `@import` where it sits, even in the middle of a file (it must be at top level, not inside an `@layer` block). Put each import where the app's own copy of the rules was. Within one layer, later rules win at equal specificity, so moving shared rules above or below app rules can flip the result for elements matched by both. `examples/migration.md` gives the exact position for each app, and `pnpm --filter @vvedantb/ui verify` checks it.
|
|
61
|
-
|
|
62
|
-
### Variables each file needs
|
|
63
|
-
|
|
64
|
-
| File | Needs | Provided by |
|
|
65
|
-
| --- | --- | --- |
|
|
66
|
-
| `base.css` | `--accent`, `--foreground` | tokens.css or the app theme |
|
|
67
|
-
| `dialog.css` | none | |
|
|
68
|
-
| `modal.css` | `--modal-open-dur`, `--modal-close-dur`, `--modal-scale`, `--modal-scale-close`, `--modal-ease` | app `:root` |
|
|
69
|
-
| `tabs.css` | `--tabs-dur`, `--tabs-ease` (app `:root`); `--tabs-text-muted`, `--tabs-text-active`, `--tabs-pill-bg` | colours: tabs-pills.css or the app theme |
|
|
70
|
-
| `tabs-pills.css` | `--muted`, `--segment`, `--segment-foreground`, `--default` | tokens.css or the app theme |
|
|
71
|
-
| `utilities.css` | `--breakpoint-sm` (Tailwind default or app theme) | Tailwind |
|
|
72
|
-
| `scrollbar-thin.css` | `--scrollbar` | tokens.css or the app theme |
|
|
73
|
-
| `animations.css` | none | |
|
|
74
|
-
| `tailwind-preset` | the CSS variables named in its colour map, `--radius`, `--field-radius` | tokens.css or the app theme |
|
|
75
|
-
|
|
76
|
-
### Using an existing shadcn RGB theme
|
|
77
|
-
|
|
78
|
-
See the package `README.md`. With the compat file, `base.css`, `scrollbar-thin.css`, `utilities.css`, `dialog.css`, `modal.css` and `tabs.css` need no `tokens.css`. `base.css` reads `--accent` and `--foreground` and `tabs-pills.css` reads `--muted`, `--segment` and `--default`. Those are not channel-compatible, so skip `tabs-pills.css` and `index.css` (which includes `base.css`) unless the app defines those variables as full colours.
|
|
79
|
-
|
|
80
|
-
### Parameters
|
|
81
|
-
|
|
82
|
-
Where apps share a rule but drifted in one value, the value is a custom property whose fallback is the most common value. Set it on `:root` to keep a different value.
|
|
83
|
-
|
|
84
|
-
| File | Property | Default | Set by |
|
|
85
|
-
| --- | --- | --- | --- |
|
|
86
|
-
| `base.css` | `--tokens-control-transition-property` | `background-color, border-color, color, box-shadow, transform, opacity` | app-c: `background-color, color, transform, opacity` |
|
|
87
|
-
| `base.css` | `--tokens-control-transition-duration` | `240ms` | app-c: `100ms` |
|
|
88
|
-
|
|
89
|
-
## Develop
|
|
90
|
-
|
|
91
|
-
```sh
|
|
92
|
-
pnpm --filter @vvedantb/ui build # export targets exist + tsc (primitives, shell, app)
|
|
93
|
-
pnpm --filter @vvedantb/ui test # vitest (app helpers) + offline node --test: exports, CSS structure, Tailwind compile, preset
|
|
94
|
-
pnpm --filter @vvedantb/ui analyze # rule-level duplication across the app clones -> verify/ANALYSIS.tables.md
|
|
95
|
-
pnpm --filter @vvedantb/ui verify # before/after compile of every app -> verify/RESULTS.md, examples/migration.md
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
`analyze` and `verify` need the app clones (`APPS_DIR`, default `/workspace/cvx`; name or symlink the clones `app-a` to `app-e` inside it) and network access the first time: they install Tailwind 4.3.3, the plugins, postcss and jiti into a scratch directory (`VERIFY_DIR`, default `/workspace/pk-tokens-verify`, outside the repo). The migration for each app is data in `verify/migrations/<app>.json`. Each removed block must equal the shared file's block with the same context and selector, or the run fails.
|