@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogoshi/ui",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 { ComponentProps, ReactNode } from "react";
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
- const text = selectionText();
28
- if (text) void navigator.clipboard.writeText(text);
29
- document.execCommand("delete");
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
- const text = selectionText();
34
- if (text) void navigator.clipboard.writeText(text);
125
+ if (!saved.text) return;
126
+ void navigator.clipboard.writeText(saved.text);
127
+ restoreField();
35
128
  }
36
129
 
37
130
  async function pasteSelection() {
38
- const text = await navigator.clipboard.readText();
39
- const active = document.activeElement;
40
- if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
41
- const start = active.selectionStart ?? active.value.length;
42
- const end = active.selectionEnd ?? start;
43
- active.setRangeText(text, start, end, "end");
44
- active.dispatchEvent(new Event("input", { bubbles: true }));
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
- document.execCommand("insertText", false, text);
139
+ if (!text || !saved.field) return;
140
+ writeField(text, saved.start + text.length);
48
141
  }
49
142
 
50
143
  function selectAll() {
51
- const active = document.activeElement;
52
- if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
53
- active.select();
54
- return;
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 className={cn(popupClass, className)} {...props}>
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({ className, children, ...props }: ComponentProps<typeof BaseDialog.Popup>) {
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 className={cn("fixed inset-0 z-[var(--z-overlay)] bg-transparent", backdropMotion)} />
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-[calc(0.75rem+var(--safe-left))]">
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,translate,border-radius,background-color,border-color,box-shadow] duration-[calc(var(--motion-duration)*4)] ease-[var(--motion-ease)]",
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%-max(0.75rem,var(--safe-left))-max(0.75rem,var(--safe-right))))] translate-y-[calc(var(--safe-top)+0.75rem)] rounded-full px-2 shadow-[var(--shadow-md)]"
39
- : "before:pointer-events-auto w-full translate-y-[var(--safe-top)] border-transparent bg-transparent pl-[max(0.75rem,var(--safe-left))] pr-[max(0.75rem,var(--safe-right))] before:absolute before:inset-x-0 before:bottom-full before:h-[var(--safe-top)] before:content-['']",
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>
@@ -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}
@@ -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-[calc(var(--safe-top)+0.75rem)]",
22
- right: "right-[max(0.75rem,var(--safe-right))]",
23
- bottom: "bottom-2",
24
- left: "left-[max(0.75rem,var(--safe-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.5rem)] pb-2 pl-[var(--safe-left)] transition-[width] duration-[var(--motion-duration)] ease-[var(--motion-ease)] md:flex",
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
- <nav
156
- className={cn(
157
- "bg-bg-elevated/70 border-border fixed right-[max(0.75rem,var(--safe-right))] bottom-[max(0.5rem,var(--safe-bottom))] left-[max(0.75rem,var(--safe-left))] z-[var(--z-sticky)] rounded-[var(--radius-lg)] border p-1 shadow-[var(--shadow-md)] backdrop-blur-xl md:hidden",
158
- className,
159
- )}
160
- {...props}
161
- >
162
- <div className="relative flex items-stretch justify-around">
163
- <Glide selector={ACTIVE} className="bg-accent-muted/60 rounded-[var(--radius-md)]" />
164
- {children}
165
- </div>
166
- </nav>
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: "1.5rem",
17
- right: "1.5rem",
16
+ bottom: "var(--toast-bottom)",
17
+ right: "max(1.5rem, var(--safe-right))",
18
18
  }}
19
19
  mobileOffset={{
20
- bottom: "5rem",
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%;