@hogoshi/ui 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
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
  ];
@@ -80,16 +167,36 @@ function editEntries(): ContextMenuEntry[] {
80
167
  export const ContextMenuRoot = BaseContextMenu.Root;
81
168
  export const ContextMenuTrigger = BaseContextMenu.Trigger;
82
169
 
83
- export function ContextMenuContent({ className, children, ...props }: ComponentProps<typeof BaseContextMenu.Popup>) {
170
+ export function ContextMenuContent({
171
+ edit = true,
172
+ className,
173
+ children,
174
+ ...props
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
+
84
187
  return (
85
188
  <BaseContextMenu.Portal>
86
189
  <BaseContextMenu.Positioner className="z-[var(--z-popover)]">
87
- <BaseContextMenu.Popup className={cn(popupClass, className)} {...props}>
190
+ <BaseContextMenu.Popup
191
+ className={cn(popupClass, className)}
192
+ onMouseDown={(event) => event.preventDefault()}
193
+ {...props}
194
+ >
88
195
  <Glide selector={HIGHLIGHTED} />
89
- <EntryList items={editEntries()} />
196
+ {edit ? <EntryList items={editEntries(canPaste)} /> : null}
90
197
  {children ? (
91
198
  <>
92
- <Separator className="my-1" />
199
+ {edit ? <Separator className="my-1" /> : null}
93
200
  {children}
94
201
  </>
95
202
  ) : null}
@@ -113,8 +220,9 @@ function EntryList({ items }: { items: ContextMenuEntry[] }) {
113
220
  ) : (
114
221
  <ContextMenuItem
115
222
  key={item.id}
223
+ disabled={item.disabled}
116
224
  className={item.destructive ? "text-danger data-[highlighted]:text-danger" : undefined}
117
- onClick={item.onSelect}
225
+ onClick={item.disabled ? undefined : item.onSelect}
118
226
  >
119
227
  {item.icon ? <Icon icon={item.icon} variant="stroke" size={16} /> : null}
120
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%-max(0.75rem,var(--safe-left))-max(0.75rem,var(--safe-right))))] rounded-full px-2 shadow-[var(--shadow-md)]"
41
+ : "w-full border-transparent bg-transparent pl-[max(0.75rem,var(--safe-left))] pr-[max(0.75rem,var(--safe-right))]",
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}
@@ -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";
@@ -152,18 +153,21 @@ 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
+ className={cn(
160
+ "bg-bg-elevated/70 border-border fixed right-[max(0.75rem,var(--safe-right))] bottom-[var(--bottombar-offset)] 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",
161
+ className,
162
+ )}
163
+ {...props}
164
+ >
165
+ <div className="relative flex items-stretch justify-around">
166
+ <Glide selector={ACTIVE} className="bg-accent-muted/60 rounded-[var(--radius-md)]" />
167
+ {children}
168
+ </div>
169
+ </nav>
170
+ </>
167
171
  );
168
172
  }
169
173
 
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ 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";
45
46
  export { Glide, glideActive } from "./components/glide";
46
47
  export type { GlideProps } from "./components/glide";
47
48
  export { Calendar } from "./components/calendar";
package/src/styles.css CHANGED
@@ -77,6 +77,34 @@ 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
+ --topbar-offset: calc(var(--safe-top) + 0.75rem);
82
+ --topbar-space: calc(var(--topbar-offset) + 3.75rem);
83
+ --bottombar-offset: max(0.5rem, var(--safe-bottom));
84
+ --bottombar-space: calc(var(--bottombar-offset) + 4.25rem);
85
+ }
86
+
87
+ @media (display-mode: standalone) {
88
+ :root {
89
+ --app-height: 100vh;
90
+ }
91
+ }
92
+
93
+ html,
94
+ body {
95
+ min-height: var(--app-height);
96
+ }
97
+
98
+ @utility h-app {
99
+ height: var(--app-height);
100
+ }
101
+
102
+ @utility min-h-app {
103
+ min-height: var(--app-height);
104
+ }
105
+
106
+ @utility max-h-app {
107
+ max-height: var(--app-height);
80
108
  }
81
109
 
82
110
  html {