@hogoshi/ui 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/chrome-veil.tsx +20 -0
- package/src/components/context-menu.tsx +138 -35
- package/src/components/dialog.tsx +10 -2
- package/src/components/image-viewer.tsx +1 -1
- package/src/components/navbar.tsx +5 -3
- package/src/components/otp.tsx +11 -1
- package/src/components/sheet.tsx +4 -4
- package/src/components/shell.tsx +173 -0
- package/src/components/sidebar.tsx +18 -13
- package/src/components/toaster.tsx +5 -5
- package/src/index.ts +2 -0
- package/src/styles.css +39 -0
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cn } from "../lib/cn";
|
|
2
|
+
|
|
3
|
+
const sides = {
|
|
4
|
+
top: "top-0 h-[calc(var(--safe-top)+2rem)] bg-gradient-to-b [mask-image:linear-gradient(to_bottom,black_30%,transparent)]",
|
|
5
|
+
bottom: "bottom-0 h-[var(--bottombar-space)] bg-gradient-to-t [mask-image:linear-gradient(to_top,black_30%,transparent)]",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function ChromeVeil({ side, fixed = false, className }: { side: "top" | "bottom"; fixed?: boolean; className?: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
aria-hidden
|
|
12
|
+
className={cn(
|
|
13
|
+
"from-bg pointer-events-none inset-x-0 to-transparent backdrop-blur-xl",
|
|
14
|
+
fixed ? "fixed z-[calc(var(--z-sticky)-1)]" : "absolute",
|
|
15
|
+
sides[side],
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
|
|
4
4
|
import { ClipboardIcon, Copy01Icon, ScissorIcon, TextIcon } from "@hugeicons-pro/core-stroke-rounded";
|
|
5
|
-
import type
|
|
5
|
+
import { useEffect, useState, type ComponentProps, type ReactNode } from "react";
|
|
6
6
|
import { cn } from "../lib/cn";
|
|
7
7
|
import type { AppIcon } from "./icon";
|
|
8
8
|
import { Icon } from "./icon";
|
|
@@ -16,6 +16,7 @@ export interface ContextMenuEntry {
|
|
|
16
16
|
icon?: AppIcon;
|
|
17
17
|
onSelect?: () => void;
|
|
18
18
|
destructive?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
19
20
|
items?: ContextMenuEntry[];
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -23,55 +24,141 @@ function selectionText() {
|
|
|
23
24
|
return window.getSelection()?.toString() ?? "";
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
type Field = HTMLInputElement | HTMLTextAreaElement;
|
|
28
|
+
|
|
29
|
+
type SavedEdit = {
|
|
30
|
+
field: Field | null;
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
text: string;
|
|
34
|
+
value: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const emptyEdit: SavedEdit = { field: null, start: 0, end: 0, text: "", value: "" };
|
|
38
|
+
let saved: SavedEdit = emptyEdit;
|
|
39
|
+
let expanded: SavedEdit = emptyEdit;
|
|
40
|
+
let collapsedAt = 0;
|
|
41
|
+
let pasteText = "";
|
|
42
|
+
const pasteListeners = new Set<(ready: boolean) => void>();
|
|
43
|
+
|
|
44
|
+
function isField(node: EventTarget | null): node is Field {
|
|
45
|
+
return node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function publishPaste(text: string) {
|
|
49
|
+
pasteText = text;
|
|
50
|
+
pasteListeners.forEach((listener) => listener(Boolean(text)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rememberField(field: Field, start: number, end: number) {
|
|
54
|
+
const next: SavedEdit = {
|
|
55
|
+
field,
|
|
56
|
+
start,
|
|
57
|
+
end,
|
|
58
|
+
text: field.value.slice(Math.min(start, end), Math.max(start, end)),
|
|
59
|
+
value: field.value,
|
|
60
|
+
};
|
|
61
|
+
saved = next;
|
|
62
|
+
if (start !== end) expanded = next;
|
|
63
|
+
else collapsedAt = performance.now();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function captureEditTarget() {
|
|
67
|
+
const active = document.activeElement;
|
|
68
|
+
if (isField(active)) {
|
|
69
|
+
const start = active.selectionStart ?? 0;
|
|
70
|
+
const end = active.selectionEnd ?? 0;
|
|
71
|
+
const clearedByClick =
|
|
72
|
+
start === end &&
|
|
73
|
+
expanded.field === active &&
|
|
74
|
+
expanded.start !== expanded.end &&
|
|
75
|
+
performance.now() - collapsedAt < 300;
|
|
76
|
+
if (clearedByClick) {
|
|
77
|
+
active.setSelectionRange(expanded.start, expanded.end);
|
|
78
|
+
saved = { ...expanded, value: active.value };
|
|
79
|
+
} else {
|
|
80
|
+
rememberField(active, start, end);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const text = selectionText();
|
|
84
|
+
saved = text ? { ...emptyEdit, text } : { ...emptyEdit };
|
|
85
|
+
}
|
|
86
|
+
void navigator.clipboard.readText().then(publishPaste).catch(() => publishPaste(""));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof document !== "undefined") {
|
|
90
|
+
document.addEventListener(
|
|
91
|
+
"selectionchange",
|
|
92
|
+
() => {
|
|
93
|
+
const active = document.activeElement;
|
|
94
|
+
if (!isField(active)) return;
|
|
95
|
+
rememberField(active, active.selectionStart ?? 0, active.selectionEnd ?? 0);
|
|
96
|
+
},
|
|
97
|
+
true,
|
|
98
|
+
);
|
|
99
|
+
document.addEventListener("contextmenu", captureEditTarget, true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function restoreField() {
|
|
103
|
+
const field = saved.field;
|
|
104
|
+
if (!field || !field.isConnected) return null;
|
|
105
|
+
field.focus({ preventScroll: true });
|
|
106
|
+
field.setSelectionRange(saved.start, saved.end);
|
|
107
|
+
return field;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeField(next: string, cursor: number) {
|
|
111
|
+
const field = restoreField();
|
|
112
|
+
if (!field) return;
|
|
113
|
+
field.setRangeText(next, saved.start, saved.end, "end");
|
|
114
|
+
field.setSelectionRange(cursor, cursor);
|
|
115
|
+
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
116
|
+
}
|
|
117
|
+
|
|
26
118
|
function cutSelection() {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
119
|
+
if (!saved.text || !saved.field) return;
|
|
120
|
+
void navigator.clipboard.writeText(saved.text);
|
|
121
|
+
writeField("", saved.start);
|
|
30
122
|
}
|
|
31
123
|
|
|
32
124
|
function copySelection() {
|
|
33
|
-
|
|
34
|
-
|
|
125
|
+
if (!saved.text) return;
|
|
126
|
+
void navigator.clipboard.writeText(saved.text);
|
|
127
|
+
restoreField();
|
|
35
128
|
}
|
|
36
129
|
|
|
37
130
|
async function pasteSelection() {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return;
|
|
131
|
+
let text = pasteText;
|
|
132
|
+
if (!text) {
|
|
133
|
+
try {
|
|
134
|
+
text = await navigator.clipboard.readText();
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
46
138
|
}
|
|
47
|
-
|
|
139
|
+
if (!text || !saved.field) return;
|
|
140
|
+
writeField(text, saved.start + text.length);
|
|
48
141
|
}
|
|
49
142
|
|
|
50
143
|
function selectAll() {
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
const selection = window.getSelection();
|
|
57
|
-
const node = selection?.anchorNode?.parentElement;
|
|
58
|
-
if (!node || !selection) return;
|
|
59
|
-
const range = document.createRange();
|
|
60
|
-
range.selectNodeContents(node);
|
|
61
|
-
selection.removeAllRanges();
|
|
62
|
-
selection.addRange(range);
|
|
144
|
+
const field = saved.field;
|
|
145
|
+
if (!field || !field.value) return;
|
|
146
|
+
field.focus({ preventScroll: true });
|
|
147
|
+
field.setSelectionRange(0, field.value.length);
|
|
63
148
|
}
|
|
64
149
|
|
|
65
|
-
function editEntries(): ContextMenuEntry[] {
|
|
150
|
+
function editEntries(canPaste: boolean): ContextMenuEntry[] {
|
|
151
|
+
const canEdit = Boolean(saved.text);
|
|
152
|
+
const canSelect = Boolean(saved.field?.value);
|
|
66
153
|
return [
|
|
67
154
|
{
|
|
68
155
|
id: "edit",
|
|
69
156
|
label: "Edit",
|
|
70
157
|
items: [
|
|
71
|
-
{ id: "cut", label: "Cut", icon: ScissorIcon, onSelect: cutSelection },
|
|
72
|
-
{ id: "copy", label: "Copy", icon: Copy01Icon, onSelect: copySelection },
|
|
73
|
-
{ id: "paste", label: "Paste", icon: ClipboardIcon, onSelect: () => void pasteSelection() },
|
|
74
|
-
{ id: "select-all", label: "Select all", icon: TextIcon, onSelect: selectAll },
|
|
158
|
+
{ id: "cut", label: "Cut", icon: ScissorIcon, onSelect: cutSelection, disabled: !canEdit || !saved.field },
|
|
159
|
+
{ id: "copy", label: "Copy", icon: Copy01Icon, onSelect: copySelection, disabled: !canEdit },
|
|
160
|
+
{ id: "paste", label: "Paste", icon: ClipboardIcon, onSelect: () => void pasteSelection(), disabled: !canPaste || !saved.field },
|
|
161
|
+
{ id: "select-all", label: "Select all", icon: TextIcon, onSelect: selectAll, disabled: !canSelect },
|
|
75
162
|
],
|
|
76
163
|
},
|
|
77
164
|
];
|
|
@@ -86,12 +173,27 @@ export function ContextMenuContent({
|
|
|
86
173
|
children,
|
|
87
174
|
...props
|
|
88
175
|
}: ComponentProps<typeof BaseContextMenu.Popup> & { edit?: boolean }) {
|
|
176
|
+
const [canPaste, setCanPaste] = useState(Boolean(pasteText));
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!edit) return;
|
|
179
|
+
const listener = (ready: boolean) => setCanPaste(ready);
|
|
180
|
+
pasteListeners.add(listener);
|
|
181
|
+
setCanPaste(Boolean(pasteText));
|
|
182
|
+
return () => {
|
|
183
|
+
pasteListeners.delete(listener);
|
|
184
|
+
};
|
|
185
|
+
}, [edit]);
|
|
186
|
+
|
|
89
187
|
return (
|
|
90
188
|
<BaseContextMenu.Portal>
|
|
91
189
|
<BaseContextMenu.Positioner className="z-[var(--z-popover)]">
|
|
92
|
-
<BaseContextMenu.Popup
|
|
190
|
+
<BaseContextMenu.Popup
|
|
191
|
+
className={cn(popupClass, className)}
|
|
192
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
93
195
|
<Glide selector={HIGHLIGHTED} />
|
|
94
|
-
{edit ? <EntryList items={editEntries()} /> : null}
|
|
196
|
+
{edit ? <EntryList items={editEntries(canPaste)} /> : null}
|
|
95
197
|
{children ? (
|
|
96
198
|
<>
|
|
97
199
|
{edit ? <Separator className="my-1" /> : null}
|
|
@@ -118,8 +220,9 @@ function EntryList({ items }: { items: ContextMenuEntry[] }) {
|
|
|
118
220
|
) : (
|
|
119
221
|
<ContextMenuItem
|
|
120
222
|
key={item.id}
|
|
223
|
+
disabled={item.disabled}
|
|
121
224
|
className={item.destructive ? "text-danger data-[highlighted]:text-danger" : undefined}
|
|
122
|
-
onClick={item.onSelect}
|
|
225
|
+
onClick={item.disabled ? undefined : item.onSelect}
|
|
123
226
|
>
|
|
124
227
|
{item.icon ? <Icon icon={item.icon} variant="stroke" size={16} /> : null}
|
|
125
228
|
{item.label}
|
|
@@ -13,10 +13,18 @@ export const DialogClose = BaseDialog.Close;
|
|
|
13
13
|
export const DialogTitle = BaseDialog.Title;
|
|
14
14
|
export const DialogDescription = BaseDialog.Description;
|
|
15
15
|
|
|
16
|
-
export function DialogContent({
|
|
16
|
+
export function DialogContent({
|
|
17
|
+
className,
|
|
18
|
+
children,
|
|
19
|
+
backdropProps,
|
|
20
|
+
...props
|
|
21
|
+
}: ComponentProps<typeof BaseDialog.Popup> & { backdropProps?: ComponentProps<typeof BaseDialog.Backdrop> }) {
|
|
17
22
|
return (
|
|
18
23
|
<BaseDialog.Portal>
|
|
19
|
-
<BaseDialog.Backdrop
|
|
24
|
+
<BaseDialog.Backdrop
|
|
25
|
+
className={cn("fixed inset-0 z-[var(--z-overlay)] bg-transparent", backdropMotion)}
|
|
26
|
+
{...backdropProps}
|
|
27
|
+
/>
|
|
20
28
|
<BaseDialog.Popup
|
|
21
29
|
className={cn(
|
|
22
30
|
"border-border bg-bg-elevated/70 fixed top-1/2 left-1/2 z-[var(--z-modal)] grid max-h-[calc(100dvh-max(1rem,var(--safe-top))-max(1rem,var(--safe-bottom)))] w-[min(32rem,calc(100vw-max(1rem,var(--safe-left))-max(1rem,var(--safe-right))))] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-[var(--radius-lg)] border p-6 shadow-[var(--shadow-lg)] backdrop-blur-xl outline-none",
|
|
@@ -204,7 +204,7 @@ export function ImageViewer({
|
|
|
204
204
|
}}
|
|
205
205
|
>
|
|
206
206
|
<BaseDialog.Title className="sr-only">{image.alt ?? fileName(image)}</BaseDialog.Title>
|
|
207
|
-
<div className="flex shrink-0 items-center gap-2 px-3 pt-[calc(0.75rem+var(--safe-top))] pr-[calc(0.75rem+var(--safe-right))] pb-3 pl-[
|
|
207
|
+
<div className="flex shrink-0 items-center gap-2 px-3 pt-[max(3.25rem,calc(0.75rem+var(--safe-top)))] pr-[calc(0.75rem+var(--safe-right))] pb-3 pl-[max(1rem,var(--safe-left))]">
|
|
208
208
|
<p className="text-fg-muted min-w-0 flex-1 truncate text-sm">
|
|
209
209
|
{count > 1 ? <span className="text-fg mr-2 tabular-nums">{index + 1} / {count}</span> : null}
|
|
210
210
|
{image.alt ?? fileName(image)}
|
|
@@ -5,6 +5,7 @@ import { NavigationMenu } from "@base-ui/react/navigation-menu";
|
|
|
5
5
|
import { ArrowDown01Icon } from "@hugeicons-pro/core-stroke-rounded";
|
|
6
6
|
import { useEffect, useState, type ComponentProps, type ReactNode } from "react";
|
|
7
7
|
import { cn } from "../lib/cn";
|
|
8
|
+
import { ChromeVeil } from "./chrome-veil";
|
|
8
9
|
import { Glide, glideActive } from "./glide";
|
|
9
10
|
import type { AppIcon } from "./icon";
|
|
10
11
|
import { Icon } from "./icon";
|
|
@@ -31,12 +32,13 @@ export function TopBar({ contained = false, children, className }: { contained?:
|
|
|
31
32
|
|
|
32
33
|
return (
|
|
33
34
|
<header className={cn("pointer-events-none inset-x-0 top-0 z-[var(--z-sticky)]", contained ? "absolute" : "fixed", className)}>
|
|
35
|
+
<ChromeVeil side="top" />
|
|
34
36
|
<div
|
|
35
37
|
className={cn(
|
|
36
|
-
"pointer-events-auto relative mx-auto flex h-12 items-center border transition-[width,
|
|
38
|
+
"pointer-events-auto relative mx-auto flex h-12 translate-y-[var(--topbar-offset)] items-center border transition-[width,border-radius,background-color,border-color,box-shadow] duration-[calc(var(--motion-duration)*4)] ease-[var(--motion-ease)]",
|
|
37
39
|
compact
|
|
38
|
-
? "border-border bg-bg-elevated w-[min(40rem,calc(100%-
|
|
39
|
-
: "
|
|
40
|
+
? "border-border bg-bg-elevated w-[min(40rem,calc(100%-var(--edge-left)-var(--edge-right)))] rounded-full px-2 shadow-[var(--shadow-md)]"
|
|
41
|
+
: "w-full border-transparent bg-transparent pr-[var(--edge-right)] pl-[var(--edge-left)]",
|
|
40
42
|
)}
|
|
41
43
|
>
|
|
42
44
|
<Navbar className="relative h-full w-full bg-transparent px-1 shadow-none backdrop-blur-none">{children}</Navbar>
|
package/src/components/otp.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { OTPField } from "@base-ui/react/otp-field";
|
|
4
|
-
import { useRef, useState, type ComponentProps } from "react";
|
|
4
|
+
import { useEffect, useRef, useState, type ComponentProps } from "react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
|
|
7
7
|
const slotClass =
|
|
@@ -51,6 +51,7 @@ function OtpSlot({ text }: { text: string }) {
|
|
|
51
51
|
type OtpProps = Omit<ComponentProps<typeof OTPField.Root>, "length" | "mask"> & {
|
|
52
52
|
length?: number;
|
|
53
53
|
mask?: boolean;
|
|
54
|
+
autoFocus?: boolean;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export function Otp({
|
|
@@ -61,12 +62,21 @@ export function Otp({
|
|
|
61
62
|
defaultValue = "",
|
|
62
63
|
onValueChange,
|
|
63
64
|
onValueComplete,
|
|
65
|
+
autoFocus,
|
|
64
66
|
...props
|
|
65
67
|
}: OtpProps) {
|
|
66
68
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
67
69
|
const [inner, setInner] = useState(defaultValue);
|
|
68
70
|
const current = value ?? inner;
|
|
69
71
|
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!autoFocus) return;
|
|
74
|
+
const id = requestAnimationFrame(() => {
|
|
75
|
+
rootRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
|
76
|
+
});
|
|
77
|
+
return () => cancelAnimationFrame(id);
|
|
78
|
+
}, [autoFocus]);
|
|
79
|
+
|
|
70
80
|
return (
|
|
71
81
|
<OTPField.Root
|
|
72
82
|
ref={rootRef}
|
package/src/components/sheet.tsx
CHANGED
|
@@ -18,10 +18,10 @@ const SideContext = createContext<SheetSide>("right");
|
|
|
18
18
|
const direction = { top: "up", right: "right", bottom: "down", left: "left" } as const;
|
|
19
19
|
|
|
20
20
|
const edge = {
|
|
21
|
-
top: "top-[
|
|
22
|
-
right: "right-[
|
|
23
|
-
bottom: "bottom-
|
|
24
|
-
left: "left-[
|
|
21
|
+
top: "top-[var(--topbar-offset)]",
|
|
22
|
+
right: "right-[var(--edge-right)]",
|
|
23
|
+
bottom: "bottom-[var(--bottombar-offset)]",
|
|
24
|
+
left: "left-[var(--edge-left)]",
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
const frame = {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Search01Icon } from "@hugeicons-pro/core-stroke-rounded";
|
|
4
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { Icon } from "./icon";
|
|
8
|
+
import { Kbd } from "./kbd";
|
|
9
|
+
import { Logo } from "./logo";
|
|
10
|
+
import { Sheet, SheetContent, SheetTitle } from "./sheet";
|
|
11
|
+
import { SidebarHeader, SidebarInset, SidebarProvider, SidebarTrigger, sidebarLabel, useSidebar } from "./sidebar";
|
|
12
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
|
|
13
|
+
|
|
14
|
+
export const shellGlass = "border-border bg-bg-elevated/70 hover:bg-bg-elevated border backdrop-blur-xl";
|
|
15
|
+
|
|
16
|
+
const focusRing = "rounded-[var(--radius-sm)] outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
17
|
+
|
|
18
|
+
export function AppShell({
|
|
19
|
+
sidebar,
|
|
20
|
+
topBar,
|
|
21
|
+
bottomBar,
|
|
22
|
+
defaultCollapsed,
|
|
23
|
+
className,
|
|
24
|
+
contentClassName,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
sidebar?: ReactNode;
|
|
28
|
+
topBar?: ReactNode;
|
|
29
|
+
bottomBar?: ReactNode;
|
|
30
|
+
defaultCollapsed?: boolean;
|
|
31
|
+
className?: string;
|
|
32
|
+
contentClassName?: string;
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<SidebarProvider defaultCollapsed={defaultCollapsed}>
|
|
37
|
+
<div data-app-shell className={cn("bg-bg fixed inset-0 flex h-app overflow-hidden", className)}>
|
|
38
|
+
{sidebar}
|
|
39
|
+
<SidebarInset className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
|
40
|
+
{topBar}
|
|
41
|
+
<div
|
|
42
|
+
data-scroll-root
|
|
43
|
+
className={cn(
|
|
44
|
+
"min-h-0 flex-1 touch-pan-y overflow-x-hidden overflow-y-scroll overscroll-y-contain pb-[var(--safe-bottom)]",
|
|
45
|
+
topBar && "pt-[var(--topbar-space)]",
|
|
46
|
+
bottomBar && "pb-[var(--bottombar-space)] md:pb-[var(--safe-bottom)]",
|
|
47
|
+
contentClassName,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
</SidebarInset>
|
|
53
|
+
</div>
|
|
54
|
+
{bottomBar}
|
|
55
|
+
</SidebarProvider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ShellBrand({ href, label, className, ...props }: Omit<ComponentProps<"a">, "children"> & { label: string }) {
|
|
60
|
+
const { collapsed } = useSidebar();
|
|
61
|
+
return (
|
|
62
|
+
<SidebarHeader className={cn("group/header relative overflow-hidden px-2", className)}>
|
|
63
|
+
<a
|
|
64
|
+
href={href}
|
|
65
|
+
aria-label={label}
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex min-w-0 items-center gap-2 px-1 whitespace-nowrap",
|
|
68
|
+
focusRing,
|
|
69
|
+
collapsed &&
|
|
70
|
+
"group-hover/header:pointer-events-none group-hover/header:opacity-0 [@media(hover:none)]:pointer-events-none [@media(hover:none)]:opacity-0",
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
<Logo size="sm" className="shrink-0" />
|
|
75
|
+
<span className={cn("text-sm font-semibold tracking-tight", sidebarLabel, collapsed && "opacity-0")}>{label}</span>
|
|
76
|
+
</a>
|
|
77
|
+
<SidebarTrigger
|
|
78
|
+
className={
|
|
79
|
+
collapsed
|
|
80
|
+
? "pointer-events-none absolute top-3 left-3 opacity-0 transition-opacity group-hover/header:pointer-events-auto group-hover/header:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
|
|
81
|
+
: "shrink-0"
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
</SidebarHeader>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function ShellLogo({ href, label, className, children, ...props }: ComponentProps<"a"> & { label: string }) {
|
|
89
|
+
return (
|
|
90
|
+
<a href={href} aria-label={label} className={cn("flex shrink-0 items-center gap-2", focusRing, className)} {...props}>
|
|
91
|
+
<Logo size="sm" />
|
|
92
|
+
{children}
|
|
93
|
+
</a>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ShellTitle({ className, children }: { className?: string; children: ReactNode }) {
|
|
98
|
+
return (
|
|
99
|
+
<p className={cn("pointer-events-none absolute top-1/2 left-1/2 max-w-[70%] -translate-x-1/2 -translate-y-1/2", className)}>
|
|
100
|
+
<span
|
|
101
|
+
aria-hidden
|
|
102
|
+
className="absolute top-1/2 left-1/2 -z-10 h-[2.4em] w-[260%] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(ellipse_at_center,var(--bg)_0%,color-mix(in_oklch,var(--bg)_82%,transparent)_36%,transparent_70%)]"
|
|
103
|
+
/>
|
|
104
|
+
<span className="relative block truncate text-sm font-semibold">{children}</span>
|
|
105
|
+
</p>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function ShellAction({ className, ...props }: ComponentProps<typeof Button>) {
|
|
110
|
+
return <Button variant="secondary" size="icon" className={cn(shellGlass, "rounded-full", className)} {...props} />;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function ShellSearch({
|
|
114
|
+
onOpen,
|
|
115
|
+
shortcut = "⌘K",
|
|
116
|
+
label = "Search",
|
|
117
|
+
className,
|
|
118
|
+
}: {
|
|
119
|
+
onOpen: () => void;
|
|
120
|
+
shortcut?: string;
|
|
121
|
+
label?: string;
|
|
122
|
+
className?: string;
|
|
123
|
+
}) {
|
|
124
|
+
const { collapsed } = useSidebar();
|
|
125
|
+
const fade = cn(sidebarLabel, collapsed && "opacity-0");
|
|
126
|
+
const button = (
|
|
127
|
+
<Button
|
|
128
|
+
variant="outline"
|
|
129
|
+
aria-label={label}
|
|
130
|
+
className={cn(shellGlass, "w-full justify-between overflow-hidden px-3 font-normal whitespace-nowrap [&>span]:w-full [&>span>span]:w-full", className)}
|
|
131
|
+
onClick={onOpen}
|
|
132
|
+
>
|
|
133
|
+
<span className="flex w-full items-center justify-between gap-2">
|
|
134
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
135
|
+
<Icon icon={Search01Icon} variant="stroke" size={16} className="shrink-0" />
|
|
136
|
+
<span className={fade}>{label}</span>
|
|
137
|
+
</span>
|
|
138
|
+
<Kbd className={cn("shrink-0", fade)}>{shortcut}</Kbd>
|
|
139
|
+
</span>
|
|
140
|
+
</Button>
|
|
141
|
+
);
|
|
142
|
+
return (
|
|
143
|
+
<div className="shrink-0 px-2 pb-2">
|
|
144
|
+
<Tooltip disabled={!collapsed}>
|
|
145
|
+
<TooltipTrigger render={button} />
|
|
146
|
+
<TooltipContent side="right">{label}</TooltipContent>
|
|
147
|
+
</Tooltip>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function ShellMenu({
|
|
153
|
+
open,
|
|
154
|
+
onOpenChange,
|
|
155
|
+
brand,
|
|
156
|
+
children,
|
|
157
|
+
}: {
|
|
158
|
+
open: boolean;
|
|
159
|
+
onOpenChange: (open: boolean) => void;
|
|
160
|
+
brand: ReactNode;
|
|
161
|
+
children: ReactNode;
|
|
162
|
+
}) {
|
|
163
|
+
return (
|
|
164
|
+
<Sheet side="left" open={open} onOpenChange={onOpenChange}>
|
|
165
|
+
<SheetContent className="right-auto px-2">
|
|
166
|
+
<SheetTitle className="px-3 pt-1 text-sm font-semibold">{brand}</SheetTitle>
|
|
167
|
+
<SidebarProvider>
|
|
168
|
+
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
|
169
|
+
</SidebarProvider>
|
|
170
|
+
</SheetContent>
|
|
171
|
+
</Sheet>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Button as BaseButton } from "@base-ui/react/button";
|
|
|
4
4
|
import { SidebarLeftIcon } from "@hugeicons-pro/core-stroke-rounded";
|
|
5
5
|
import { createContext, useContext, useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
|
6
6
|
import { cn } from "../lib/cn";
|
|
7
|
+
import { ChromeVeil } from "./chrome-veil";
|
|
7
8
|
import type { AppIcon, IconVariant } from "./icon";
|
|
8
9
|
import { Glide, glideActive } from "./glide";
|
|
9
10
|
import { Icon } from "./icon";
|
|
@@ -41,7 +42,7 @@ export function Sidebar({ className, ...props }: ComponentProps<"aside">) {
|
|
|
41
42
|
<aside
|
|
42
43
|
data-collapsed={collapsed || undefined}
|
|
43
44
|
className={cn(
|
|
44
|
-
"bg-bg-elevated border-border hidden h-full min-h-0 w-[calc(15rem+var(--safe-left))] shrink-0 flex-col overflow-hidden border-r pt-[calc(var(--safe-top)+0.
|
|
45
|
+
"bg-bg-elevated border-border hidden h-full min-h-0 w-[calc(15rem+var(--safe-left))] shrink-0 flex-col overflow-hidden border-r pt-[calc(var(--safe-top)+0.75rem)] pb-[calc(var(--safe-bottom)+0.75rem)] pl-[var(--safe-left)] transition-[width] duration-[var(--motion-duration)] ease-[var(--motion-ease)] md:flex",
|
|
45
46
|
collapsed && "w-[calc(3.5rem+var(--safe-left))]",
|
|
46
47
|
className,
|
|
47
48
|
)}
|
|
@@ -152,18 +153,22 @@ export function SidebarItem({
|
|
|
152
153
|
|
|
153
154
|
export function BottomBar({ className, children, ...props }: ComponentProps<"nav">) {
|
|
154
155
|
return (
|
|
155
|
-
|
|
156
|
-
className=
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
156
|
+
<>
|
|
157
|
+
<ChromeVeil side="bottom" fixed className="md:hidden" />
|
|
158
|
+
<nav
|
|
159
|
+
data-bottom-bar
|
|
160
|
+
className={cn(
|
|
161
|
+
"bg-bg-elevated/70 border-border fixed right-[var(--edge-right)] bottom-[var(--bottombar-offset)] left-[var(--edge-left)] z-[var(--z-sticky)] rounded-[var(--radius-lg)] border p-1 shadow-[var(--shadow-md)] backdrop-blur-xl md:hidden",
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
{...props}
|
|
165
|
+
>
|
|
166
|
+
<div className="relative flex items-stretch justify-around">
|
|
167
|
+
<Glide selector={ACTIVE} className="bg-accent-muted/60 rounded-[var(--radius-md)]" />
|
|
168
|
+
{children}
|
|
169
|
+
</div>
|
|
170
|
+
</nav>
|
|
171
|
+
</>
|
|
167
172
|
);
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -13,13 +13,13 @@ export function Toaster() {
|
|
|
13
13
|
position="bottom-right"
|
|
14
14
|
closeButton
|
|
15
15
|
offset={{
|
|
16
|
-
bottom: "
|
|
17
|
-
right: "1.5rem",
|
|
16
|
+
bottom: "var(--toast-bottom)",
|
|
17
|
+
right: "max(1.5rem, var(--safe-right))",
|
|
18
18
|
}}
|
|
19
19
|
mobileOffset={{
|
|
20
|
-
bottom: "
|
|
21
|
-
left: "1rem",
|
|
22
|
-
right: "1rem",
|
|
20
|
+
bottom: "var(--toast-bottom)",
|
|
21
|
+
left: "max(1rem, var(--safe-left))",
|
|
22
|
+
right: "max(1rem, var(--safe-right))",
|
|
23
23
|
}}
|
|
24
24
|
style={{ zIndex: "var(--z-toast)" }}
|
|
25
25
|
toastOptions={{
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,8 @@ export { Progress } from "./components/progress";
|
|
|
42
42
|
export { Logo } from "./components/logo";
|
|
43
43
|
export { LOGO_PATH, LOGO_VIEWBOX, logoSvg } from "./components/logo-mark";
|
|
44
44
|
export { Navbar, NavbarItem, NavbarMenu, NavbarMenuLink, NavbarMenus, TopBar } from "./components/navbar";
|
|
45
|
+
export { ChromeVeil } from "./components/chrome-veil";
|
|
46
|
+
export { AppShell, ShellAction, ShellBrand, ShellLogo, ShellMenu, ShellSearch, ShellTitle, shellGlass } from "./components/shell";
|
|
45
47
|
export { Glide, glideActive } from "./components/glide";
|
|
46
48
|
export type { GlideProps } from "./components/glide";
|
|
47
49
|
export { Calendar } from "./components/calendar";
|
package/src/styles.css
CHANGED
|
@@ -77,10 +77,49 @@ body {
|
|
|
77
77
|
--safe-right: env(safe-area-inset-right, 0px);
|
|
78
78
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
79
79
|
--safe-left: env(safe-area-inset-left, 0px);
|
|
80
|
+
--app-height: 100dvh;
|
|
81
|
+
--edge-x: 0.75rem;
|
|
82
|
+
--edge-left: max(var(--edge-x), var(--safe-left));
|
|
83
|
+
--edge-right: max(var(--edge-x), var(--safe-right));
|
|
84
|
+
--topbar-offset: calc(var(--safe-top) + 0.75rem);
|
|
85
|
+
--topbar-space: calc(var(--topbar-offset) + 3.75rem);
|
|
86
|
+
--bottombar-offset: calc(var(--safe-bottom) + 0.75rem);
|
|
87
|
+
--bottombar-space: calc(var(--bottombar-offset) + 4.25rem);
|
|
88
|
+
--toast-bottom: calc(var(--safe-bottom) + 1.5rem);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@media (display-mode: standalone) {
|
|
92
|
+
:root {
|
|
93
|
+
--app-height: 100vh;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@media (max-width: 47.99rem) {
|
|
98
|
+
html:has([data-bottom-bar]) {
|
|
99
|
+
--toast-bottom: var(--bottombar-space);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
html,
|
|
104
|
+
body {
|
|
105
|
+
min-height: var(--app-height);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@utility h-app {
|
|
109
|
+
height: var(--app-height);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@utility min-h-app {
|
|
113
|
+
min-height: var(--app-height);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@utility max-h-app {
|
|
117
|
+
max-height: var(--app-height);
|
|
80
118
|
}
|
|
81
119
|
|
|
82
120
|
html {
|
|
83
121
|
scroll-behavior: auto;
|
|
122
|
+
touch-action: pan-x pan-y;
|
|
84
123
|
-webkit-tap-highlight-color: transparent;
|
|
85
124
|
-webkit-text-size-adjust: 100%;
|
|
86
125
|
text-size-adjust: 100%;
|