@kennycorrea/ui 1.8.2 → 1.8.4
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/CHANGELOG.md +8 -0
- package/README.md +16 -0
- package/dist/molecules/MentionTextarea/MentionTextarea.d.ts +46 -0
- package/dist/molecules/MentionTextarea/MentionTextarea.js +158 -0
- package/dist/molecules/MentionTextarea/index.d.ts +1 -0
- package/dist/molecules/MentionTextarea/index.js +1 -0
- package/dist/molecules/index.d.ts +1 -0
- package/dist/molecules/index.js +1 -0
- package/dist/strings.d.ts +2 -0
- package/dist/strings.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.4 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
A `MentionTextarea` molecule.
|
|
6
|
+
|
|
7
|
+
- **`MentionTextarea`**: a `Textarea` that is an ARIA combobox for inline tokens. `triggers` maps a character to a kind (`{ '$': 'ticker', '@': 'member' }`); a trigger at the start or after whitespace, up to the caret, is the token. `onQuery(kind, q)` returns `{ value, label, detail? }` options, sync or async (an answer to an older query is dropped). `onPick` defaults to replacing the token with `<trigger><value> ` and putting the caret after it.
|
|
8
|
+
- The listbox sits under the field (`role="listbox"`/`option` on divs, `aria-activedescendant`, focus stays in the textarea). Arrow keys move, Enter or Tab picks, Esc closes; options pick on mousedown without blurring. Radix tokens only, so light and dark follow the theme.
|
|
9
|
+
- **New UI string `suggestions`** names the listbox. `findMentionToken` is exported too. Tests in `test/MentionTextarea.test.tsx`.
|
|
10
|
+
|
|
3
11
|
## 1.8.2 — 2026-10-30
|
|
4
12
|
|
|
5
13
|
A live validation status on `FormField`.
|
package/README.md
CHANGED
|
@@ -284,6 +284,22 @@ Full example with 500 rows: [examples/admin/Orders.tsx](examples/admin/Orders.ts
|
|
|
284
284
|
- **`FilterPopover`**: a "Filters (n)" button opening a popover, or a bottom sheet below `sm`.
|
|
285
285
|
With `onApply` the controls are a draft. **`FilterChips`** is a ChipGroup plus clear-all.
|
|
286
286
|
**`FilterBar`** lays out search, filters and actions, with active filters as removable pills.
|
|
287
|
+
- **`MentionTextarea`**: a Textarea with inline tokens such as `@ana` or `$AAPL`, as an ARIA
|
|
288
|
+
combobox. Typing a trigger opens a listbox under the field; arrows move, Enter/Tab picks,
|
|
289
|
+
Esc closes, and a click picks without taking focus from the textarea.
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
<MentionTextarea
|
|
293
|
+
value={note}
|
|
294
|
+
onValueChange={setNote}
|
|
295
|
+
triggers={{ $: 'ticker', '@': 'member' }}
|
|
296
|
+
onQuery={(kind, q) => (kind === 'member' ? searchMembers(q) : searchTickers(q))}
|
|
297
|
+
/>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Options are `{ value, label, detail? }`, sync or async. `onPick(option, token, text)` returns
|
|
301
|
+
`{ value, caret }`; the default replaces the token with `<trigger><value> `. The listbox is
|
|
302
|
+
named by the `suggestions` UI string.
|
|
287
303
|
- **`SettingsPage`**: section list beside the content from 768px; a select above it when
|
|
288
304
|
narrower.
|
|
289
305
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type TextareaProps } from '../../atoms/Textarea/Textarea.js';
|
|
3
|
+
export interface MentionOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
/** Secondary text, shown muted beside the label. */
|
|
7
|
+
detail?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
/** The token under the caret: its trigger, the kind it maps to, the query and where it sits. */
|
|
10
|
+
export interface MentionToken {
|
|
11
|
+
trigger: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
query: string;
|
|
14
|
+
/** Index of the trigger character. */
|
|
15
|
+
start: number;
|
|
16
|
+
/** Index just past the token (the caret). */
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
export interface MentionTextareaProps extends Omit<TextareaProps, 'value' | 'defaultValue' | 'onChange' | 'role'> {
|
|
20
|
+
value?: string;
|
|
21
|
+
defaultValue?: string;
|
|
22
|
+
onValueChange?: (value: string) => void;
|
|
23
|
+
/** Trigger character → kind, e.g. `{ '$': 'ticker', '@': 'member' }`. */
|
|
24
|
+
triggers: Record<string, string>;
|
|
25
|
+
/** The options for a token. May be async; an answer to an older query is dropped. */
|
|
26
|
+
onQuery: (kind: string, query: string) => MentionOption[] | Promise<MentionOption[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Returns the new text and caret, or nothing to leave the text alone.
|
|
29
|
+
* Default: replace the token with `<trigger><value> `.
|
|
30
|
+
*/
|
|
31
|
+
onPick?: (option: MentionOption, token: MentionToken, text: string) => {
|
|
32
|
+
value: string;
|
|
33
|
+
caret: number;
|
|
34
|
+
} | undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The token ending at `caret`: a trigger at the start or after whitespace, then no whitespace. */
|
|
37
|
+
export declare function findMentionToken(text: string, caret: number, triggers: Record<string, string>): MentionToken | null;
|
|
38
|
+
/**
|
|
39
|
+
* A Textarea that is also an ARIA combobox for inline tokens such as `@ana` or `$AAPL`.
|
|
40
|
+
*
|
|
41
|
+
* Typing a trigger opens a listbox under the field. Arrow keys move the active option, announced
|
|
42
|
+
* through `aria-activedescendant` so focus never leaves the textarea; Enter or Tab picks it and
|
|
43
|
+
* Escape closes the list. Options are picked on mousedown with the default prevented, so the
|
|
44
|
+
* textarea keeps focus and the caret. Colours are Radix tokens, so the list follows light and dark.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MentionTextarea: import("react").ForwardRefExoticComponent<MentionTextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState, } from 'react';
|
|
3
|
+
import { Textarea } from '../../atoms/Textarea/Textarea.js';
|
|
4
|
+
import { useUiStrings } from '../../strings.js';
|
|
5
|
+
/** The token ending at `caret`: a trigger at the start or after whitespace, then no whitespace. */
|
|
6
|
+
export function findMentionToken(text, caret, triggers) {
|
|
7
|
+
for (let i = caret - 1; i >= 0; i--) {
|
|
8
|
+
const ch = text[i];
|
|
9
|
+
if (/\s/.test(ch))
|
|
10
|
+
return null;
|
|
11
|
+
const kind = triggers[ch];
|
|
12
|
+
if (kind !== undefined && (i === 0 || /\s/.test(text[i - 1]))) {
|
|
13
|
+
return { trigger: ch, kind, query: text.slice(i + 1, caret), start: i, end: caret };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
function defaultPick(option, token, text) {
|
|
19
|
+
const insert = `${token.trigger}${option.value} `;
|
|
20
|
+
return {
|
|
21
|
+
value: text.slice(0, token.start) + insert + text.slice(token.end),
|
|
22
|
+
caret: token.start + insert.length,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A Textarea that is also an ARIA combobox for inline tokens such as `@ana` or `$AAPL`.
|
|
27
|
+
*
|
|
28
|
+
* Typing a trigger opens a listbox under the field. Arrow keys move the active option, announced
|
|
29
|
+
* through `aria-activedescendant` so focus never leaves the textarea; Enter or Tab picks it and
|
|
30
|
+
* Escape closes the list. Options are picked on mousedown with the default prevented, so the
|
|
31
|
+
* textarea keeps focus and the caret. Colours are Radix tokens, so the list follows light and dark.
|
|
32
|
+
*/
|
|
33
|
+
export const MentionTextarea = forwardRef(function MentionTextarea({ value, defaultValue = '', onValueChange, triggers, onQuery, onPick = defaultPick, onKeyDown, onBlur, onSelect, id, ...props }, ref) {
|
|
34
|
+
const strings = useUiStrings();
|
|
35
|
+
const autoId = useId();
|
|
36
|
+
const listId = `${id ?? autoId}-listbox`;
|
|
37
|
+
const optionId = (i) => `${listId}-${i}`;
|
|
38
|
+
const area = useRef(null);
|
|
39
|
+
useImperativeHandle(ref, () => area.current);
|
|
40
|
+
const [text, setText] = useState(value ?? defaultValue);
|
|
41
|
+
const [token, setToken] = useState(null);
|
|
42
|
+
const [options, setOptions] = useState([]);
|
|
43
|
+
const [active, setActive] = useState(0);
|
|
44
|
+
const request = useRef(0);
|
|
45
|
+
const pendingCaret = useRef(null);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (value !== undefined)
|
|
48
|
+
setText(value);
|
|
49
|
+
}, [value]);
|
|
50
|
+
// After a pick re-renders the text, put the caret after the inserted token.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (pendingCaret.current === null || !area.current)
|
|
53
|
+
return;
|
|
54
|
+
area.current.setSelectionRange(pendingCaret.current, pendingCaret.current);
|
|
55
|
+
pendingCaret.current = null;
|
|
56
|
+
});
|
|
57
|
+
const close = useCallback(() => {
|
|
58
|
+
request.current++;
|
|
59
|
+
setToken(null);
|
|
60
|
+
setOptions([]);
|
|
61
|
+
}, []);
|
|
62
|
+
const detect = useCallback((nextText, caret) => {
|
|
63
|
+
const found = findMentionToken(nextText, caret, triggers);
|
|
64
|
+
if (!found) {
|
|
65
|
+
close();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setToken((prev) => prev && prev.start === found.start && prev.query === found.query ? prev : found);
|
|
69
|
+
const ticket = ++request.current;
|
|
70
|
+
Promise.resolve(onQuery(found.kind, found.query)).then((result) => {
|
|
71
|
+
if (ticket !== request.current)
|
|
72
|
+
return;
|
|
73
|
+
setOptions(result);
|
|
74
|
+
setActive(0);
|
|
75
|
+
});
|
|
76
|
+
}, [triggers, onQuery, close]);
|
|
77
|
+
const commit = (next) => {
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
setText(next);
|
|
80
|
+
onValueChange?.(next);
|
|
81
|
+
};
|
|
82
|
+
const pick = (option) => {
|
|
83
|
+
if (!token)
|
|
84
|
+
return;
|
|
85
|
+
const result = onPick(option, token, text);
|
|
86
|
+
close();
|
|
87
|
+
if (!result)
|
|
88
|
+
return;
|
|
89
|
+
pendingCaret.current = result.caret;
|
|
90
|
+
commit(result.value);
|
|
91
|
+
};
|
|
92
|
+
const open = token !== null && options.length > 0;
|
|
93
|
+
const handleKeyDown = (event) => {
|
|
94
|
+
if (open) {
|
|
95
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
98
|
+
setActive((a) => (a + step + options.length) % options.length);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if ((event.key === 'Enter' && !event.shiftKey) || event.key === 'Tab') {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
pick(options[active]);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (event.key === 'Escape') {
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
event.stopPropagation();
|
|
109
|
+
close();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
onKeyDown?.(event);
|
|
114
|
+
};
|
|
115
|
+
return (_jsxs("div", { style: { position: 'relative' }, children: [_jsx(Textarea, { ...props, id: id, ref: area, value: text, role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open ? optionId(active) : undefined, onChange: (event) => {
|
|
116
|
+
const next = event.target.value;
|
|
117
|
+
commit(next);
|
|
118
|
+
detect(next, event.target.selectionStart ?? next.length);
|
|
119
|
+
}, onSelect: (event) => {
|
|
120
|
+
const el = event.currentTarget;
|
|
121
|
+
if (el.selectionStart === el.selectionEnd)
|
|
122
|
+
detect(el.value, el.selectionStart);
|
|
123
|
+
else
|
|
124
|
+
close();
|
|
125
|
+
onSelect?.(event);
|
|
126
|
+
}, onKeyDown: handleKeyDown, onBlur: (event) => {
|
|
127
|
+
close();
|
|
128
|
+
onBlur?.(event);
|
|
129
|
+
} }), open && (_jsx("div", { id: listId, role: "listbox", "aria-label": strings.suggestions, className: "ui-mention-listbox", style: {
|
|
130
|
+
position: 'absolute',
|
|
131
|
+
insetInlineStart: 0,
|
|
132
|
+
insetInlineEnd: 0,
|
|
133
|
+
top: '100%',
|
|
134
|
+
marginTop: 4,
|
|
135
|
+
zIndex: 50,
|
|
136
|
+
maxHeight: 240,
|
|
137
|
+
overflowY: 'auto',
|
|
138
|
+
padding: 4,
|
|
139
|
+
background: 'var(--color-panel-solid)',
|
|
140
|
+
color: 'var(--gray-12)',
|
|
141
|
+
border: '1px solid var(--gray-a6)',
|
|
142
|
+
borderRadius: 'var(--radius-3)',
|
|
143
|
+
boxShadow: 'var(--shadow-4)',
|
|
144
|
+
}, children: options.map((option, i) => (_jsxs("div", { id: optionId(i), role: "option", tabIndex: -1, "aria-selected": i === active, onMouseDown: (event) => {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
pick(option);
|
|
147
|
+
}, onMouseMove: () => setActive(i), style: {
|
|
148
|
+
display: 'flex',
|
|
149
|
+
gap: 8,
|
|
150
|
+
alignItems: 'baseline',
|
|
151
|
+
padding: '6px 8px',
|
|
152
|
+
minHeight: 32,
|
|
153
|
+
borderRadius: 'var(--radius-2)',
|
|
154
|
+
cursor: 'pointer',
|
|
155
|
+
background: i === active ? 'var(--accent-a4)' : undefined,
|
|
156
|
+
color: i === active ? 'var(--accent-12)' : undefined,
|
|
157
|
+
}, children: [_jsx("span", { children: option.label }), option.detail != null && (_jsx("span", { style: { color: 'var(--gray-11)', fontSize: 'var(--font-size-1)' }, children: option.detail }))] }, option.value))) }))] }));
|
|
158
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './MentionTextarea.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './MentionTextarea.js';
|
|
@@ -12,6 +12,7 @@ export * from './FormField/index.js';
|
|
|
12
12
|
export * from './FormSection/index.js';
|
|
13
13
|
export * from './KeyValueList/index.js';
|
|
14
14
|
export * from './LoadingState/index.js';
|
|
15
|
+
export * from './MentionTextarea/index.js';
|
|
15
16
|
export * from './NavItem/index.js';
|
|
16
17
|
export * from './Pagination/index.js';
|
|
17
18
|
export * from './PasswordInput/index.js';
|
package/dist/molecules/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from './FormField/index.js';
|
|
|
12
12
|
export * from './FormSection/index.js';
|
|
13
13
|
export * from './KeyValueList/index.js';
|
|
14
14
|
export * from './LoadingState/index.js';
|
|
15
|
+
export * from './MentionTextarea/index.js';
|
|
15
16
|
export * from './NavItem/index.js';
|
|
16
17
|
export * from './Pagination/index.js';
|
|
17
18
|
export * from './PasswordInput/index.js';
|
package/dist/strings.d.ts
CHANGED
|
@@ -128,6 +128,8 @@ export interface UiStrings {
|
|
|
128
128
|
viewAsChart: string;
|
|
129
129
|
/** PasswordInput: the visibility toggle's name. It is a toggle (`aria-pressed`), so one name. */
|
|
130
130
|
showPassword: string;
|
|
131
|
+
/** MentionTextarea: the name of the listbox of suggestions for the token at the caret. */
|
|
132
|
+
suggestions: string;
|
|
131
133
|
}
|
|
132
134
|
/**
|
|
133
135
|
* Feeds the components their strings, once, near the root.
|
package/dist/strings.js
CHANGED
package/package.json
CHANGED