@preset.nz/ux-kit 0.1.2 → 0.1.3
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/path-field.tsx +308 -0
- package/src/components/toolbar.tsx +15 -3
- package/src/components/tooltip.tsx +3 -2
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { CaretDownIcon, FilePlusIcon } from "@phosphor-icons/react"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/utils"
|
|
5
|
+
import { Button } from "./button"
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from "./dropdown-menu"
|
|
12
|
+
import { Input } from "./input"
|
|
13
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
14
|
+
|
|
15
|
+
// PathField: label, a text field for a file path, then a ▾ list of recent paths and a browse icon,
|
|
16
|
+
// both bare. The kit knows nothing of Tauri or the file system: the native dialog, the recent list
|
|
17
|
+
// and the Missing check arrive through props (app-kit supplies them). Paths are shown exactly as
|
|
18
|
+
// written, relative or absolute; the field never rewrites one. Text commits on Return or blur,
|
|
19
|
+
// never per keystroke; Esc reverts. A refused commit (status "invalid") keeps the typed draft.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The DOM event a path field listens for on its root: a `CustomEvent` with `detail: { path }`,
|
|
23
|
+
* sent by whatever turns an OS file drop into DOM (app-kit's `installPathDrop`). The kit knows
|
|
24
|
+
* nothing of Tauri, only this name. app-kit/core cannot import it, so it repeats the string.
|
|
25
|
+
*/
|
|
26
|
+
const PATH_DROP_EVENT = "path-drop"
|
|
27
|
+
/** Set on the field's root, by the same code, while a drag is over it; the field draws a ring. */
|
|
28
|
+
const PATH_DROP_TARGET_ATTRIBUTE = "data-drop-target"
|
|
29
|
+
|
|
30
|
+
type PathFieldStatus = "ok" | "missing" | "invalid"
|
|
31
|
+
|
|
32
|
+
interface PathFieldProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
|
|
33
|
+
/** The path as written, relative or absolute. `null` is empty. */
|
|
34
|
+
value: string | null
|
|
35
|
+
/** Return or blur with changed text: the trimmed text, or null when empty. Missing makes the field read-only. */
|
|
36
|
+
onCommit?: (path: string | null) => void
|
|
37
|
+
/** Recent paths, newest first, for the ▾ list. Empty disables the ▾. */
|
|
38
|
+
recent?: string[]
|
|
39
|
+
/** Opens the native file dialog. A resolved path is committed; null (cancelled) does nothing. Missing hides the icon. */
|
|
40
|
+
onBrowse?: () => Promise<string | null>
|
|
41
|
+
/** "missing": the path in the warning tone with a "Missing" tag. "invalid": the field is marked and `message` shown. */
|
|
42
|
+
status?: PathFieldStatus
|
|
43
|
+
/** Why the path was refused; shown below the field when `status` is "invalid". */
|
|
44
|
+
message?: string
|
|
45
|
+
label?: string
|
|
46
|
+
placeholder?: string
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Splits a path at its last separator into the file name and the folder (with no trailing slash). */
|
|
51
|
+
function splitPath(path: string): { name: string; folder: string } {
|
|
52
|
+
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
|
|
53
|
+
if (i < 0) return { name: path, folder: "" }
|
|
54
|
+
return { name: path.slice(i + 1) || path, folder: path.slice(0, i) || path.slice(i, i + 1) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A bare icon button with a tooltip. A disabled button gets a span trigger so the tooltip still shows. */
|
|
58
|
+
function IconButton({
|
|
59
|
+
label,
|
|
60
|
+
disabled,
|
|
61
|
+
children,
|
|
62
|
+
...props
|
|
63
|
+
}: Omit<React.ComponentProps<typeof Button>, "aria-label"> & { label: string }) {
|
|
64
|
+
const button = (
|
|
65
|
+
<Button
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="icon-xs"
|
|
69
|
+
aria-label={label}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
className="text-muted-foreground"
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{children}
|
|
75
|
+
</Button>
|
|
76
|
+
)
|
|
77
|
+
return (
|
|
78
|
+
<Tooltip>
|
|
79
|
+
{disabled ? (
|
|
80
|
+
<TooltipTrigger render={<span className="inline-flex" />}>{button}</TooltipTrigger>
|
|
81
|
+
) : (
|
|
82
|
+
<TooltipTrigger render={button} />
|
|
83
|
+
)}
|
|
84
|
+
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
85
|
+
</Tooltip>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function PathField({
|
|
90
|
+
value,
|
|
91
|
+
onCommit,
|
|
92
|
+
recent,
|
|
93
|
+
onBrowse,
|
|
94
|
+
status = "ok",
|
|
95
|
+
message,
|
|
96
|
+
label,
|
|
97
|
+
placeholder,
|
|
98
|
+
disabled,
|
|
99
|
+
id,
|
|
100
|
+
className,
|
|
101
|
+
ref,
|
|
102
|
+
...props
|
|
103
|
+
}: PathFieldProps) {
|
|
104
|
+
const generatedId = React.useId()
|
|
105
|
+
const inputId = id ?? generatedId
|
|
106
|
+
const noteId = `${inputId}-note`
|
|
107
|
+
const locked = !onCommit
|
|
108
|
+
const inert = locked || !!disabled
|
|
109
|
+
|
|
110
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
111
|
+
// The typed text while it differs from `value`. It outlives a commit the app refused ("invalid")
|
|
112
|
+
// and is dropped when `value` changes, on Esc, or on a blur that changed nothing.
|
|
113
|
+
const [draft, setDraft] = React.useState<string | null>(null)
|
|
114
|
+
// The text last sent to onCommit, so Return then blur does not send it twice.
|
|
115
|
+
const sent = React.useRef<string | null>(null)
|
|
116
|
+
|
|
117
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a new value is the app accepting (or replacing) the text
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
setDraft(null)
|
|
120
|
+
sent.current = null
|
|
121
|
+
}, [value])
|
|
122
|
+
|
|
123
|
+
// At rest the input shows the end of a long path (the file name), not the start.
|
|
124
|
+
const showEnd = React.useCallback(() => {
|
|
125
|
+
const el = inputRef.current
|
|
126
|
+
if (el && document.activeElement !== el) el.scrollLeft = el.scrollWidth
|
|
127
|
+
}, [])
|
|
128
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the shown text changes
|
|
129
|
+
React.useLayoutEffect(showEnd, [showEnd, value, draft])
|
|
130
|
+
|
|
131
|
+
const commit = () => {
|
|
132
|
+
if (draft === null) return
|
|
133
|
+
const text = draft.trim()
|
|
134
|
+
if (text === (value ?? "")) {
|
|
135
|
+
setDraft(null)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (text === sent.current) return
|
|
139
|
+
sent.current = text
|
|
140
|
+
onCommit?.(text === "" ? null : text)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const choose = (path: string) => {
|
|
144
|
+
setDraft(null)
|
|
145
|
+
onCommit?.(path)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A file dropped on the field commits like a pick, unless the field is read-only or disabled.
|
|
149
|
+
// The listener is on the root, which the host owns, so it reads the latest `choose` from a ref.
|
|
150
|
+
const dropped = React.useRef<((path: string) => void) | null>(null)
|
|
151
|
+
dropped.current = inert ? null : choose
|
|
152
|
+
const rootRef = React.useCallback(
|
|
153
|
+
(el: HTMLDivElement | null) => {
|
|
154
|
+
const setRef = (value: HTMLDivElement | null) => {
|
|
155
|
+
if (typeof ref === "function") ref(value)
|
|
156
|
+
else if (ref) ref.current = value
|
|
157
|
+
}
|
|
158
|
+
setRef(el)
|
|
159
|
+
if (!el) return
|
|
160
|
+
const onDrop = (e: Event) => {
|
|
161
|
+
const path = (e as CustomEvent<{ path?: unknown }>).detail?.path
|
|
162
|
+
if (typeof path === "string" && path !== "") dropped.current?.(path)
|
|
163
|
+
}
|
|
164
|
+
el.addEventListener(PATH_DROP_EVENT, onDrop)
|
|
165
|
+
return () => {
|
|
166
|
+
el.removeEventListener(PATH_DROP_EVENT, onDrop)
|
|
167
|
+
setRef(null)
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[ref],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const browse = async () => {
|
|
174
|
+
const picked = await onBrowse?.()
|
|
175
|
+
if (picked != null) choose(picked)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const missing = status === "missing" && draft === null && !!value
|
|
179
|
+
const invalid = status === "invalid"
|
|
180
|
+
const note = invalid && message ? message : null
|
|
181
|
+
const hasRecent = !!recent?.length
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
ref={rootRef}
|
|
186
|
+
data-slot="path-field"
|
|
187
|
+
className={cn("group/path-field flex flex-col gap-1", className)}
|
|
188
|
+
{...props}
|
|
189
|
+
>
|
|
190
|
+
<div className={cn("flex items-center gap-1", disabled && "opacity-50")}>
|
|
191
|
+
{label ? (
|
|
192
|
+
<label htmlFor={inputId} className="mr-1 shrink-0 text-xs text-muted-foreground">
|
|
193
|
+
{label}
|
|
194
|
+
</label>
|
|
195
|
+
) : null}
|
|
196
|
+
<div
|
|
197
|
+
className={cn(
|
|
198
|
+
"relative min-w-0 flex-1",
|
|
199
|
+
!inert &&
|
|
200
|
+
"group-data-[drop-target]/path-field:ring-2 group-data-[drop-target]/path-field:ring-ring",
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
<Input
|
|
204
|
+
ref={inputRef}
|
|
205
|
+
id={inputId}
|
|
206
|
+
value={draft ?? value ?? ""}
|
|
207
|
+
placeholder={placeholder}
|
|
208
|
+
readOnly={locked}
|
|
209
|
+
disabled={disabled}
|
|
210
|
+
spellCheck={false}
|
|
211
|
+
aria-invalid={invalid || undefined}
|
|
212
|
+
aria-describedby={note || missing ? noteId : undefined}
|
|
213
|
+
onChange={(e) => {
|
|
214
|
+
sent.current = null
|
|
215
|
+
setDraft(e.target.value)
|
|
216
|
+
}}
|
|
217
|
+
onBlur={() => {
|
|
218
|
+
commit()
|
|
219
|
+
showEnd()
|
|
220
|
+
}}
|
|
221
|
+
onKeyDown={(e) => {
|
|
222
|
+
if (e.key === "Enter") commit()
|
|
223
|
+
if (e.key === "Escape" && draft !== null) {
|
|
224
|
+
e.stopPropagation()
|
|
225
|
+
sent.current = null
|
|
226
|
+
setDraft(null)
|
|
227
|
+
}
|
|
228
|
+
}}
|
|
229
|
+
className={cn("font-mono", missing && "pr-16 text-destructive")}
|
|
230
|
+
/>
|
|
231
|
+
{missing ? (
|
|
232
|
+
<span
|
|
233
|
+
id={noteId}
|
|
234
|
+
className="pointer-events-none absolute inset-y-0 right-2 flex items-center bg-background pl-1 text-[11px] font-medium text-destructive dark:bg-transparent"
|
|
235
|
+
>
|
|
236
|
+
Missing
|
|
237
|
+
</span>
|
|
238
|
+
) : null}
|
|
239
|
+
</div>
|
|
240
|
+
<DropdownMenu>
|
|
241
|
+
<Tooltip>
|
|
242
|
+
{hasRecent && !inert ? (
|
|
243
|
+
<TooltipTrigger
|
|
244
|
+
render={
|
|
245
|
+
<DropdownMenuTrigger
|
|
246
|
+
render={
|
|
247
|
+
<Button
|
|
248
|
+
type="button"
|
|
249
|
+
variant="ghost"
|
|
250
|
+
size="icon-xs"
|
|
251
|
+
aria-label="Recent"
|
|
252
|
+
className="text-muted-foreground"
|
|
253
|
+
/>
|
|
254
|
+
}
|
|
255
|
+
/>
|
|
256
|
+
}
|
|
257
|
+
>
|
|
258
|
+
<CaretDownIcon />
|
|
259
|
+
</TooltipTrigger>
|
|
260
|
+
) : (
|
|
261
|
+
<TooltipTrigger render={<span className="inline-flex" />}>
|
|
262
|
+
<Button
|
|
263
|
+
type="button"
|
|
264
|
+
variant="ghost"
|
|
265
|
+
size="icon-xs"
|
|
266
|
+
aria-label="Recent"
|
|
267
|
+
disabled
|
|
268
|
+
className="text-muted-foreground"
|
|
269
|
+
>
|
|
270
|
+
<CaretDownIcon />
|
|
271
|
+
</Button>
|
|
272
|
+
</TooltipTrigger>
|
|
273
|
+
)}
|
|
274
|
+
<TooltipContent side="bottom">
|
|
275
|
+
{hasRecent ? "Recent" : "No recent files"}
|
|
276
|
+
</TooltipContent>
|
|
277
|
+
</Tooltip>
|
|
278
|
+
<DropdownMenuContent align="end" className="max-w-96">
|
|
279
|
+
{recent?.map((path) => {
|
|
280
|
+
const { name, folder } = splitPath(path)
|
|
281
|
+
return (
|
|
282
|
+
<DropdownMenuItem key={path} onClick={() => choose(path)} title={path}>
|
|
283
|
+
<span className="shrink-0 font-medium">{name}</span>
|
|
284
|
+
{folder ? (
|
|
285
|
+
<span className="min-w-0 truncate text-xs text-muted-foreground">{folder}</span>
|
|
286
|
+
) : null}
|
|
287
|
+
</DropdownMenuItem>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
</DropdownMenuContent>
|
|
291
|
+
</DropdownMenu>
|
|
292
|
+
{onBrowse ? (
|
|
293
|
+
<IconButton label="Choose File…" disabled={inert} onClick={browse}>
|
|
294
|
+
<FilePlusIcon />
|
|
295
|
+
</IconButton>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
{note ? (
|
|
299
|
+
<p id={noteId} className="text-xs text-destructive">
|
|
300
|
+
{note}
|
|
301
|
+
</p>
|
|
302
|
+
) : null}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export type { PathFieldProps, PathFieldStatus }
|
|
308
|
+
export { PATH_DROP_EVENT, PATH_DROP_TARGET_ATTRIBUTE, PathField, splitPath }
|
|
@@ -33,6 +33,10 @@ interface ToolbarMenuEntry {
|
|
|
33
33
|
shortcut?: string
|
|
34
34
|
/** Default true. */
|
|
35
35
|
enabled?: boolean
|
|
36
|
+
/** Drawn before the label: a tool flyout's entries are tools with icons. */
|
|
37
|
+
icon?: React.ReactNode
|
|
38
|
+
/** The active one of a tool flyout's entries. */
|
|
39
|
+
pressed?: boolean
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** One toolbar item. `id` is the command id shared with the menu. */
|
|
@@ -48,7 +52,11 @@ interface ToolbarItemSpec {
|
|
|
48
52
|
disabledReason?: string
|
|
49
53
|
/** Present (true or false) makes the item a toggle. Absent means a plain button. */
|
|
50
54
|
pressed?: boolean
|
|
51
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. With
|
|
57
|
+
* `pressed` too it is a tool flyout: several tools behind one button, pressed while one of
|
|
58
|
+
* them is active.
|
|
59
|
+
*/
|
|
52
60
|
menu?: ToolbarMenuEntry[]
|
|
53
61
|
}
|
|
54
62
|
|
|
@@ -167,7 +175,7 @@ function ToolbarButton({
|
|
|
167
175
|
)
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu"
|
|
178
|
+
interface ToolbarDropdownProps extends Omit<ToolbarItemSpec, "id" | "menu"> {
|
|
171
179
|
entries: ToolbarMenuEntry[]
|
|
172
180
|
onCommand?: (id: string) => void
|
|
173
181
|
}
|
|
@@ -179,6 +187,7 @@ function ToolbarDropdown({
|
|
|
179
187
|
shortcut,
|
|
180
188
|
enabled = true,
|
|
181
189
|
disabledReason,
|
|
190
|
+
pressed,
|
|
182
191
|
entries,
|
|
183
192
|
onCommand,
|
|
184
193
|
}: ToolbarDropdownProps) {
|
|
@@ -192,8 +201,9 @@ function ToolbarDropdown({
|
|
|
192
201
|
<ToolbarPrimitive.Button
|
|
193
202
|
data-slot="toolbar-dropdown"
|
|
194
203
|
aria-label={label}
|
|
204
|
+
aria-pressed={pressed}
|
|
195
205
|
disabled={!enabled}
|
|
196
|
-
className={buttonClass()}
|
|
206
|
+
className={buttonClass(pressed)}
|
|
197
207
|
/>
|
|
198
208
|
}
|
|
199
209
|
/>
|
|
@@ -215,7 +225,9 @@ function ToolbarDropdown({
|
|
|
215
225
|
key={e.id}
|
|
216
226
|
disabled={e.enabled === false}
|
|
217
227
|
onClick={() => onCommand?.(e.id)}
|
|
228
|
+
className={cn(e.pressed && "bg-muted")}
|
|
218
229
|
>
|
|
230
|
+
{e.icon}
|
|
219
231
|
{e.label}
|
|
220
232
|
{e.shortcut ? <DropdownMenuShortcut>{e.shortcut}</DropdownMenuShortcut> : null}
|
|
221
233
|
</DropdownMenuItem>
|
|
@@ -16,10 +16,12 @@ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
|
|
16
16
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// No arrow: a tooltip's diamond sat over the control being clicked (Georg, 2026-10-28), so the
|
|
20
|
+
// popup stands a little clear of the trigger instead.
|
|
19
21
|
function TooltipContent({
|
|
20
22
|
className,
|
|
21
23
|
side = "top",
|
|
22
|
-
sideOffset =
|
|
24
|
+
sideOffset = 6,
|
|
23
25
|
align = "center",
|
|
24
26
|
alignOffset = 0,
|
|
25
27
|
children,
|
|
@@ -44,7 +46,6 @@ function TooltipContent({
|
|
|
44
46
|
{...props}
|
|
45
47
|
>
|
|
46
48
|
{children}
|
|
47
|
-
<TooltipPrimitive.Arrow className="z-50 size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
|
48
49
|
</TooltipPrimitive.Popup>
|
|
49
50
|
</TooltipPrimitive.Positioner>
|
|
50
51
|
</TooltipPrimitive.Portal>
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from "./components/label"
|
|
|
17
17
|
export * from "./components/list"
|
|
18
18
|
export * from "./components/navigation-menu"
|
|
19
19
|
export * from "./components/number-field"
|
|
20
|
+
export * from "./components/path-field"
|
|
20
21
|
export * from "./components/popover"
|
|
21
22
|
export * from "./components/progress"
|
|
22
23
|
export * from "./components/select"
|