@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preset.nz/ux-kit",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "One look and one way of working for the preset.nz desktop apps: tokens, palettes, fonts and Base UI primitives.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- /** Present makes the item a dropdown; picking an entry calls `onCommand(entry.id)`. */
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" | "pressed"> {
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 = 4,
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"