@vvedantb/ui 0.4.1 → 0.4.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/README.md +20 -0
- package/app/src/settings/SettingsField.tsx +30 -2
- package/package.json +1 -1
- package/src/composer/input-area/InputArea.tsx +6 -10
- package/src/composer/input-area/InputAreaEditor.tsx +170 -27
- package/src/composer/input-area/useComposerCompact.ts +2 -4
- package/src/index.ts +2 -0
- package/src/ui/confirm-dialog.tsx +45 -0
- package/src/ui/slide-over-panel.tsx +65 -0
- package/src/ui/toolbar-search-input.tsx +80 -0
package/README.md
CHANGED
|
@@ -194,6 +194,26 @@ import { SessionsHoverCard } from "@vvedantb/ui";
|
|
|
194
194
|
|
|
195
195
|
`children` is the trigger. It is rendered with `asChild` by default, so it keeps its own handlers (such as a context menu). Extra props and the ref go to it. Use `asChild={false}` for a default trigger element. The content portals to `document.body` by default, so `overflow:hidden` ancestors do not clip it. Pass `portal={false}` to opt out. `surface` is `"overlay"` (default), `"surface"` (`rounded-surface`) or `"glass"` (`glass-panel-strong`, `rounded-surface`). `className` merges over the surface without `!`. Defaults: `openDelay` 400 ms, `closeDelay` 150 ms, `side="right"`, `align="start"`, `sideOffset` 8, width and padding `w-64 p-3` (override with `className`). `body` is a node or a function of `{ open }`. `loadContent` runs only while the card is open. Control the state with `open`, `defaultOpen` and `onOpenChange`. The card opens on pointer hover and on keyboard focus (`:focus-visible`) only. Focus that follows a click or a context menu close does not open it. If pointer-driven focus left it stuck open, pointer leave force-closes it; otherwise Radix handles close (including moving onto the content). Fixture: `shell/examples/sessions-hover-card/`.
|
|
196
196
|
|
|
197
|
+
## Panels, search and confirm
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import { ConfirmDialog, SlideOverPanel, ToolbarSearchInput } from "@vvedantb/ui";
|
|
201
|
+
|
|
202
|
+
<div className="relative">
|
|
203
|
+
<SlideOverPanel open={open} onClose={() => setOpen(false)} header={<h2>Details</h2>}>
|
|
204
|
+
<div className="p-4">Body</div>
|
|
205
|
+
</SlideOverPanel>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<ToolbarSearchInput value={query} onChange={setQuery} />
|
|
209
|
+
|
|
210
|
+
<ConfirmDialog size="sm" open={open} onOpenChange={setOpen} title="Delete item?" variant="destructive" onConfirm={remove} />
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- `SlideOverPanel`: a right-side panel, absolutely positioned inside its nearest positioned parent. It is not a portal or dialog. It slides in with a spring and is hidden below `md`. Only the close button closes it (no Escape, no outside click). `width` is `"w-96"` (default) or `"w-80"`. `closeLabel` defaults to `"Close panel"`. `className` merges last.
|
|
214
|
+
- `ToolbarSearchInput`: a compact `h-8` search field for toolbars, with a search icon and a clear button. The clear button stays mounted and is hidden while the value is blank. Clearing calls `onChange("")` and does not move focus. Defaults: `placeholder="Search..."`, `label="Search"` (the `aria-label`), `clearLabel="Clear search"`. The ref goes to the `<input>`.
|
|
215
|
+
- `ConfirmDialog` `size`: `"default"` keeps the alert dialog. `"sm"` renders a compact, dismissible dialog (outside click, Escape and the close button close it) with small Cancel and confirm buttons. Cancel calls `onCancel` and then `onOpenChange(false)`. Confirm calls `onConfirm` only, so the caller closes it.
|
|
216
|
+
|
|
197
217
|
## Toaster
|
|
198
218
|
|
|
199
219
|
```tsx
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
interface SettingsFieldProps {
|
|
4
|
-
/** Field name, shown above the control. */
|
|
4
|
+
/** Field name, shown above the control (stacked) or left of it (horizontal). */
|
|
5
5
|
label: ReactNode;
|
|
6
|
-
/** Help text shown under the control. */
|
|
6
|
+
/** Help text, shown under the control (stacked) or under the label (horizontal). */
|
|
7
7
|
description?: ReactNode;
|
|
8
8
|
/** `id` of the control, so clicking the label focuses it. */
|
|
9
9
|
htmlFor?: string;
|
|
10
|
+
/** `"stacked"` (default) or `"horizontal"`. */
|
|
11
|
+
layout?: "stacked" | "horizontal";
|
|
10
12
|
children: ReactNode;
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -16,13 +18,39 @@ interface SettingsFieldProps {
|
|
|
16
18
|
* Every settings form repeats the same label / control / help-text stack, so it
|
|
17
19
|
* lives here rather than being re-spaced per field. Stack several inside a
|
|
18
20
|
* section body with `grid gap-5`. Shared across apps.
|
|
21
|
+
*
|
|
22
|
+
* - `layout="stacked"` (default): label above the control, help text below it.
|
|
23
|
+
* - `layout="horizontal"`: one row with the label and help text on the left and
|
|
24
|
+
* the control on the right, for compact controls such as a select. The label
|
|
25
|
+
* is a `<label>` when `htmlFor` is set, otherwise a `<p>`.
|
|
19
26
|
*/
|
|
20
27
|
export function SettingsField({
|
|
21
28
|
label,
|
|
22
29
|
description,
|
|
23
30
|
htmlFor,
|
|
31
|
+
layout = "stacked",
|
|
24
32
|
children,
|
|
25
33
|
}: SettingsFieldProps) {
|
|
34
|
+
if (layout === "horizontal") {
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex items-center justify-between gap-3">
|
|
37
|
+
<div className="min-w-0">
|
|
38
|
+
{htmlFor ? (
|
|
39
|
+
<label htmlFor={htmlFor} className="block text-sm font-medium text-foreground">
|
|
40
|
+
{label}
|
|
41
|
+
</label>
|
|
42
|
+
) : (
|
|
43
|
+
<p className="text-sm font-medium text-foreground">{label}</p>
|
|
44
|
+
)}
|
|
45
|
+
{description ? (
|
|
46
|
+
<p className="mt-1 text-xs text-muted-foreground text-pretty">{description}</p>
|
|
47
|
+
) : null}
|
|
48
|
+
</div>
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
return (
|
|
27
55
|
<div>
|
|
28
56
|
<label
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React 19 UI for web apps: one JavaScript entry (primitives, shell pieces, app helpers, native tokens), design-token CSS, shared Tailwind v4 CSS and a Tailwind preset.",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { SpeechInput } from "../../ai-elements/speech-input";
|
|
23
23
|
import { chatInputContainerClass } from "../../ui/surface-classes";
|
|
24
24
|
import { ComposerSlashMenu } from "./ComposerSlashMenu";
|
|
25
|
-
import { InputAreaEditor } from "./InputAreaEditor";
|
|
25
|
+
import { InputAreaEditor, placeCaretAtEnd } from "./InputAreaEditor";
|
|
26
26
|
import { FadePresence } from "./FadePresence";
|
|
27
27
|
import { InputActions, type InputActionsProps } from "./InputActions";
|
|
28
28
|
import { motionFast } from "./motion";
|
|
@@ -33,8 +33,11 @@ import { useInputAreaPrediction } from "./useInputAreaPrediction";
|
|
|
33
33
|
|
|
34
34
|
const EXPANDED_EDITOR =
|
|
35
35
|
"min-h-16 max-h-50 w-full self-stretch overflow-y-auto rounded-none px-4 pt-3.5 pb-1 text-left";
|
|
36
|
+
// Block, not flex: chips and text must share one inline flow, or a click lands
|
|
37
|
+
// the caret inside a chip. The 36px box (py-2 + leading-5) centres the line, and
|
|
38
|
+
// `align-top` keeps chips level with the text as the old flex row did.
|
|
36
39
|
const COMPACT_EDITOR =
|
|
37
|
-
"
|
|
40
|
+
"block min-h-9 max-h-9 min-w-0 w-auto overflow-hidden whitespace-pre! rounded-none px-1 py-2 text-left leading-5 [&>[data-composer-chips-inline]>*]:align-top";
|
|
38
41
|
const COMPACT_START_ADDON =
|
|
39
42
|
"order-first shrink-0 justify-start gap-1 py-0 pl-1.5 pr-0 has-[>button]:ml-0";
|
|
40
43
|
const COMPACT_END_ADDON =
|
|
@@ -252,14 +255,7 @@ export function InputArea({
|
|
|
252
255
|
const length = editor.value.length;
|
|
253
256
|
editor.setSelectionRange(length, length);
|
|
254
257
|
} else {
|
|
255
|
-
|
|
256
|
-
if (sel) {
|
|
257
|
-
const range = document.createRange();
|
|
258
|
-
range.selectNodeContents(editor);
|
|
259
|
-
range.collapse(false);
|
|
260
|
-
sel.removeAllRanges();
|
|
261
|
-
sel.addRange(range);
|
|
262
|
-
}
|
|
258
|
+
placeCaretAtEnd(editor);
|
|
263
259
|
}
|
|
264
260
|
}
|
|
265
261
|
};
|
|
@@ -28,6 +28,8 @@ export const INLINE_EDITOR_CLASS =
|
|
|
28
28
|
// the text in a column beside it.
|
|
29
29
|
const CHIPS_HOST_CLASS = "contents empty:hidden [&>*]:mr-1.5";
|
|
30
30
|
|
|
31
|
+
const ZERO_WIDTH_SPACE = "\u200b";
|
|
32
|
+
|
|
31
33
|
/** Reads the plain text of the editor. Skips the chips host. */
|
|
32
34
|
export function extractEditorText(root: HTMLElement): string {
|
|
33
35
|
let out = "";
|
|
@@ -50,7 +52,7 @@ export function extractEditorText(root: HTMLElement): string {
|
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
53
|
-
out += (child.textContent ?? "").
|
|
55
|
+
out += (child.textContent ?? "").replaceAll(ZERO_WIDTH_SPACE, "");
|
|
54
56
|
}
|
|
55
57
|
});
|
|
56
58
|
};
|
|
@@ -58,14 +60,105 @@ export function extractEditorText(root: HTMLElement): string {
|
|
|
58
60
|
return out;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
function
|
|
63
|
+
function chipsHost(root: HTMLElement): HTMLElement | null {
|
|
64
|
+
for (const child of root.children) {
|
|
65
|
+
if (child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR)) return child;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns the text node right after the chips host, creating it if missing.
|
|
72
|
+
* It starts with U+200B, so the caret always has an editable position after
|
|
73
|
+
* the chips, even with an empty draft. `extractEditorText` strips U+200B.
|
|
74
|
+
*/
|
|
75
|
+
function ensureCaretAnchor(root: HTMLElement): Text {
|
|
76
|
+
const existing = findCaretAnchor(root);
|
|
77
|
+
if (existing) return existing;
|
|
78
|
+
const host = chipsHost(root);
|
|
79
|
+
const anchor = document.createTextNode(ZERO_WIDTH_SPACE);
|
|
80
|
+
root.insertBefore(anchor, host ? host.nextSibling : root.firstChild);
|
|
81
|
+
return anchor;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findCaretAnchor(root: HTMLElement): Text | null {
|
|
85
|
+
const host = chipsHost(root);
|
|
86
|
+
const next = host ? host.nextSibling : root.firstChild;
|
|
87
|
+
return next instanceof Text && next.data.startsWith(ZERO_WIDTH_SPACE) ? next : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setSelection(
|
|
91
|
+
anchorNode: Node,
|
|
92
|
+
anchorOffset: number,
|
|
93
|
+
focusNode: Node = anchorNode,
|
|
94
|
+
focusOffset: number = anchorOffset,
|
|
95
|
+
) {
|
|
96
|
+
window.getSelection()?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** End of the text: the last text node, or the end of the editor after a line break. */
|
|
100
|
+
function textEnd(root: HTMLElement): [Node, number] {
|
|
101
|
+
ensureCaretAnchor(root);
|
|
102
|
+
const last = root.lastChild;
|
|
103
|
+
if (last instanceof Text) return [last, last.length];
|
|
104
|
+
return [root, root.childNodes.length];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Puts a collapsed caret at the end of the text, after the chips host. */
|
|
108
|
+
export function placeCaretAtEnd(root: HTMLElement) {
|
|
109
|
+
const [node, offset] = textEnd(root);
|
|
110
|
+
setSelection(node, offset);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Moves any selection end that sits inside or before the chips host to the
|
|
115
|
+
* first text position after it. The host is React's, so the caret must never
|
|
116
|
+
* rest in it and an edit must never delete it.
|
|
117
|
+
*/
|
|
118
|
+
export function normaliseSelection(root: HTMLElement) {
|
|
62
119
|
const sel = window.getSelection();
|
|
63
|
-
if (!sel) return;
|
|
64
|
-
const range =
|
|
65
|
-
range
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
120
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
121
|
+
const range = sel.getRangeAt(0);
|
|
122
|
+
const { startContainer, startOffset, endContainer, endOffset } = range;
|
|
123
|
+
if (!root.contains(startContainer) || !root.contains(endContainer)) return;
|
|
124
|
+
const anchor = ensureCaretAnchor(root);
|
|
125
|
+
const first = document.createRange();
|
|
126
|
+
first.setStart(anchor, 1);
|
|
127
|
+
// The host comes first, so only the start can be before the text unless both are.
|
|
128
|
+
if (first.comparePoint(startContainer, startOffset) >= 0) return;
|
|
129
|
+
const endBefore = first.comparePoint(endContainer, endOffset) < 0;
|
|
130
|
+
const [end, endAt]: [Node, number] = endBefore ? [anchor, 1] : [endContainer, endOffset];
|
|
131
|
+
const backwards =
|
|
132
|
+
!range.collapsed &&
|
|
133
|
+
sel.anchorNode === endContainer &&
|
|
134
|
+
sel.anchorOffset === endOffset;
|
|
135
|
+
if (backwards) setSelection(end, endAt, anchor, 1);
|
|
136
|
+
else setSelection(anchor, 1, end, endAt);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Selects the text only, never the chips host. */
|
|
140
|
+
function selectText(root: HTMLElement) {
|
|
141
|
+
const anchor = ensureCaretAnchor(root);
|
|
142
|
+
const [node, offset] = textEnd(root);
|
|
143
|
+
setSelection(anchor, 1, node, offset);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Width the inline chips take before the text on the first line. The host is
|
|
148
|
+
* `display: contents` and has no box, so this measures where the text starts.
|
|
149
|
+
*/
|
|
150
|
+
export function readInlineChipsWidth(root: HTMLElement): number {
|
|
151
|
+
const anchor = findCaretAnchor(root);
|
|
152
|
+
if (!anchor || !chipsHost(root)?.childElementCount) return 0;
|
|
153
|
+
// A text offset, not an element boundary: Chromium gives no rect for the latter.
|
|
154
|
+
const textStart = document.createRange();
|
|
155
|
+
textStart.setStart(anchor, 0);
|
|
156
|
+
const style = getComputedStyle(root);
|
|
157
|
+
const contentLeft =
|
|
158
|
+
root.getBoundingClientRect().left +
|
|
159
|
+
root.clientLeft +
|
|
160
|
+
(Number.parseFloat(style.paddingLeft) || 0);
|
|
161
|
+
return Math.max(0, textStart.getBoundingClientRect().left - contentLeft);
|
|
69
162
|
}
|
|
70
163
|
|
|
71
164
|
/** True when nothing but the chips host sits before the caret. */
|
|
@@ -74,13 +167,24 @@ function caretAtTextStart(root: HTMLElement): boolean {
|
|
|
74
167
|
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
|
|
75
168
|
const anchor = sel.anchorNode;
|
|
76
169
|
if (!anchor || !root.contains(anchor)) return false;
|
|
77
|
-
const host = root
|
|
170
|
+
const host = chipsHost(root);
|
|
78
171
|
if (host?.contains(anchor)) return false;
|
|
79
172
|
const probe = document.createRange();
|
|
80
173
|
probe.selectNodeContents(root);
|
|
81
174
|
if (host) probe.setStartAfter(host);
|
|
82
175
|
probe.setEnd(anchor, sel.anchorOffset);
|
|
83
|
-
|
|
176
|
+
const before = probe.cloneContents();
|
|
177
|
+
return (
|
|
178
|
+
(before.textContent ?? "").replaceAll(ZERO_WIDTH_SPACE, "") === "" &&
|
|
179
|
+
before.querySelector("br, div, p") === null
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Clicks on chip controls, such as remove buttons, keep their own behaviour. */
|
|
184
|
+
function isChipControl(target: EventTarget | null): boolean {
|
|
185
|
+
if (!(target instanceof Element)) return false;
|
|
186
|
+
const control = target.closest("button, a, input, [role=button]");
|
|
187
|
+
return control?.closest(`[${CHIPS_HOST_ATTR}]`) != null;
|
|
84
188
|
}
|
|
85
189
|
|
|
86
190
|
function insertPlainText(root: HTMLElement, text: string) {
|
|
@@ -121,7 +225,8 @@ export type InputAreaEditorProps = {
|
|
|
121
225
|
* Contenteditable editor. The chips host is a non-editable inline atom at the
|
|
122
226
|
* start of the same text flow, so wrapped lines start under the chips. React
|
|
123
227
|
* owns the host only. The text nodes after it belong to the DOM and are
|
|
124
|
-
* reconciled against `value
|
|
228
|
+
* reconciled against `value`, behind a U+200B caret anchor that keeps an
|
|
229
|
+
* editable position after the chips.
|
|
125
230
|
*/
|
|
126
231
|
export function InputAreaEditor({
|
|
127
232
|
editorRef,
|
|
@@ -142,23 +247,45 @@ export function InputAreaEditor({
|
|
|
142
247
|
const composingRef = useRef(false);
|
|
143
248
|
const ghostProbeRef = useRef<HTMLSpanElement | null>(null);
|
|
144
249
|
|
|
145
|
-
const
|
|
250
|
+
const assignEditorRef = (node: HTMLDivElement | null) => {
|
|
146
251
|
rootRef.current = node;
|
|
147
252
|
if (typeof editorRef === "function") editorRef(node);
|
|
148
253
|
else if (editorRef) editorRef.current = node;
|
|
149
254
|
};
|
|
150
255
|
|
|
256
|
+
const keepCaretOutsideChips = (root: HTMLElement) => {
|
|
257
|
+
if (!composingRef.current) normaliseSelection(root);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Native listeners: React has no `selectionchange`, and its `onBeforeInput`
|
|
261
|
+
// does not see deletions.
|
|
262
|
+
const setRefs = (node: HTMLDivElement) => {
|
|
263
|
+
assignEditorRef(node);
|
|
264
|
+
const keepSelectionOutsideChips = () => {
|
|
265
|
+
if (document.activeElement === node) keepCaretOutsideChips(node);
|
|
266
|
+
};
|
|
267
|
+
document.addEventListener("selectionchange", keepSelectionOutsideChips);
|
|
268
|
+
node.addEventListener("beforeinput", keepSelectionOutsideChips);
|
|
269
|
+
return () => {
|
|
270
|
+
document.removeEventListener("selectionchange", keepSelectionOutsideChips);
|
|
271
|
+
node.removeEventListener("beforeinput", keepSelectionOutsideChips);
|
|
272
|
+
assignEditorRef(null);
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
151
276
|
useLayoutEffect(() => {
|
|
152
277
|
const root = rootRef.current;
|
|
153
278
|
if (!root || composingRef.current) return;
|
|
154
|
-
if (extractEditorText(root)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
279
|
+
if (extractEditorText(root) !== value) {
|
|
280
|
+
Array.from(root.childNodes).forEach((child) => {
|
|
281
|
+
if (!(child instanceof HTMLElement && child.hasAttribute(CHIPS_HOST_ATTR))) {
|
|
282
|
+
root.removeChild(child);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
root.appendChild(document.createTextNode(ZERO_WIDTH_SPACE + value));
|
|
286
|
+
if (document.activeElement === root) placeCaretAtEnd(root);
|
|
287
|
+
}
|
|
288
|
+
ensureCaretAnchor(root);
|
|
162
289
|
});
|
|
163
290
|
|
|
164
291
|
// Copy the probe colour to the editor. Opacity below 1 is folded into the
|
|
@@ -185,8 +312,20 @@ export function InputAreaEditor({
|
|
|
185
312
|
onKeyDown?.(event);
|
|
186
313
|
if (event.defaultPrevented) return;
|
|
187
314
|
const root = event.currentTarget;
|
|
315
|
+
if (composingRef.current || event.nativeEvent.isComposing) return;
|
|
316
|
+
if (
|
|
317
|
+
(event.metaKey || event.ctrlKey) &&
|
|
318
|
+
!event.altKey &&
|
|
319
|
+
!event.shiftKey &&
|
|
320
|
+
event.key.toLowerCase() === "a"
|
|
321
|
+
) {
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
selectText(root);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Before the default action, so a selection never spans the chips host.
|
|
327
|
+
normaliseSelection(root);
|
|
188
328
|
if (event.key === "Enter") {
|
|
189
|
-
if (composingRef.current || event.nativeEvent.isComposing) return;
|
|
190
329
|
if (event.shiftKey) return;
|
|
191
330
|
event.preventDefault();
|
|
192
331
|
const form = root.closest("form");
|
|
@@ -197,13 +336,10 @@ export function InputAreaEditor({
|
|
|
197
336
|
form?.requestSubmit();
|
|
198
337
|
return;
|
|
199
338
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
chipCount > 0 &&
|
|
203
|
-
caretAtTextStart(root)
|
|
204
|
-
) {
|
|
339
|
+
// Nothing visible to delete. The default would eat the U+200B anchor.
|
|
340
|
+
if (event.key === "Backspace" && caretAtTextStart(root)) {
|
|
205
341
|
event.preventDefault();
|
|
206
|
-
onRemoveLastChip?.();
|
|
342
|
+
if (chipCount > 0) onRemoveLastChip?.();
|
|
207
343
|
}
|
|
208
344
|
};
|
|
209
345
|
|
|
@@ -231,6 +367,7 @@ export function InputAreaEditor({
|
|
|
231
367
|
className={cn(INLINE_EDITOR_CLASS, className)}
|
|
232
368
|
onInput={(event) => {
|
|
233
369
|
if (composingRef.current) return;
|
|
370
|
+
ensureCaretAnchor(event.currentTarget);
|
|
234
371
|
onValueChange(extractEditorText(event.currentTarget));
|
|
235
372
|
}}
|
|
236
373
|
onCompositionStart={() => {
|
|
@@ -238,9 +375,15 @@ export function InputAreaEditor({
|
|
|
238
375
|
}}
|
|
239
376
|
onCompositionEnd={(event) => {
|
|
240
377
|
composingRef.current = false;
|
|
378
|
+
ensureCaretAnchor(event.currentTarget);
|
|
241
379
|
onValueChange(extractEditorText(event.currentTarget));
|
|
242
380
|
}}
|
|
243
381
|
onKeyDown={handleKeyDown}
|
|
382
|
+
onFocus={(event) => keepCaretOutsideChips(event.currentTarget)}
|
|
383
|
+
onMouseUp={(event) => {
|
|
384
|
+
if (!isChipControl(event.target)) keepCaretOutsideChips(event.currentTarget);
|
|
385
|
+
}}
|
|
386
|
+
onKeyUp={(event) => keepCaretOutsideChips(event.currentTarget)}
|
|
244
387
|
onPaste={handlePaste}
|
|
245
388
|
onBlur={onBlur}
|
|
246
389
|
>
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isComposerCompact,
|
|
6
6
|
readEditorInnerWidth,
|
|
7
7
|
} from "./composerCompact";
|
|
8
|
+
import { readInlineChipsWidth } from "./InputAreaEditor";
|
|
8
9
|
|
|
9
10
|
export function useComposerCompact({
|
|
10
11
|
value,
|
|
@@ -24,10 +25,7 @@ export function useComposerCompact({
|
|
|
24
25
|
const editor = editorNode instanceof HTMLElement ? editorNode : null;
|
|
25
26
|
const font = editor ? getComputedStyle(editor).font : "14px sans-serif";
|
|
26
27
|
if (editor && wasCompactRef.current) {
|
|
27
|
-
const
|
|
28
|
-
editor.querySelector<HTMLElement>("[data-composer-chips-inline]")
|
|
29
|
-
?.offsetWidth ?? 0;
|
|
30
|
-
const inner = readEditorInnerWidth(editor) - chipsWidth;
|
|
28
|
+
const inner = readEditorInnerWidth(editor) - readInlineChipsWidth(editor);
|
|
31
29
|
if (inner > 0) pillWidthRef.current = inner;
|
|
32
30
|
}
|
|
33
31
|
const exceedsPill =
|
package/src/index.ts
CHANGED
|
@@ -42,12 +42,14 @@ export * from "./ui/search-input";
|
|
|
42
42
|
export * from "./ui/select";
|
|
43
43
|
export * from "./ui/separator";
|
|
44
44
|
export * from "./ui/skeleton";
|
|
45
|
+
export * from "./ui/slide-over-panel";
|
|
45
46
|
export * from "./ui/sonner";
|
|
46
47
|
export * from "./ui/spinner";
|
|
47
48
|
export * from "./ui/switch";
|
|
48
49
|
export * from "./ui/table";
|
|
49
50
|
export * from "./ui/tabs";
|
|
50
51
|
export * from "./ui/textarea";
|
|
52
|
+
export * from "./ui/toolbar-search-input";
|
|
51
53
|
export * from "./ui/time-picker";
|
|
52
54
|
export * from "./ui/tooltip";
|
|
53
55
|
export * from "./ui/kbd";
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
AlertDialogHeader,
|
|
10
10
|
AlertDialogTitle,
|
|
11
11
|
} from "./alert-dialog";
|
|
12
|
+
import { Button } from "./button";
|
|
13
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
|
|
12
14
|
|
|
13
15
|
export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
|
|
14
16
|
title: React.ReactNode;
|
|
@@ -19,6 +21,12 @@ export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
|
|
|
19
21
|
onConfirm?: () => void;
|
|
20
22
|
variant?: "default" | "destructive";
|
|
21
23
|
confirmDisabled?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* `"default"`: blocking alert dialog that closes on either button.
|
|
26
|
+
* `"sm"`: compact dismissible dialog (outside click, Escape and the close
|
|
27
|
+
* button close it). Confirm does not close it; the caller controls `open`.
|
|
28
|
+
*/
|
|
29
|
+
size?: "default" | "sm";
|
|
22
30
|
};
|
|
23
31
|
|
|
24
32
|
function ConfirmDialog({
|
|
@@ -30,9 +38,46 @@ function ConfirmDialog({
|
|
|
30
38
|
onConfirm,
|
|
31
39
|
variant = "default",
|
|
32
40
|
confirmDisabled,
|
|
41
|
+
size = "default",
|
|
33
42
|
children,
|
|
34
43
|
...props
|
|
35
44
|
}: ConfirmDialogProps) {
|
|
45
|
+
if (size === "sm") {
|
|
46
|
+
return (
|
|
47
|
+
<Dialog {...props}>
|
|
48
|
+
{children}
|
|
49
|
+
<DialogContent className="sm:max-w-96">
|
|
50
|
+
<DialogTitle className="mb-2 text-sm font-medium">{title}</DialogTitle>
|
|
51
|
+
{description != null ? (
|
|
52
|
+
<DialogDescription className="mb-4 text-sm text-muted-foreground">
|
|
53
|
+
{description}
|
|
54
|
+
</DialogDescription>
|
|
55
|
+
) : null}
|
|
56
|
+
<div className="flex justify-end gap-2">
|
|
57
|
+
<Button
|
|
58
|
+
variant="ghost"
|
|
59
|
+
size="sm"
|
|
60
|
+
onClick={() => {
|
|
61
|
+
onCancel?.();
|
|
62
|
+
props.onOpenChange?.(false);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{cancelLabel}
|
|
66
|
+
</Button>
|
|
67
|
+
<Button
|
|
68
|
+
variant={variant}
|
|
69
|
+
size="sm"
|
|
70
|
+
disabled={confirmDisabled}
|
|
71
|
+
onClick={onConfirm}
|
|
72
|
+
>
|
|
73
|
+
{confirmLabel}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</DialogContent>
|
|
77
|
+
</Dialog>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
return (
|
|
37
82
|
<AlertDialog {...props}>
|
|
38
83
|
{children}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
3
|
+
import { IconX } from "@tabler/icons-react";
|
|
4
|
+
import { cn } from "../utils/cn";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
|
|
7
|
+
export interface SlideOverPanelProps {
|
|
8
|
+
open: boolean;
|
|
9
|
+
/** Panel width. Default `"w-96"`. */
|
|
10
|
+
width?: "w-80" | "w-96";
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
header: ReactNode;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
/** `aria-label` of the close button. Default `"Close panel"`. */
|
|
15
|
+
closeLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Right-side slide-over shell, absolutely positioned inside its nearest
|
|
21
|
+
* positioned parent (not a portal or dialog). Only the close button closes it.
|
|
22
|
+
* Hidden below `md`.
|
|
23
|
+
*/
|
|
24
|
+
export function SlideOverPanel({
|
|
25
|
+
open,
|
|
26
|
+
width = "w-96",
|
|
27
|
+
onClose,
|
|
28
|
+
header,
|
|
29
|
+
children,
|
|
30
|
+
closeLabel = "Close panel",
|
|
31
|
+
className,
|
|
32
|
+
}: SlideOverPanelProps) {
|
|
33
|
+
return (
|
|
34
|
+
<AnimatePresence initial={false}>
|
|
35
|
+
{open && (
|
|
36
|
+
<motion.div
|
|
37
|
+
key="slide-over-panel"
|
|
38
|
+
initial={{ x: "100%" }}
|
|
39
|
+
animate={{ x: 0 }}
|
|
40
|
+
exit={{ x: "100%" }}
|
|
41
|
+
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
|
42
|
+
className={cn(
|
|
43
|
+
`absolute top-0 right-0 bottom-0 ${width} z-20 glass-panel-strong smooth-shadow-ring-xl overflow-y-auto hidden md:flex flex-col`,
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<div className="flex items-start justify-between p-4 pb-2">
|
|
48
|
+
{header}
|
|
49
|
+
<Button
|
|
50
|
+
type="button"
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="icon-sm"
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
className="ml-2 shrink-0 text-muted-foreground hover:text-foreground"
|
|
55
|
+
aria-label={closeLabel}
|
|
56
|
+
>
|
|
57
|
+
<IconX size={16} />
|
|
58
|
+
</Button>
|
|
59
|
+
</div>
|
|
60
|
+
{children}
|
|
61
|
+
</motion.div>
|
|
62
|
+
)}
|
|
63
|
+
</AnimatePresence>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { IconSearch, IconX } from "@tabler/icons-react";
|
|
3
|
+
import { cn } from "../utils/cn";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { Input } from "./input";
|
|
6
|
+
|
|
7
|
+
export interface ToolbarSearchInputProps {
|
|
8
|
+
value: string;
|
|
9
|
+
onChange: (value: string) => void;
|
|
10
|
+
/** Default `"Search..."`. */
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** `aria-label` of the input. Default `"Search"`. */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** `aria-label` of the clear button. Default `"Clear search"`. */
|
|
15
|
+
clearLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
inputClassName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stable rounded search field for toolbars: no enter or clear animations, no
|
|
22
|
+
* layout jump. The clear button stays mounted and is hidden while the value is
|
|
23
|
+
* blank. Clearing does not move focus. The ref points at the `<input>`.
|
|
24
|
+
*/
|
|
25
|
+
export const ToolbarSearchInput = React.forwardRef<HTMLInputElement, ToolbarSearchInputProps>(
|
|
26
|
+
function ToolbarSearchInput(
|
|
27
|
+
{
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
placeholder = "Search...",
|
|
31
|
+
label = "Search",
|
|
32
|
+
clearLabel = "Clear search",
|
|
33
|
+
className,
|
|
34
|
+
inputClassName,
|
|
35
|
+
},
|
|
36
|
+
ref,
|
|
37
|
+
) {
|
|
38
|
+
const active = value.trim().length > 0;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={cn("relative min-w-0 flex-1 sm:flex-none", className)}>
|
|
42
|
+
<IconSearch
|
|
43
|
+
size={14}
|
|
44
|
+
className="pointer-events-none absolute left-2.5 top-1/2 z-[1] -translate-y-1/2 text-muted-foreground"
|
|
45
|
+
/>
|
|
46
|
+
<Input
|
|
47
|
+
ref={ref}
|
|
48
|
+
type="text"
|
|
49
|
+
value={value}
|
|
50
|
+
onChange={(event) => onChange(event.target.value)}
|
|
51
|
+
placeholder={placeholder}
|
|
52
|
+
aria-label={label}
|
|
53
|
+
enterKeyHint="search"
|
|
54
|
+
className={cn(
|
|
55
|
+
// match outline toolbar buttons beside it
|
|
56
|
+
"h-8 w-full min-w-0 rounded-lg border border-border bg-transparent pl-8 pr-8 text-sm shadow-none sm:w-44 md:w-52",
|
|
57
|
+
"placeholder:text-muted-foreground/70 hover:bg-default",
|
|
58
|
+
"focus-visible:border-border focus-visible:bg-default focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
59
|
+
inputClassName,
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
<Button
|
|
63
|
+
type="button"
|
|
64
|
+
variant="ghost"
|
|
65
|
+
size="icon-sm"
|
|
66
|
+
aria-label={clearLabel}
|
|
67
|
+
tabIndex={active ? 0 : -1}
|
|
68
|
+
aria-hidden={!active}
|
|
69
|
+
className={cn(
|
|
70
|
+
"absolute right-0.5 top-1/2 z-[1] h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground",
|
|
71
|
+
!active && "pointer-events-none opacity-0",
|
|
72
|
+
)}
|
|
73
|
+
onClick={() => onChange("")}
|
|
74
|
+
>
|
|
75
|
+
<IconX size={14} stroke={1.75} />
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
);
|