@momoi-labs/kiso-react 0.8.0 → 0.9.0
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 +55 -1
- package/dist/chip-input.d.ts +2 -2
- package/dist/chip-input.js +77 -16
- package/dist/command-palette.d.ts +1 -1
- package/dist/command-palette.js +18 -4
- package/dist/empty-state.js +2 -2
- package/dist/filter-expression.d.ts +49 -0
- package/dist/filter-expression.js +264 -0
- package/dist/filter-input.d.ts +14 -0
- package/dist/filter-input.js +251 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +7 -0
- package/dist/lifecycle.js +10 -0
- package/dist/split.js +3 -1
- package/dist/status-badge.d.ts +8 -0
- package/dist/status-badge.js +8 -0
- package/dist/styles.css +16 -3
- package/dist/theme-selector.js +15 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -38,6 +38,22 @@ those again. React and React DOM are peer dependencies supplied by the
|
|
|
38
38
|
application. No Tailwind setup is required. The stylesheet includes global
|
|
39
39
|
styles and Google Fonts.
|
|
40
40
|
|
|
41
|
+
## Appearance
|
|
42
|
+
|
|
43
|
+
The stylesheet includes the gallery's theme, accent, border, and corner-mark
|
|
44
|
+
options. Copy the Appearance page's HTML attributes onto your `<html>` element;
|
|
45
|
+
you do not need any gallery files or a second stylesheet.
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<html lang="en" data-accent="terracotta" data-border-style="soft"
|
|
49
|
+
data-corner-size="small" data-corner-marks="arcs" data-mark-size="medium">
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
See the [appearance attributes](../../kiso/docs/tokens.md#appearance-attributes)
|
|
53
|
+
for supported values. Omit `data-theme` to follow the operating system.
|
|
54
|
+
Your application owns saving and restoring preferences before first paint.
|
|
55
|
+
Remove any previously copied gallery appearance overrides after upgrading.
|
|
56
|
+
|
|
41
57
|
## Available components
|
|
42
58
|
|
|
43
59
|
Controls and forms
|
|
@@ -52,6 +68,8 @@ Controls and forms
|
|
|
52
68
|
field. Each chip reads as a call: scope, name, value, then one segment per
|
|
53
69
|
option as name=value or name=[a, b].
|
|
54
70
|
- ValidationMessage, for a field error referenced by `aria-describedby`.
|
|
71
|
+
- FilterInput, for typed field/operator/value conditions, IN lists, and nested
|
|
72
|
+
AND/OR groups. ChipInput keeps its existing API for structured stored values.
|
|
55
73
|
|
|
56
74
|
Data and structure
|
|
57
75
|
|
|
@@ -68,6 +86,8 @@ Data and structure
|
|
|
68
86
|
- LogView, LogViewLine, LogViewTime, and LogViewLevel.
|
|
69
87
|
- StepList, a run's steps with a state each, and StepBar, the same run as
|
|
70
88
|
one segment per step for a summary, a table cell, or a toast.
|
|
89
|
+
- Lifecycle, a detail screen's status, verbs and destructive action in one
|
|
90
|
+
row, and StatusBadge, a state in one of three tones.
|
|
71
91
|
- Stat, StatHeader, StatLabel, StatValue, StatFoot, and StatDelta.
|
|
72
92
|
- KV, KVKey, and KVValue, a description list of fixed facts.
|
|
73
93
|
- Separator, horizontal or vertical, and Dot and `dotVariants`.
|
|
@@ -187,10 +207,44 @@ import { BrandMark, TerminalIcon } from '@momoi-labs/kiso-react';
|
|
|
187
207
|
</div>
|
|
188
208
|
```
|
|
189
209
|
|
|
210
|
+
## Structured filters
|
|
211
|
+
|
|
212
|
+
`FilterInput` turns completed conditions into editable chips. The application
|
|
213
|
+
supplies a field schema and owns the confirmed filter tree and search results.
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { useState } from 'react';
|
|
217
|
+
import { FilterInput, type FilterField, type FilterNode } from '@momoi-labs/kiso-react';
|
|
218
|
+
|
|
219
|
+
const fields: FilterField[] = [
|
|
220
|
+
{ key: 'status', type: 'text', values: ['active', 'paused'] },
|
|
221
|
+
{ key: 'region', type: 'text', values: ['us', 'eu', 'ap'] },
|
|
222
|
+
{ key: 'lag', type: 'number' },
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
export function ReplicaFilters() {
|
|
226
|
+
const [filters, setFilters] = useState<FilterNode[]>([]);
|
|
227
|
+
return <FilterInput label="Find replicas" fields={fields}
|
|
228
|
+
value={filters} onValueChange={setFilters} />;
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Type `status=active ` to create a chip, or
|
|
233
|
+
`(region IN (us, eu,) OR lag>100)` to create a group. Click a chip's field,
|
|
234
|
+
operator, or value to edit that segment. Use Edit expression to change grouping
|
|
235
|
+
across existing chips.
|
|
236
|
+
|
|
237
|
+
`parseFilterExpression` validates saved text against the schema;
|
|
238
|
+
`serializeFilterExpression` converts the confirmed tree back to text. The
|
|
239
|
+
component does not execute queries. Products own server validation and
|
|
240
|
+
parameterized query translation. See the
|
|
241
|
+
[FilterInput contract](../../kiso/docs/components/filter-input.md) for syntax,
|
|
242
|
+
draft handling, keyboard behavior, and mobile states.
|
|
243
|
+
|
|
190
244
|
## Development
|
|
191
245
|
|
|
192
246
|
From the repository root, run `npm ci`, then `npm run prototype`. The component
|
|
193
|
-
gallery opens at <http://127.0.0.1:5173/#components> and covers
|
|
247
|
+
gallery opens at <http://127.0.0.1:5173/#components> and covers the Kiso
|
|
194
248
|
catalog entries, each rendering the component this package exports. Its sample
|
|
195
249
|
data is simulated in memory.
|
|
196
250
|
|
package/dist/chip-input.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ declare function ChipInput({ className, ...props }: React.ComponentProps<"div">)
|
|
|
3
3
|
declare function ChipInputBox({ className, disabled, onMouseDown, ...props }: React.ComponentProps<"div"> & {
|
|
4
4
|
disabled?: boolean;
|
|
5
5
|
}): React.JSX.Element;
|
|
6
|
-
declare function ChipInputField({ className, onKeyDown, onRemoveLast, ...props }: React.ComponentProps<"input"> & {
|
|
6
|
+
declare function ChipInputField({ className, onKeyDown, onChange, onRemoveLast, onCompositionStart, onCompositionEnd, ...props }: React.ComponentProps<"input"> & {
|
|
7
7
|
onRemoveLast?: () => void;
|
|
8
8
|
}): React.JSX.Element;
|
|
9
9
|
declare function ChipInputList({ className, "aria-label": ariaLabel, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
@@ -39,5 +39,5 @@ declare function ChipOption({ className, name, value, label, editLabel, confirmL
|
|
|
39
39
|
declare function ChipOptionAdd({ className, label, placeholder, editLabel, confirmLabel, ...props }: Omit<React.ComponentProps<typeof EditableSegment>, "slot" | "value" | "display"> & {
|
|
40
40
|
label: string;
|
|
41
41
|
}): React.JSX.Element;
|
|
42
|
-
declare function ChipRemove({ className, ...props }: React.ComponentProps<"button">): React.JSX.Element;
|
|
42
|
+
declare function ChipRemove({ className, onClick, ...props }: React.ComponentProps<"button">): React.JSX.Element;
|
|
43
43
|
export { Chip, ChipInput, ChipInputBox, ChipInputEmpty, ChipInputField, ChipInputList, ChipInputOption, ChipName, ChipOption, ChipOptionAdd, ChipRemove, ChipScope, ChipValue, };
|
package/dist/chip-input.js
CHANGED
|
@@ -37,35 +37,56 @@ function ChipInputBox({ className, disabled = false, onMouseDown, ...props }) {
|
|
|
37
37
|
onMouseDown?.(event);
|
|
38
38
|
}, ...props }));
|
|
39
39
|
}
|
|
40
|
-
function ChipInputField({ className, onKeyDown, onRemoveLast, ...props }) {
|
|
41
|
-
const { activeId, setActiveId, listId, listRef, fieldRef, open } = useChipInput();
|
|
40
|
+
function ChipInputField({ className, onKeyDown, onChange, onRemoveLast, onCompositionStart, onCompositionEnd, ...props }) {
|
|
41
|
+
const { activeId, setActiveId, listId, listRef, fieldRef, open, setOpen } = useChipInput();
|
|
42
|
+
const composing = React.useRef(false);
|
|
42
43
|
// Typing changes the list under the highlight, so re-anchor it every render
|
|
43
44
|
// instead of tracking an index the filter invalidates.
|
|
44
45
|
React.useEffect(() => {
|
|
45
|
-
const visible = options(listRef.current);
|
|
46
|
+
const visible = open ? options(listRef.current) : [];
|
|
46
47
|
if (!visible.some((option) => option.id === activeId)) {
|
|
47
48
|
setActiveId(visible[0]?.id ?? "");
|
|
48
49
|
}
|
|
49
50
|
});
|
|
51
|
+
// Reopening must render the list before scrolling its highlight.
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (open)
|
|
54
|
+
options(listRef.current).find((option) => option.id === activeId)?.scrollIntoView({ block: "nearest" });
|
|
55
|
+
}, [activeId, open, listRef]);
|
|
50
56
|
function keys(event) {
|
|
51
|
-
|
|
57
|
+
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) {
|
|
58
|
+
event.stopPropagation();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const visible = open ? options(listRef.current) : [];
|
|
52
62
|
const index = visible.findIndex((option) => option.id === activeId);
|
|
53
63
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
54
64
|
event.preventDefault();
|
|
55
|
-
|
|
65
|
+
const available = options(listRef.current);
|
|
66
|
+
if (!available.length)
|
|
56
67
|
return;
|
|
68
|
+
setOpen(true);
|
|
57
69
|
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
58
|
-
const next =
|
|
70
|
+
const next = available[index < 0
|
|
71
|
+
? (step > 0 ? 0 : available.length - 1)
|
|
72
|
+
: (index + step + available.length) % available.length];
|
|
59
73
|
setActiveId(next.id);
|
|
60
|
-
next.scrollIntoView({ block: "nearest" });
|
|
61
74
|
}
|
|
62
|
-
else if ((event.key === "Enter" || event.key === "Tab"
|
|
75
|
+
else if ((event.key === "Enter" || (event.key === "Tab" && !event.shiftKey)) &&
|
|
76
|
+
index >= 0) {
|
|
63
77
|
// Tab only commits a highlighted suggestion; with none it still leaves
|
|
64
78
|
// the field, so the box never traps the keyboard. Enter with no
|
|
65
79
|
// highlight falls through to the product, which may take the query.
|
|
66
80
|
event.preventDefault();
|
|
67
81
|
visible[index]?.click();
|
|
68
82
|
}
|
|
83
|
+
else if (event.key === "Enter" && event.currentTarget.value === "") {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
}
|
|
86
|
+
else if (event.key === "Escape") {
|
|
87
|
+
setOpen(false);
|
|
88
|
+
setActiveId("");
|
|
89
|
+
}
|
|
69
90
|
else if (event.key === "Backspace" &&
|
|
70
91
|
event.currentTarget.value === "" &&
|
|
71
92
|
onRemoveLast) {
|
|
@@ -74,15 +95,25 @@ function ChipInputField({ className, onKeyDown, onRemoveLast, ...props }) {
|
|
|
74
95
|
}
|
|
75
96
|
onKeyDown?.(event);
|
|
76
97
|
}
|
|
77
|
-
return (_jsx("input", { ref: fieldRef, "data-slot": "chip-input-field", role: "combobox", "aria-expanded": open, "aria-controls": listId, "aria-activedescendant": activeId
|
|
98
|
+
return (_jsx("input", { ref: fieldRef, "data-slot": "chip-input-field", role: "combobox", "aria-expanded": open, "aria-controls": listId, "aria-activedescendant": open && activeId ? activeId : undefined, "aria-autocomplete": "list", autoComplete: "off", onKeyDown: keys, onChange: (event) => {
|
|
99
|
+
if (listRef.current)
|
|
100
|
+
setOpen(true);
|
|
101
|
+
onChange?.(event);
|
|
102
|
+
}, onCompositionStart: (event) => {
|
|
103
|
+
composing.current = true;
|
|
104
|
+
onCompositionStart?.(event);
|
|
105
|
+
}, onCompositionEnd: (event) => {
|
|
106
|
+
composing.current = false;
|
|
107
|
+
onCompositionEnd?.(event);
|
|
108
|
+
}, className: cn("chip-input-field", className), ...props }));
|
|
78
109
|
}
|
|
79
110
|
function ChipInputList({ className, "aria-label": ariaLabel = "Suggestions", ...props }) {
|
|
80
|
-
const { listId, listRef, setOpen } = useChipInput();
|
|
111
|
+
const { listId, listRef, open, setOpen } = useChipInput();
|
|
81
112
|
React.useEffect(() => {
|
|
82
113
|
setOpen(true);
|
|
83
114
|
return () => setOpen(false);
|
|
84
115
|
}, [setOpen]);
|
|
85
|
-
return (_jsx("div", { id: listId, ref: listRef, role: "listbox", "aria-label": ariaLabel, "data-slot": "chip-input-list", className: cn("menu chip-input-list", className), ...props }));
|
|
116
|
+
return (_jsx("div", { id: listId, ref: listRef, role: "listbox", hidden: !open, "aria-label": ariaLabel, "data-slot": "chip-input-list", className: cn("menu chip-input-list", className), ...props }));
|
|
86
117
|
}
|
|
87
118
|
function ChipInputOption({ className, disabled = false, onSelect, onClick, ...props }) {
|
|
88
119
|
const id = React.useId();
|
|
@@ -108,10 +139,24 @@ function ChipScope({ className, ...props }) {
|
|
|
108
139
|
function ChipName({ className, ...props }) {
|
|
109
140
|
return (_jsx("span", { "data-slot": "chip-name", className: cn("chip-name truncate", className), ...props }));
|
|
110
141
|
}
|
|
142
|
+
// Resolve the control after the parent's update: adding an option can replace
|
|
143
|
+
// the editor's entire component, so a ref on its old button is not enough.
|
|
144
|
+
function restoreSegmentFocus(editor) {
|
|
145
|
+
const chip = editor?.parentElement;
|
|
146
|
+
if (!chip || !editor)
|
|
147
|
+
return;
|
|
148
|
+
const controls = 'button[data-slot], input[data-slot]';
|
|
149
|
+
const index = Array.from(chip.querySelectorAll(controls)).indexOf(editor);
|
|
150
|
+
requestAnimationFrame(() => {
|
|
151
|
+
chip.querySelectorAll(controls)[index]?.focus();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
111
154
|
// Every editable segment behaves the same: press it, it becomes an input
|
|
112
155
|
// sized to its text, Enter or the check confirms, Escape restores.
|
|
113
156
|
function EditableSegment({ slot, className, value, placeholder, display, onCommit, editable = true, editLabel, confirmLabel, maxWidth = 48, ...props }) {
|
|
114
157
|
const [draft, setDraft] = React.useState(null);
|
|
158
|
+
const composing = React.useRef(false);
|
|
159
|
+
const editorRef = React.useRef(null);
|
|
115
160
|
if (!editable || !onCommit) {
|
|
116
161
|
return (_jsx("span", { "data-slot": slot, className: cn(className), children: value ? (display ?? value) : placeholder }));
|
|
117
162
|
}
|
|
@@ -121,22 +166,27 @@ function EditableSegment({ slot, className, value, placeholder, display, onCommi
|
|
|
121
166
|
setDraft(null);
|
|
122
167
|
}
|
|
123
168
|
if (draft !== null) {
|
|
124
|
-
return (_jsxs(_Fragment, { children: [_jsx("input", { "data-slot": `${slot}-input`, className: cn(className, "is-editing"), "aria-label": editLabel, value: draft, autoFocus: true, size: 1, style: {
|
|
169
|
+
return (_jsxs(_Fragment, { children: [_jsx("input", { ref: editorRef, "data-slot": `${slot}-input`, className: cn(className, "is-editing"), "aria-label": editLabel, value: draft, autoFocus: true, size: 1, style: {
|
|
125
170
|
width: `${Math.min(Math.max(draft.length + 1, 6), maxWidth)}ch`,
|
|
126
|
-
}, onChange: (event) => setDraft(event.target.value), onBlur: () => commit(draft), onKeyDown: (event) => {
|
|
171
|
+
}, onChange: (event) => setDraft(event.target.value), onBlur: () => commit(draft), onCompositionStart: () => { composing.current = true; }, onCompositionEnd: () => { composing.current = false; }, onKeyDown: (event) => {
|
|
172
|
+
event.stopPropagation();
|
|
173
|
+
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229)
|
|
174
|
+
return;
|
|
127
175
|
if (event.key === "Enter") {
|
|
128
176
|
event.preventDefault();
|
|
177
|
+
restoreSegmentFocus(editorRef.current);
|
|
129
178
|
commit(draft);
|
|
130
179
|
}
|
|
131
180
|
else if (event.key === "Escape") {
|
|
132
181
|
event.preventDefault();
|
|
182
|
+
restoreSegmentFocus(editorRef.current);
|
|
133
183
|
setDraft(null);
|
|
134
184
|
}
|
|
135
|
-
event.stopPropagation();
|
|
136
185
|
} }), _jsx("button", { type: "button", className: "chip-action", "aria-label": confirmLabel,
|
|
137
186
|
// Commit before the input's blur can unmount this button.
|
|
138
187
|
onMouseDown: (event) => {
|
|
139
188
|
event.preventDefault();
|
|
189
|
+
restoreSegmentFocus(editorRef.current);
|
|
140
190
|
commit(draft);
|
|
141
191
|
}, children: _jsx(CheckIcon, {}) })] }));
|
|
142
192
|
}
|
|
@@ -162,8 +212,19 @@ function ChipOption({ className, name, value, label, editLabel, confirmLabel, ..
|
|
|
162
212
|
function ChipOptionAdd({ className, label, placeholder = "+", editLabel, confirmLabel, ...props }) {
|
|
163
213
|
return (_jsx(EditableSegment, { slot: "chip-option-add", className: cn("chip-option", className), value: "", placeholder: placeholder, editLabel: editLabel ?? `Add an option to ${label}`, confirmLabel: confirmLabel ?? `Confirm the new option on ${label}`, ...props }));
|
|
164
214
|
}
|
|
165
|
-
function ChipRemove({ className, ...props }) {
|
|
166
|
-
return (_jsx("button", { type: "button", "data-slot": "chip-remove", className: cn("chip-action", className), ...props,
|
|
215
|
+
function ChipRemove({ className, onClick, ...props }) {
|
|
216
|
+
return (_jsx("button", { type: "button", "data-slot": "chip-remove", className: cn("chip-action", className), ...props, onClick: (event) => {
|
|
217
|
+
const button = event.currentTarget;
|
|
218
|
+
const root = button.closest('[data-slot="chip-input"]');
|
|
219
|
+
onClick?.(event);
|
|
220
|
+
if (event.defaultPrevented)
|
|
221
|
+
return;
|
|
222
|
+
requestAnimationFrame(() => {
|
|
223
|
+
if (!button.isConnected) {
|
|
224
|
+
root?.querySelector('[data-slot="chip-input-field"]')?.focus();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}, children: _jsx(CloseIcon, {}) }));
|
|
167
228
|
}
|
|
168
229
|
function CheckIcon() {
|
|
169
230
|
return (_jsx("svg", { className: "icon", viewBox: "0 0 16 16", "aria-hidden": "true", children: _jsx("path", { d: "m3.5 8.5 3 3 6-7" }) }));
|
|
@@ -4,7 +4,7 @@ declare function CommandPalette({ className, label, children, ...props }: React.
|
|
|
4
4
|
className?: string;
|
|
5
5
|
label?: string;
|
|
6
6
|
}): React.JSX.Element;
|
|
7
|
-
declare function CommandPaletteInput({ className, onKeyDown, ...props }: React.ComponentProps<"input">): React.JSX.Element;
|
|
7
|
+
declare function CommandPaletteInput({ className, onKeyDown, onCompositionStart, onCompositionEnd, ...props }: React.ComponentProps<"input">): React.JSX.Element;
|
|
8
8
|
declare function CommandPaletteList({ className, "aria-label": ariaLabel, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
9
9
|
declare function CommandPaletteGroup({ className, heading, children, ...props }: React.ComponentProps<"div"> & {
|
|
10
10
|
heading: string;
|
package/dist/command-palette.js
CHANGED
|
@@ -20,12 +20,16 @@ function items(list) {
|
|
|
20
20
|
}
|
|
21
21
|
function CommandPalette({ className, label = "Command palette", children, ...props }) {
|
|
22
22
|
const [activeId, setActiveId] = React.useState("");
|
|
23
|
+
const composing = React.useRef(false);
|
|
23
24
|
const listId = React.useId();
|
|
24
25
|
const listRef = React.useRef(null);
|
|
25
|
-
return (_jsx(DialogPrimitive.Root, { "data-slot": "command-palette", ...props, children: _jsxs(DialogPortal, { children: [_jsx(DialogOverlay, {}), _jsxs(DialogPrimitive.Content, { "aria-modal": "true",
|
|
26
|
+
return (_jsx(DialogPrimitive.Root, { "data-slot": "command-palette", ...props, children: _jsxs(DialogPortal, { children: [_jsx(DialogOverlay, {}), _jsxs(DialogPrimitive.Content, { "aria-modal": "true", onEscapeKeyDown: (event) => {
|
|
27
|
+
if (composing.current || event.isComposing || event.keyCode === 229)
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
}, "data-slot": "command-palette-content", className: cn("palette kiso-react-palette", className), children: [_jsx(VisuallyHidden.Root, { asChild: true, children: _jsx(DialogPrimitive.Title, { children: label }) }), _jsx(CommandPaletteContext.Provider, { value: { composing, activeId, setActiveId, listId, listRef }, children: children })] })] }) }));
|
|
26
30
|
}
|
|
27
|
-
function CommandPaletteInput({ className, onKeyDown, ...props }) {
|
|
28
|
-
const { activeId, setActiveId, listId, listRef } = useCommandPalette();
|
|
31
|
+
function CommandPaletteInput({ className, onKeyDown, onCompositionStart, onCompositionEnd, ...props }) {
|
|
32
|
+
const { composing, activeId, setActiveId, listId, listRef } = useCommandPalette();
|
|
29
33
|
// The query changes the list under the highlight, so re-anchor it every
|
|
30
34
|
// render rather than tracking an index that the filter invalidates.
|
|
31
35
|
React.useEffect(() => {
|
|
@@ -35,6 +39,10 @@ function CommandPaletteInput({ className, onKeyDown, ...props }) {
|
|
|
35
39
|
}
|
|
36
40
|
});
|
|
37
41
|
function move(event) {
|
|
42
|
+
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) {
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
38
46
|
const visible = items(listRef.current);
|
|
39
47
|
const index = visible.findIndex((item) => item.id === activeId);
|
|
40
48
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
@@ -52,7 +60,13 @@ function CommandPaletteInput({ className, onKeyDown, ...props }) {
|
|
|
52
60
|
}
|
|
53
61
|
onKeyDown?.(event);
|
|
54
62
|
}
|
|
55
|
-
return (_jsxs("div", { className: "palette-input", "data-slot": "command-palette-input", children: [_jsxs("svg", { className: "icon muted", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "7", cy: "7", r: "4.5" }), _jsx("path", { d: "m10.5 10.5 3 3" })] }), _jsx("input", { role: "combobox", "aria-expanded": "true", "aria-controls": listId, "aria-activedescendant": activeId || undefined, "aria-autocomplete": "list", autoComplete: "off", autoFocus: true, onKeyDown: move,
|
|
63
|
+
return (_jsxs("div", { className: "palette-input", "data-slot": "command-palette-input", children: [_jsxs("svg", { className: "icon muted", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "7", cy: "7", r: "4.5" }), _jsx("path", { d: "m10.5 10.5 3 3" })] }), _jsx("input", { role: "combobox", "aria-expanded": "true", "aria-controls": listId, "aria-activedescendant": activeId || undefined, "aria-autocomplete": "list", autoComplete: "off", autoFocus: true, onKeyDown: move, onCompositionStart: (event) => {
|
|
64
|
+
composing.current = true;
|
|
65
|
+
onCompositionStart?.(event);
|
|
66
|
+
}, onCompositionEnd: (event) => {
|
|
67
|
+
composing.current = false;
|
|
68
|
+
onCompositionEnd?.(event);
|
|
69
|
+
}, className: className, ...props })] }));
|
|
56
70
|
}
|
|
57
71
|
function CommandPaletteList({ className, "aria-label": ariaLabel = "Commands", ...props }) {
|
|
58
72
|
const { listId, listRef } = useCommandPalette();
|
package/dist/empty-state.js
CHANGED
|
@@ -3,8 +3,8 @@ import { cva } from "class-variance-authority";
|
|
|
3
3
|
import { clsx as cn } from "clsx";
|
|
4
4
|
import { Slot } from "radix-ui";
|
|
5
5
|
// Emptiness is not an error: the variants change which action belongs here, not
|
|
6
|
-
// the colour of the surface.
|
|
7
|
-
const emptyStateVariants = cva("empty", {
|
|
6
|
+
// the colour of the surface. The hatch says "this region is not data".
|
|
7
|
+
const emptyStateVariants = cva("empty hatch", {
|
|
8
8
|
variants: {
|
|
9
9
|
variant: { "first-run": "", "no-results": "", informational: "" },
|
|
10
10
|
size: { md: "", sm: "empty-sm" },
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Structured filters. Parsing is opt-in and does not change ChipInput. */
|
|
2
|
+
export type FilterOperator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "IN" | "CONTAINS" | "IS NULL";
|
|
3
|
+
export type FilterScalar = string | number;
|
|
4
|
+
export type FilterJoin = "AND" | "OR";
|
|
5
|
+
export type FilterField = {
|
|
6
|
+
key: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
type: "text" | "number";
|
|
9
|
+
nullable?: boolean;
|
|
10
|
+
operators?: readonly FilterOperator[];
|
|
11
|
+
values?: readonly FilterScalar[];
|
|
12
|
+
};
|
|
13
|
+
export type FilterCondition = {
|
|
14
|
+
kind: "condition";
|
|
15
|
+
field: string;
|
|
16
|
+
operator: FilterOperator;
|
|
17
|
+
value: FilterScalar | FilterScalar[] | null;
|
|
18
|
+
/** The first node's join is ignored. AND binds before OR within each group. */
|
|
19
|
+
join: FilterJoin;
|
|
20
|
+
};
|
|
21
|
+
export type FilterGroup = {
|
|
22
|
+
kind: "group";
|
|
23
|
+
children: FilterNode[];
|
|
24
|
+
join: FilterJoin;
|
|
25
|
+
};
|
|
26
|
+
export type FilterNode = FilterCondition | FilterGroup;
|
|
27
|
+
export type FilterParseResult = {
|
|
28
|
+
ok: true;
|
|
29
|
+
value: FilterNode[];
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
export type FilterSuggestion = {
|
|
35
|
+
label: string;
|
|
36
|
+
text: string;
|
|
37
|
+
description: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function filterOperators(field: FilterField): readonly FilterOperator[];
|
|
40
|
+
export declare function formatFilterScalar(value: FilterScalar): string;
|
|
41
|
+
export declare function formatFilterValue(condition: FilterCondition): string;
|
|
42
|
+
export declare function serializeFilterNode(node: FilterNode): string;
|
|
43
|
+
export declare function serializeFilterExpression(nodes: readonly FilterNode[]): string;
|
|
44
|
+
/** Empty input is an empty filter. Incomplete input returns an error without partial results. */
|
|
45
|
+
export declare function parseFilterExpression(source: string, fields: readonly FilterField[], options?: {
|
|
46
|
+
allowLeadingJoin?: boolean;
|
|
47
|
+
}): FilterParseResult;
|
|
48
|
+
/** Suggestions replace only the condition at the end of the draft, including inside groups. */
|
|
49
|
+
export declare function getFilterSuggestions(source: string, fields: readonly FilterField[], existing?: boolean): FilterSuggestion[];
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
const textOperators = ["=", "!=", "IN", "CONTAINS"];
|
|
2
|
+
const numberOperators = ["=", "!=", ">", ">=", "<", "<=", "IN"];
|
|
3
|
+
export function filterOperators(field) {
|
|
4
|
+
const supported = field.type === "number" ? numberOperators : textOperators;
|
|
5
|
+
const allowed = field.nullable ? [...supported, "IS NULL"] : supported;
|
|
6
|
+
return field.operators ? field.operators.filter(operator => allowed.includes(operator)) : allowed;
|
|
7
|
+
}
|
|
8
|
+
export function formatFilterScalar(value) {
|
|
9
|
+
return typeof value === "number" || /^[\w.-]+$/.test(value) && !/^(AND|OR)$/i.test(value)
|
|
10
|
+
? String(value) : JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
export function formatFilterValue(condition) {
|
|
13
|
+
return condition.value === null ? "" : Array.isArray(condition.value)
|
|
14
|
+
? `(${condition.value.map(formatFilterScalar).join(", ")})`
|
|
15
|
+
: formatFilterScalar(condition.value);
|
|
16
|
+
}
|
|
17
|
+
export function serializeFilterNode(node) {
|
|
18
|
+
return node.kind === "group" ? `(${serializeFilterExpression(node.children)})`
|
|
19
|
+
: `${node.field} ${node.operator} ${formatFilterValue(node)}`.trim();
|
|
20
|
+
}
|
|
21
|
+
export function serializeFilterExpression(nodes) {
|
|
22
|
+
return nodes.map((node, index) => `${index ? `${node.join} ` : ""}${serializeFilterNode(node)}`).join(" ");
|
|
23
|
+
}
|
|
24
|
+
function tokens(source, incomplete = false) {
|
|
25
|
+
const result = [];
|
|
26
|
+
let index = 0;
|
|
27
|
+
while (index < source.length) {
|
|
28
|
+
if (/\s/.test(source[index])) {
|
|
29
|
+
index++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const start = index;
|
|
33
|
+
const quote = source[index];
|
|
34
|
+
if (quote === '"' || quote === "'") {
|
|
35
|
+
let text = "";
|
|
36
|
+
index++;
|
|
37
|
+
while (index < source.length && source[index] !== quote) {
|
|
38
|
+
if (source[index] === "\\") {
|
|
39
|
+
index++;
|
|
40
|
+
if (index === source.length)
|
|
41
|
+
break;
|
|
42
|
+
const escape = source[index++];
|
|
43
|
+
if (escape === "u") {
|
|
44
|
+
const hex = source.slice(index, index + 4);
|
|
45
|
+
if (!/^[0-9a-f]{4}$/i.test(hex)) {
|
|
46
|
+
throw new Error("Complete the Unicode escape with four hexadecimal digits.");
|
|
47
|
+
}
|
|
48
|
+
text += String.fromCharCode(parseInt(hex, 16));
|
|
49
|
+
index += 4;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
text += { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f" }[escape] ?? escape;
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
text += source[index++];
|
|
56
|
+
}
|
|
57
|
+
if (source[index] !== quote && !incomplete)
|
|
58
|
+
throw new Error("Close the quoted value before confirming.");
|
|
59
|
+
if (source[index] === quote)
|
|
60
|
+
index++;
|
|
61
|
+
result.push({ text, quoted: true, start, end: index });
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const match = /^(>=|<=|!=|[=<>:~(),\[\]]|[^\s=<>!:~(),\[\]"']+)/.exec(source.slice(index));
|
|
65
|
+
if (!match) {
|
|
66
|
+
// A final "!" may become "!="; any other unexpected character stays an error.
|
|
67
|
+
if (incomplete && source.slice(index) === "!") {
|
|
68
|
+
result.push({ text: "!", quoted: false, start, end: ++index });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Unexpected character "${source[index]}". Choose a suggested operator.`);
|
|
72
|
+
}
|
|
73
|
+
index += match[0].length;
|
|
74
|
+
result.push({ text: match[0], quoted: false, start, end: index });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/** Empty input is an empty filter. Incomplete input returns an error without partial results. */
|
|
80
|
+
export function parseFilterExpression(source, fields, options = {}) {
|
|
81
|
+
try {
|
|
82
|
+
const input = tokens(source);
|
|
83
|
+
let index = 0;
|
|
84
|
+
const is = (text) => !input[index]?.quoted && input[index]?.text.toUpperCase() === text;
|
|
85
|
+
function take(message) {
|
|
86
|
+
const token = input[index++];
|
|
87
|
+
if (!token)
|
|
88
|
+
throw new Error(message);
|
|
89
|
+
return token;
|
|
90
|
+
}
|
|
91
|
+
function sequence(nested = false, depth = 0) {
|
|
92
|
+
// Bound recursion for pasted or generated input; this is not a UI nesting limit.
|
|
93
|
+
if (depth > 64)
|
|
94
|
+
throw new Error("Too many nested groups. Use fewer than 65 levels.");
|
|
95
|
+
const nodes = [];
|
|
96
|
+
while (index < input.length && !(nested && is(")"))) {
|
|
97
|
+
let join = "AND";
|
|
98
|
+
if (is("AND") || is("OR")) {
|
|
99
|
+
if (!nodes.length && !(options.allowLeadingJoin && !nested))
|
|
100
|
+
throw new Error("Add a condition before AND or OR.");
|
|
101
|
+
join = take("").text.toUpperCase();
|
|
102
|
+
}
|
|
103
|
+
if (is("(")) {
|
|
104
|
+
index++;
|
|
105
|
+
const children = sequence(true, depth + 1);
|
|
106
|
+
if (!is(")"))
|
|
107
|
+
throw new Error("Close the filter group with ).");
|
|
108
|
+
index++;
|
|
109
|
+
nodes.push({ kind: "group", children, join });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (is(")"))
|
|
113
|
+
throw new Error("This ) has no matching filter group.");
|
|
114
|
+
const name = take("Type a field after the connector.");
|
|
115
|
+
const field = fields.find(field => field.key.toLowerCase() === name.text.toLowerCase());
|
|
116
|
+
if (!field || name.quoted)
|
|
117
|
+
throw new Error(`Unknown field "${name.text}". Choose a suggested field.`);
|
|
118
|
+
let operator = take(`Choose an operator for ${field.label ?? field.key}.`).text.toUpperCase();
|
|
119
|
+
if (operator === ":")
|
|
120
|
+
operator = "=";
|
|
121
|
+
if (operator === "~")
|
|
122
|
+
operator = "CONTAINS";
|
|
123
|
+
if (operator === "IS") {
|
|
124
|
+
if (!is("NULL"))
|
|
125
|
+
throw new Error("Complete IS NULL, or choose another operator.");
|
|
126
|
+
index++;
|
|
127
|
+
operator = "IS NULL";
|
|
128
|
+
}
|
|
129
|
+
if (!filterOperators(field).includes(operator)) {
|
|
130
|
+
throw new Error(`${field.label ?? field.key} accepts ${filterOperators(field).join(", ")}.`);
|
|
131
|
+
}
|
|
132
|
+
const scalar = () => {
|
|
133
|
+
const token = take(`Type a value for ${field.label ?? field.key}.`);
|
|
134
|
+
if (!token.quoted && /^(AND|OR|[(),\[\]])$/i.test(token.text))
|
|
135
|
+
throw new Error(`Type a value for ${field.label ?? field.key}. Quote text containing spaces or reserved words.`);
|
|
136
|
+
let value = token.text;
|
|
137
|
+
if (field.type === "number") {
|
|
138
|
+
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(token.text) || !Number.isFinite(Number(token.text)))
|
|
139
|
+
throw new Error(`${field.label ?? field.key} needs a finite number.`);
|
|
140
|
+
value = Number(token.text);
|
|
141
|
+
}
|
|
142
|
+
if (field.values && !field.values.includes(value))
|
|
143
|
+
throw new Error(`Choose a listed value for ${field.label ?? field.key}.`);
|
|
144
|
+
return value;
|
|
145
|
+
};
|
|
146
|
+
let value = null;
|
|
147
|
+
if (operator === "IN") {
|
|
148
|
+
if (!is("(") && !is("["))
|
|
149
|
+
throw new Error("Write IN (a, b), with comma-separated values.");
|
|
150
|
+
const closing = take("").text === "(" ? ")" : "]";
|
|
151
|
+
const values = [scalar()];
|
|
152
|
+
while (is(",")) {
|
|
153
|
+
index++;
|
|
154
|
+
// Completion leaves a comma ready for another value; closing ends the list.
|
|
155
|
+
if (is(closing))
|
|
156
|
+
break;
|
|
157
|
+
values.push(scalar());
|
|
158
|
+
}
|
|
159
|
+
if (!is(closing))
|
|
160
|
+
throw new Error(`Separate IN values with commas and close the list with ${closing}.`);
|
|
161
|
+
index++;
|
|
162
|
+
value = [...new Set(values)];
|
|
163
|
+
}
|
|
164
|
+
else if (operator !== "IS NULL")
|
|
165
|
+
value = scalar();
|
|
166
|
+
nodes.push({ kind: "condition", field: field.key, operator: operator, value, join });
|
|
167
|
+
}
|
|
168
|
+
if (nested && !nodes.length)
|
|
169
|
+
throw new Error("Add a condition inside the group.");
|
|
170
|
+
return nodes;
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, value: sequence() };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return { ok: false, error: error.message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Suggestions replace only the condition at the end of the draft, including inside groups. */
|
|
179
|
+
export function getFilterSuggestions(source, fields, existing = false) {
|
|
180
|
+
const complete = parseFilterExpression(source, fields, { allowLeadingJoin: existing });
|
|
181
|
+
if (complete.ok && complete.value.length)
|
|
182
|
+
return [];
|
|
183
|
+
let input;
|
|
184
|
+
// Never suggest past a lexical error: accepting would drop the unread suffix.
|
|
185
|
+
try {
|
|
186
|
+
input = tokens(source, true);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
let start = 0;
|
|
192
|
+
const stack = [];
|
|
193
|
+
input.forEach((token, index) => {
|
|
194
|
+
// A complete prefix marks an implicit AND boundary, using the parser grammar.
|
|
195
|
+
if (!stack.some(frame => frame.kind === "list")) {
|
|
196
|
+
const preceding = parseFilterExpression(source.slice(start, token.start), fields, { allowLeadingJoin: true });
|
|
197
|
+
if (preceding.ok && preceding.value.length)
|
|
198
|
+
start = token.start;
|
|
199
|
+
}
|
|
200
|
+
if (token.quoted)
|
|
201
|
+
return;
|
|
202
|
+
if (token.text === "(" || token.text === "[") {
|
|
203
|
+
const list = input[index - 1]?.text.toUpperCase() === "IN" && !input[index - 1].quoted;
|
|
204
|
+
stack.push({ kind: list ? "list" : "group", start });
|
|
205
|
+
if (!list)
|
|
206
|
+
start = token.end;
|
|
207
|
+
}
|
|
208
|
+
else if (token.text === ")" || token.text === "]") {
|
|
209
|
+
const frame = stack.pop();
|
|
210
|
+
if (frame?.kind === "group")
|
|
211
|
+
start = frame.start;
|
|
212
|
+
}
|
|
213
|
+
else if (!stack.some(frame => frame.kind === "list") && /^(AND|OR)$/i.test(token.text) && /\s/.test(source[token.end] ?? ""))
|
|
214
|
+
start = token.end;
|
|
215
|
+
});
|
|
216
|
+
const prefix = source.slice(0, start);
|
|
217
|
+
const tail = source.slice(start).trimStart();
|
|
218
|
+
const leadingSpace = source.slice(start).slice(0, source.slice(start).length - tail.length);
|
|
219
|
+
const wrap = (suggestions) => suggestions.map(suggestion => ({ ...suggestion, text: prefix + leadingSpace + suggestion.text }));
|
|
220
|
+
const tailResult = parseFilterExpression(tail, fields, { allowLeadingJoin: true });
|
|
221
|
+
if (tailResult.ok && tailResult.value.length)
|
|
222
|
+
return [];
|
|
223
|
+
const condition = tokens(tail, true);
|
|
224
|
+
const first = condition[0];
|
|
225
|
+
const field = fields.find(field => field.key.toLowerCase() === first?.text.toLowerCase());
|
|
226
|
+
if (!field || condition.length === 1 && !/\s$/.test(tail)) {
|
|
227
|
+
const suggestions = fields.filter(field => field.key.toLowerCase().startsWith(tail.toLowerCase()) || field.label?.toLowerCase().startsWith(tail.toLowerCase()))
|
|
228
|
+
.map(field => ({ label: field.label ?? field.key, text: `${field.key} `, description: field.type === "number" ? "Number" : "Text" }));
|
|
229
|
+
if (existing && !prefix && /^(A|AN|AND|O|OR)$/i.test(tail)) {
|
|
230
|
+
const join = tail.toUpperCase().startsWith("A") ? "AND" : "OR";
|
|
231
|
+
suggestions.push({ label: join, text: `${join} `, description: "Combine with the next filter" });
|
|
232
|
+
}
|
|
233
|
+
return wrap(suggestions);
|
|
234
|
+
}
|
|
235
|
+
const remainder = tail.slice(first.end).trimStart();
|
|
236
|
+
const operatorMatch = /^(IS NULL|CONTAINS|IN|!=|>=|<=|[=<>:~])\s*(.*)$/i.exec(remainder);
|
|
237
|
+
if (!operatorMatch)
|
|
238
|
+
return wrap(filterOperators(field).filter(operator => operator.startsWith(remainder.toUpperCase())).map(operator => ({ label: operator, text: `${field.key} ${operator} `, description: operator === "IN" ? "Several values" : "Operator" })));
|
|
239
|
+
const operator = operatorMatch[1].toUpperCase();
|
|
240
|
+
const rawValue = operatorMatch[2];
|
|
241
|
+
if (operator === "IS NULL")
|
|
242
|
+
return [];
|
|
243
|
+
if (operator === "IN") {
|
|
244
|
+
const valueTokens = tokens(rawValue, true);
|
|
245
|
+
// Complete only "(a, b, c": members alternate with commas and the list stays open.
|
|
246
|
+
const opened = !!valueTokens[0] && !valueTokens[0].quoted && /^[(\[]$/.test(valueTokens[0].text);
|
|
247
|
+
const listTokens = opened ? valueTokens.slice(1) : valueTokens;
|
|
248
|
+
const wellFormed = listTokens.every((token, index) => index % 2
|
|
249
|
+
? token.text === "," && !token.quoted
|
|
250
|
+
: token.quoted || !/^(AND|OR|[(),\[\]])$/i.test(token.text));
|
|
251
|
+
if (!wellFormed || (!opened && listTokens.length > 1))
|
|
252
|
+
return [];
|
|
253
|
+
const lastComma = [...valueTokens].reverse().find(token => token.text === "," && !token.quoted);
|
|
254
|
+
const partial = lastComma ? rawValue.slice(lastComma.end).trim() : rawValue.replace(/^\s*[(\[]/, "").trim();
|
|
255
|
+
const committed = lastComma ? rawValue.slice(0, lastComma.end).replace(/^\s*[(\[]/, "").trim() : "";
|
|
256
|
+
const search = tokens(partial, true)[0]?.text ?? "";
|
|
257
|
+
return wrap((field.values ?? []).filter(value => String(value).toLowerCase().startsWith(search.toLowerCase())).map(value => ({ label: String(value), text: `${field.key} IN (${committed ? `${committed} ` : ""}${formatFilterScalar(value)}, `, description: "Add value; type ) to finish" })));
|
|
258
|
+
}
|
|
259
|
+
const valueTokens = tokens(rawValue, true);
|
|
260
|
+
if (valueTokens.length > 1)
|
|
261
|
+
return [];
|
|
262
|
+
const partial = valueTokens[0]?.text ?? "";
|
|
263
|
+
return wrap((field.values ?? []).filter(value => String(value).toLowerCase().startsWith(partial.toLowerCase())).map(value => ({ label: String(value), text: `${field.key} ${operator} ${formatFilterScalar(value)}`, description: "Complete condition" })));
|
|
264
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type FilterField, type FilterNode } from "./filter-expression.js";
|
|
3
|
+
export type FilterInputProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> & {
|
|
4
|
+
label: string;
|
|
5
|
+
fields: readonly FilterField[];
|
|
6
|
+
value: FilterNode[];
|
|
7
|
+
onValueChange: (value: FilterNode[]) => void;
|
|
8
|
+
/** Observe uncommitted text, for example to prevent submitting an incomplete search. */
|
|
9
|
+
onDraftChange?: (draft: string) => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Continuous typing creates segmented filters; the product owns querying and SQL. */
|
|
14
|
+
export declare function FilterInput({ id: providedId, label, fields, value, onValueChange, onDraftChange, disabled, placeholder, className, ...props }: FilterInputProps): React.JSX.Element;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { clsx as cn } from "clsx";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { Chip, ChipRemove } from "./chip-input.js";
|
|
7
|
+
import { filterOperators, formatFilterValue, getFilterSuggestions, parseFilterExpression, serializeFilterExpression, serializeFilterNode, } from "./filter-expression.js";
|
|
8
|
+
function nodeAt(nodes, path) {
|
|
9
|
+
let node;
|
|
10
|
+
for (const index of path) {
|
|
11
|
+
node = nodes[index];
|
|
12
|
+
nodes = node?.kind === "group" ? node.children : [];
|
|
13
|
+
}
|
|
14
|
+
return node;
|
|
15
|
+
}
|
|
16
|
+
function replaceNode(nodes, path, replacement) {
|
|
17
|
+
return nodes.flatMap((node, index) => {
|
|
18
|
+
if (index !== path[0])
|
|
19
|
+
return [node];
|
|
20
|
+
if (path.length === 1)
|
|
21
|
+
return replacement ? [replacement] : [];
|
|
22
|
+
if (node.kind !== "group")
|
|
23
|
+
return [node];
|
|
24
|
+
const children = replaceNode(node.children, path.slice(1), replacement);
|
|
25
|
+
return children.length ? [{ ...node, children }] : [];
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const controlKey = (path, segment) => `${path.join("-")}-${segment}`;
|
|
29
|
+
/** Continuous typing creates segmented filters; the product owns querying and SQL. */
|
|
30
|
+
export function FilterInput({ id: providedId, label, fields, value, onValueChange, onDraftChange, disabled = false, placeholder = "status=active OR region IN (eu, us)", className, ...props }) {
|
|
31
|
+
const generatedId = React.useId();
|
|
32
|
+
const id = providedId ?? generatedId;
|
|
33
|
+
const host = React.useRef(null);
|
|
34
|
+
const input = React.useRef(null);
|
|
35
|
+
const composing = React.useRef(false);
|
|
36
|
+
const [draft, setDraft] = React.useState("");
|
|
37
|
+
const [error, setError] = React.useState("");
|
|
38
|
+
const [message, setAnnouncement] = React.useState({ text: "", revision: 0 });
|
|
39
|
+
const [focused, setFocused] = React.useState(false);
|
|
40
|
+
const [dismissed, setDismissed] = React.useState(false);
|
|
41
|
+
const [active, setActive] = React.useState(0);
|
|
42
|
+
const [edit, setEdit] = React.useState(null);
|
|
43
|
+
const [wholeExpression, setWholeExpression] = React.useState(false);
|
|
44
|
+
const suggestions = getFilterSuggestions(draft, fields, !wholeExpression && value.length > 0);
|
|
45
|
+
const visible = !disabled && !edit && focused && !dismissed && suggestions.length > 0;
|
|
46
|
+
const selected = Math.min(active, Math.max(0, suggestions.length - 1));
|
|
47
|
+
const parsed = parseFilterExpression(draft, fields, { allowLeadingJoin: !wholeExpression && value.length > 0 });
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (edit && !nodeAt(value, edit.path))
|
|
50
|
+
setEdit(null);
|
|
51
|
+
}, [value, edit]);
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (visible)
|
|
54
|
+
host.current?.querySelector(`#${CSS.escape(`${id}-option-${selected}`)}`)?.scrollIntoView({ block: "nearest" });
|
|
55
|
+
}, [selected, visible, id]);
|
|
56
|
+
// Replace the live region content even when consecutive actions use the same text.
|
|
57
|
+
function setMessage(text) {
|
|
58
|
+
setAnnouncement(previous => ({ text, revision: previous.revision + 1 }));
|
|
59
|
+
}
|
|
60
|
+
function updateDraft(text) {
|
|
61
|
+
setDraft(text);
|
|
62
|
+
onDraftChange?.(text);
|
|
63
|
+
setError("");
|
|
64
|
+
setActive(0);
|
|
65
|
+
setDismissed(false);
|
|
66
|
+
}
|
|
67
|
+
function commit(text = draft, reportError = true) {
|
|
68
|
+
if (disabled || !text.trim() && !wholeExpression)
|
|
69
|
+
return false;
|
|
70
|
+
const result = parseFilterExpression(text, fields, { allowLeadingJoin: !wholeExpression && value.length > 0 });
|
|
71
|
+
if (!result.ok) {
|
|
72
|
+
if (reportError)
|
|
73
|
+
setError(result.error);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
onValueChange(wholeExpression ? result.value : [...value, ...result.value]);
|
|
77
|
+
setWholeExpression(false);
|
|
78
|
+
updateDraft("");
|
|
79
|
+
setDismissed(true);
|
|
80
|
+
setMessage("Filters updated. Keep typing to add another condition.");
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
function typed(text, caretAtEnd) {
|
|
84
|
+
updateDraft(text);
|
|
85
|
+
if (!composing.current && !wholeExpression && caretAtEnd && /(?:\s|[)\]])$/.test(text))
|
|
86
|
+
commit(text, false);
|
|
87
|
+
}
|
|
88
|
+
function choose(index) {
|
|
89
|
+
const suggestion = suggestions[index];
|
|
90
|
+
if (!suggestion || disabled)
|
|
91
|
+
return;
|
|
92
|
+
updateDraft(suggestion.text);
|
|
93
|
+
if (!wholeExpression)
|
|
94
|
+
commit(suggestion.text, false);
|
|
95
|
+
input.current?.focus();
|
|
96
|
+
}
|
|
97
|
+
function restoreFocus(path, segment) {
|
|
98
|
+
requestAnimationFrame(() => {
|
|
99
|
+
const button = host.current?.querySelector(`[data-filter-control="${controlKey(path, segment)}"]`);
|
|
100
|
+
(button ?? input.current)?.focus();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function closeEdit() {
|
|
104
|
+
if (!edit)
|
|
105
|
+
return;
|
|
106
|
+
restoreFocus(edit.path, edit.segment);
|
|
107
|
+
setEdit(null);
|
|
108
|
+
}
|
|
109
|
+
function beginEdit(path, segment) {
|
|
110
|
+
if (disabled || edit || wholeExpression || draft.trim())
|
|
111
|
+
return;
|
|
112
|
+
const node = nodeAt(value, path);
|
|
113
|
+
if (!node)
|
|
114
|
+
return;
|
|
115
|
+
const text = node.kind === "group" ? serializeFilterExpression(node.children)
|
|
116
|
+
: segment === "field" ? node.field : segment === "operator" ? node.operator : formatFilterValue(node);
|
|
117
|
+
setEdit({ path, segment, text, error: "" });
|
|
118
|
+
setDismissed(true);
|
|
119
|
+
}
|
|
120
|
+
function saveEdit() {
|
|
121
|
+
if (!edit || disabled)
|
|
122
|
+
return;
|
|
123
|
+
const node = nodeAt(value, edit.path);
|
|
124
|
+
if (!node) {
|
|
125
|
+
closeEdit();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
let candidate = edit.text;
|
|
129
|
+
if (node.kind === "condition") {
|
|
130
|
+
let operator = edit.operator ?? node.operator;
|
|
131
|
+
let nextValue = node.value;
|
|
132
|
+
if (edit.segment === "operator") {
|
|
133
|
+
const entered = edit.text.trim().toUpperCase();
|
|
134
|
+
operator = (entered === ":" ? "=" : entered === "~" ? "CONTAINS" : entered);
|
|
135
|
+
const field = fields.find(field => field.key === node.field);
|
|
136
|
+
if (!field || !filterOperators(field).includes(operator)) {
|
|
137
|
+
setEdit({ ...edit, error: "Choose an operator supported by this field." });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (operator === "IS NULL")
|
|
141
|
+
nextValue = null;
|
|
142
|
+
else if (nextValue === null || Array.isArray(nextValue) && operator !== "IN" && nextValue.length > 1) {
|
|
143
|
+
setEdit({ ...edit, segment: "value", operator, text: "", error: "Type a value for the new operator, then press Enter." });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
else if (operator === "IN" && !Array.isArray(nextValue))
|
|
147
|
+
nextValue = [nextValue];
|
|
148
|
+
else if (operator !== "IN" && Array.isArray(nextValue))
|
|
149
|
+
nextValue = nextValue[0];
|
|
150
|
+
}
|
|
151
|
+
const field = edit.segment === "field" ? edit.text : node.field;
|
|
152
|
+
const editedValue = edit.segment === "value"
|
|
153
|
+
? operator === "IN" && !/^\s*[(\[]/.test(edit.text) ? `(${edit.text})` : edit.text
|
|
154
|
+
: formatFilterValue({ ...node, value: nextValue });
|
|
155
|
+
candidate = `${field} ${operator} ${operator === "IS NULL" ? "" : editedValue}`;
|
|
156
|
+
}
|
|
157
|
+
const result = parseFilterExpression(candidate, fields);
|
|
158
|
+
if (!result.ok || !result.value.length || node.kind === "condition" && (result.value.length !== 1 || result.value[0].kind !== "condition")) {
|
|
159
|
+
setEdit({ ...edit, error: !result.ok ? result.error : "Enter a valid condition. Groups cannot be empty." });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const next = node.kind === "group" ? { ...node, children: result.value } : { ...result.value[0], join: node.join };
|
|
163
|
+
onValueChange(replaceNode(value, edit.path, next));
|
|
164
|
+
setMessage("Filter updated.");
|
|
165
|
+
closeEdit();
|
|
166
|
+
}
|
|
167
|
+
function remove(path) {
|
|
168
|
+
if (disabled)
|
|
169
|
+
return;
|
|
170
|
+
onValueChange(replaceNode(value, path, null));
|
|
171
|
+
setEdit(null);
|
|
172
|
+
setMessage("Filter removed.");
|
|
173
|
+
input.current?.focus();
|
|
174
|
+
}
|
|
175
|
+
function keys(event) {
|
|
176
|
+
if (composing.current || event.nativeEvent.isComposing)
|
|
177
|
+
return;
|
|
178
|
+
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && suggestions.length) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
setDismissed(false);
|
|
181
|
+
setActive(visible ? (selected + (event.key === "ArrowDown" ? 1 : -1) + suggestions.length) % suggestions.length : event.key === "ArrowDown" ? 0 : suggestions.length - 1);
|
|
182
|
+
}
|
|
183
|
+
else if (event.key === "Tab" && !event.shiftKey && visible) {
|
|
184
|
+
event.preventDefault();
|
|
185
|
+
choose(selected);
|
|
186
|
+
}
|
|
187
|
+
else if (event.key === "Enter") {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
if (parsed.ok && (parsed.value.length || wholeExpression))
|
|
190
|
+
commit();
|
|
191
|
+
else if (visible && draft.trim())
|
|
192
|
+
choose(selected);
|
|
193
|
+
else if (draft.trim())
|
|
194
|
+
commit();
|
|
195
|
+
}
|
|
196
|
+
else if (event.key === "Escape") {
|
|
197
|
+
setDismissed(true);
|
|
198
|
+
setError("");
|
|
199
|
+
if (wholeExpression) {
|
|
200
|
+
setWholeExpression(false);
|
|
201
|
+
updateDraft("");
|
|
202
|
+
setDismissed(true);
|
|
203
|
+
setMessage("Expression edit canceled. Filters unchanged.");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (event.key === "Backspace" && !draft && !wholeExpression && value.length) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
const last = value.at(-1);
|
|
209
|
+
onValueChange(value.slice(0, -1));
|
|
210
|
+
updateDraft(`${value.length > 1 ? `${last.join} ` : ""}${serializeFilterNode(last)}`);
|
|
211
|
+
setMessage("Last filter restored as text. Edit it and press Enter.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function editor() {
|
|
215
|
+
if (!edit)
|
|
216
|
+
return null;
|
|
217
|
+
const node = nodeAt(value, edit.path);
|
|
218
|
+
return _jsxs("span", { className: "filter-editor", children: [_jsx("input", { autoFocus: true, disabled: disabled, className: "filter-segment-input", "aria-label": `Edit ${edit.segment} in ${node ? serializeFilterNode(node) : "filter"}`, "aria-invalid": !!edit.error, "aria-describedby": `${id}-edit-hint`, value: edit.text, size: Math.max(6, Math.min(edit.text.length + 1, 48)), onFocus: event => event.target.select(), onChange: event => setEdit({ ...edit, text: event.target.value, error: "" }), onKeyDown: event => {
|
|
219
|
+
event.stopPropagation();
|
|
220
|
+
if (event.nativeEvent.isComposing)
|
|
221
|
+
return;
|
|
222
|
+
if (event.key === "Enter") {
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
saveEdit();
|
|
225
|
+
}
|
|
226
|
+
if (event.key === "Escape") {
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
closeEdit();
|
|
229
|
+
}
|
|
230
|
+
} }, controlKey(edit.path, edit.segment)), _jsxs("span", { className: "filter-edit-actions", children: [_jsx(Button, { size: "xs", onClick: saveEdit, disabled: disabled, children: "Save" }), _jsx(Button, { size: "xs", variant: "ghost", onClick: closeEdit, disabled: disabled, children: "Cancel" })] })] });
|
|
231
|
+
}
|
|
232
|
+
const busy = !!edit || wholeExpression || !!draft.trim();
|
|
233
|
+
function renderNodes(nodes, parent = []) {
|
|
234
|
+
return nodes.map((node, index) => {
|
|
235
|
+
const path = [...parent, index];
|
|
236
|
+
const key = path.join("-");
|
|
237
|
+
const editingHere = edit?.path.join("-") === key;
|
|
238
|
+
const description = serializeFilterNode(node);
|
|
239
|
+
function segment(name, content) {
|
|
240
|
+
return editingHere && edit?.segment === name ? editor() : _jsx("button", { type: "button", className: `filter-segment filter-${name}`, "data-filter-control": controlKey(path, name), disabled: disabled || busy, "aria-label": `Edit ${name} in ${description}`, onClick: () => beginEdit(path, name), children: content });
|
|
241
|
+
}
|
|
242
|
+
return _jsxs(React.Fragment, { children: [index > 0 && _jsx("button", { type: "button", className: "filter-join", "data-join": node.join, disabled: disabled || busy, "aria-label": `Change ${node.join} before ${description}`, onClick: () => onValueChange(replaceNode(value, path, { ...node, join: node.join === "AND" ? "OR" : "AND" })), children: node.join }), node.kind === "group" ? _jsxs("span", { className: "filter-group", role: "group", "aria-label": `Filter group: ${serializeFilterExpression(node.children)}`, children: [_jsx("button", { type: "button", className: "filter-boundary", "data-filter-control": controlKey(path, "group"), disabled: disabled || busy, "aria-label": `Edit group ${description}`, onClick: () => beginEdit(path, "group"), children: "(" }), editingHere ? editor() : renderNodes(node.children, path), _jsx("span", { className: "filter-boundary", "aria-hidden": "true", children: ")" }), _jsx(ChipRemove, { disabled: disabled || busy, "aria-label": `Remove group ${description}`, onClick: () => remove(path) })] }) : _jsxs(Chip, { className: "filter-chip", invalid: editingHere && !!edit?.error, children: [segment("field", fields.find(field => field.key === node.field)?.label ?? node.field), segment("operator", editingHere && edit?.operator ? edit.operator : node.operator), (editingHere && edit?.operator ? edit.operator : node.operator) !== "IS NULL" && segment("value", Array.isArray(node.value)
|
|
243
|
+
? _jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "(" }), node.value.map((item, i) => _jsxs(React.Fragment, { children: [i > 0 && _jsx("span", { children: "," }), _jsx("span", { className: "filter-list-value", children: String(item) })] }, i)), _jsx("span", { "aria-hidden": "true", children: ")" })] })
|
|
244
|
+
: node.value === "" ? '""' : String(node.value)), _jsx(ChipRemove, { disabled: disabled || busy, "aria-label": `Remove ${description}`, onClick: () => remove(path) })] })] }, key);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return _jsxs("div", { ...props, ref: host, "data-slot": "filter-input", className: cn("filter-input", className), children: [_jsx("label", { className: "label", htmlFor: id, children: label }), _jsxs("div", { className: "chip-input", children: [_jsxs("div", { className: "chip-input-box filter-box", "data-disabled": disabled || undefined, "aria-invalid": !!error || undefined, onMouseDown: event => { if (event.target === event.currentTarget && !disabled) {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
input.current?.focus();
|
|
250
|
+
} }, children: [!wholeExpression && renderNodes(value), _jsx("input", { ref: input, id: id, "data-slot": "filter-input-field", className: "chip-input-field filter-field-input", role: "combobox", autoComplete: "off", spellCheck: false, disabled: disabled || !!edit, "aria-autocomplete": "list", "aria-expanded": visible, "aria-controls": visible ? `${id}-list` : undefined, "aria-activedescendant": visible ? `${id}-option-${selected}` : undefined, "aria-invalid": !!error, "aria-describedby": `${id}-hint${error ? ` ${id}-error` : ""}`, placeholder: placeholder, value: draft, onChange: event => typed(event.target.value, event.target.selectionStart === event.target.value.length), onCompositionStart: () => { composing.current = true; }, onCompositionEnd: event => { composing.current = false; typed(event.currentTarget.value, event.currentTarget.selectionStart === event.currentTarget.value.length); }, onKeyDown: keys, onFocus: () => setFocused(true), onBlur: () => setFocused(false) })] }), visible && _jsx("div", { className: "menu filter-suggestions", id: `${id}-list`, role: "listbox", "aria-label": `${label} suggestions`, children: suggestions.map((suggestion, index) => _jsxs("div", { id: `${id}-option-${index}`, role: "option", "aria-selected": index === selected, className: "menu-item", onMouseDown: event => event.preventDefault(), onPointerMove: () => setActive(index), onClick: () => choose(index), children: [_jsx("span", { children: suggestion.label }), _jsx("span", { className: "muted", children: suggestion.description })] }, `${suggestion.text}-${index}`)) })] }), _jsx("p", { id: `${id}-hint`, className: "field-hint", children: "Space completes a filter. Close IN lists and groups with ). Enter also confirms. Tab completes suggestions; Escape closes them. Quote values with spaces." }), error && _jsx("p", { className: "field-error", id: `${id}-error`, role: "alert", children: error }), edit && _jsx("p", { id: `${id}-edit-hint`, className: edit.error ? "field-error" : "field-hint", role: edit.error ? "alert" : undefined, children: edit.error || "Enter saves; Escape cancels. Results use the saved filter until you confirm." }), wholeExpression && _jsx("p", { className: "field-hint", children: "Edit parentheses or conditions. Enter saves; Escape cancels. Results still use the saved filters." }), _jsxs("div", { className: "filter-actions", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: disabled || busy || !value.length, onClick: () => { updateDraft(serializeFilterExpression(value)); setWholeExpression(true); input.current?.focus(); }, children: "Edit expression" }), _jsx(Button, { size: "sm", variant: "ghost", disabled: disabled || (!value.length && !draft && !edit), onClick: () => { onValueChange([]); setEdit(null); setWholeExpression(false); updateDraft(""); setMessage("Filters cleared."); requestAnimationFrame(() => input.current?.focus()); }, children: "Clear filters" })] }), _jsx("span", { className: "filter-status", role: "status", "aria-atomic": "true", children: _jsx("span", { children: message.text }, message.revision) })] });
|
|
251
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export * from "./button.js";
|
|
|
8
8
|
export * from "./card.js";
|
|
9
9
|
export * from "./checkbox.js";
|
|
10
10
|
export * from "./chip-input.js";
|
|
11
|
+
export * from "./filter-input.js";
|
|
12
|
+
export * from "./filter-expression.js";
|
|
11
13
|
export * from "./command-palette.js";
|
|
12
14
|
export * from "./dialog.js";
|
|
13
15
|
export * from "./dot.js";
|
|
@@ -53,3 +55,5 @@ export * from "./disclosure.js";
|
|
|
53
55
|
export * from "./time-range-control.js";
|
|
54
56
|
export * from "./dashboard-grid.js";
|
|
55
57
|
export * from "./step-list.js";
|
|
58
|
+
export * from "./lifecycle.js";
|
|
59
|
+
export * from "./status-badge.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export * from "./button.js";
|
|
|
9
9
|
export * from "./card.js";
|
|
10
10
|
export * from "./checkbox.js";
|
|
11
11
|
export * from "./chip-input.js";
|
|
12
|
+
export * from "./filter-input.js";
|
|
13
|
+
export * from "./filter-expression.js";
|
|
12
14
|
export * from "./command-palette.js";
|
|
13
15
|
export * from "./dialog.js";
|
|
14
16
|
export * from "./dot.js";
|
|
@@ -54,3 +56,5 @@ export * from "./disclosure.js";
|
|
|
54
56
|
export * from "./time-range-control.js";
|
|
55
57
|
export * from "./dashboard-grid.js";
|
|
56
58
|
export * from "./step-list.js";
|
|
59
|
+
export * from "./lifecycle.js";
|
|
60
|
+
export * from "./status-badge.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type LifecycleProps = Omit<React.ComponentProps<"div">, "children"> & {
|
|
3
|
+
status: React.ReactNode;
|
|
4
|
+
actions?: React.ReactNode;
|
|
5
|
+
destructive?: React.ReactNode;
|
|
6
|
+
};
|
|
7
|
+
export declare function Lifecycle({ status, actions, destructive, className, ...props }: LifecycleProps): React.JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { clsx as cn } from "clsx";
|
|
3
|
+
/* The header row of a detail screen: what the thing is, what you can do to
|
|
4
|
+
it, and the one action you do not want to hit by accident. Status is read,
|
|
5
|
+
the verbs are pressed, and the destructive action sits outside both with a
|
|
6
|
+
wider gap. A screen with no verbs passes none, and the middle group is
|
|
7
|
+
dropped rather than rendered as an empty bordered box. */
|
|
8
|
+
export function Lifecycle({ status, actions, destructive, className, ...props }) {
|
|
9
|
+
return _jsxs("div", { ...props, "data-slot": "lifecycle", className: cn("lifecycle", className), children: [_jsx("div", { className: "cluster cluster-status", role: "group", "aria-label": "Status", children: status }), actions ? _jsx("div", { className: "cluster cluster-verbs", role: "group", "aria-label": "Actions", children: actions }) : null, destructive] });
|
|
10
|
+
}
|
package/dist/split.js
CHANGED
|
@@ -18,6 +18,7 @@ function Splitter({ className, defaultSize = 50, min = 25, max = 75, step = 2, o
|
|
|
18
18
|
const ref = React.useRef(null);
|
|
19
19
|
const [size, setSize] = React.useState(defaultSize);
|
|
20
20
|
const [dragging, setDragging] = React.useState(false);
|
|
21
|
+
const dragStart = React.useRef({ x: 0, size: defaultSize });
|
|
21
22
|
useIsomorphicLayoutEffect(() => {
|
|
22
23
|
const pane = ref.current?.previousElementSibling;
|
|
23
24
|
if (pane instanceof HTMLElement)
|
|
@@ -32,6 +33,7 @@ function Splitter({ className, defaultSize = 50, min = 25, max = 75, step = 2, o
|
|
|
32
33
|
}
|
|
33
34
|
return (_jsx("div", { ref: ref, role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": ariaLabel, "aria-valuenow": Math.round(size), "aria-valuemin": min, "aria-valuemax": max, "data-slot": "splitter", className: cn("splitter", dragging && "dragging", className), ...props, onPointerDown: (event) => {
|
|
34
35
|
onPointerDown?.(event);
|
|
36
|
+
dragStart.current = { x: event.clientX, size };
|
|
35
37
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
36
38
|
setDragging(true);
|
|
37
39
|
}, onPointerMove: (event) => {
|
|
@@ -40,7 +42,7 @@ function Splitter({ className, defaultSize = 50, min = 25, max = 75, step = 2, o
|
|
|
40
42
|
return;
|
|
41
43
|
const box = event.currentTarget.parentElement?.getBoundingClientRect();
|
|
42
44
|
if (box)
|
|
43
|
-
resize(((event.clientX -
|
|
45
|
+
resize(dragStart.current.size + ((event.clientX - dragStart.current.x) / box.width) * 100);
|
|
44
46
|
},
|
|
45
47
|
/* Covers the pointer being released and the drag being cancelled. */
|
|
46
48
|
onLostPointerCapture: (event) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge } from "./badge.js";
|
|
3
|
+
export type StatusTone = "success" | "danger" | "neutral";
|
|
4
|
+
export type StatusBadgeProps = Omit<React.ComponentProps<typeof Badge>, "variant" | "asChild"> & {
|
|
5
|
+
tone: StatusTone;
|
|
6
|
+
pulse?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function StatusBadge({ tone, pulse, children, ...props }: StatusBadgeProps): React.JSX.Element;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Badge } from "./badge.js";
|
|
3
|
+
import { Dot } from "./dot.js";
|
|
4
|
+
/* The dot pulses while work is going, a run or a build; a thing that is
|
|
5
|
+
merely up holds still. */
|
|
6
|
+
export function StatusBadge({ tone, pulse = false, children, ...props }) {
|
|
7
|
+
return _jsxs(Badge, { ...props, variant: tone, children: [_jsx(Dot, { pulse: pulse }), children] });
|
|
8
|
+
}
|
package/dist/styles.css
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
border-radius: var(--radius-lg);
|
|
17
17
|
background: var(--color-muted);
|
|
18
18
|
}
|
|
19
|
-
.theme-buttons .btn[aria-
|
|
19
|
+
.theme-buttons .btn[aria-checked="true"] {
|
|
20
20
|
background: var(--color-card);
|
|
21
21
|
color: var(--color-foreground);
|
|
22
22
|
box-shadow: var(--shadow-xs);
|
|
@@ -161,9 +161,17 @@
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
@media (pointer: coarse) {
|
|
164
|
-
.
|
|
164
|
+
/* Reserve the extended target in layout so adjacent controls cannot overlap. */
|
|
165
|
+
.kiso-checkbox { margin: calc((var(--size-touch-min) - var(--size-icon-md)) / 2); }
|
|
166
|
+
.kiso-react-switch {
|
|
167
|
+
margin-block: calc((var(--size-touch-min) - 18px) / 2);
|
|
168
|
+
margin-inline: calc((var(--size-touch-min) - 32px) / 2);
|
|
169
|
+
}
|
|
170
|
+
.kiso-checkbox::before, .kiso-react-switch::before {
|
|
165
171
|
content: "";
|
|
166
172
|
position: absolute;
|
|
173
|
+
top: 50%; left: 50%;
|
|
174
|
+
translate: -50% -50%;
|
|
167
175
|
width: var(--size-touch-min);
|
|
168
176
|
height: var(--size-touch-min);
|
|
169
177
|
}
|
|
@@ -217,10 +225,15 @@
|
|
|
217
225
|
cursor: pointer;
|
|
218
226
|
}
|
|
219
227
|
[data-slot="select-trigger"][data-placeholder] {
|
|
220
|
-
color: var(--color-
|
|
228
|
+
color: var(--color-muted-foreground);
|
|
221
229
|
}
|
|
222
230
|
[data-slot="select-trigger"]:disabled { cursor: not-allowed; }
|
|
223
231
|
[data-slot="select-item"] { justify-content: space-between; }
|
|
232
|
+
[data-slot="select-content"] {
|
|
233
|
+
max-height: var(--radix-select-content-available-height);
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
}
|
|
236
|
+
[data-slot="select-viewport"] { min-height: 0; }
|
|
224
237
|
|
|
225
238
|
/* Portalled surfaces: above the page, never wider than it. */
|
|
226
239
|
.kiso-react-floating {
|
package/dist/theme-selector.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRef } from "react";
|
|
3
4
|
import { Button } from "./button.js";
|
|
4
5
|
export function ThemeSelector({ theme, onChange, }) {
|
|
6
|
+
const buttons = useRef([]);
|
|
5
7
|
const options = [
|
|
6
8
|
{
|
|
7
9
|
value: "system",
|
|
@@ -19,5 +21,17 @@ export function ThemeSelector({ theme, onChange, }) {
|
|
|
19
21
|
path: "M13.5 10A6 6 0 0 1 6 2.5a6 6 0 1 0 7.5 7.5Z",
|
|
20
22
|
},
|
|
21
23
|
];
|
|
22
|
-
return (_jsxs("div", { className: "theme-row", children: [_jsx("span", { className: "t-label", children: "Theme" }), _jsx("div", { className: "theme-buttons", role: "
|
|
24
|
+
return (_jsxs("div", { className: "theme-row", children: [_jsx("span", { className: "t-label", children: "Theme" }), _jsx("div", { className: "theme-buttons", role: "radiogroup", "aria-label": "Theme", children: options.map((option, index) => (_jsx(Button, { ref: (button) => { buttons.current[index] = button; }, variant: "ghost", size: "sm", className: "btn-icon", role: "radio", "aria-label": option.label, title: option.label, "aria-checked": theme === option.value, tabIndex: theme === option.value ? 0 : -1, onClick: () => onChange(option.value), onKeyDown: (event) => {
|
|
25
|
+
if (event.altKey || event.ctrlKey || event.metaKey)
|
|
26
|
+
return;
|
|
27
|
+
const direction = event.key === "ArrowRight" || event.key === "ArrowDown"
|
|
28
|
+
? 1
|
|
29
|
+
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
|
30
|
+
if (!direction)
|
|
31
|
+
return;
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
const next = (index + direction + options.length) % options.length;
|
|
34
|
+
buttons.current[next]?.focus();
|
|
35
|
+
onChange(options[next].value);
|
|
36
|
+
}, children: _jsx("svg", { className: "icon icon-sm", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.4", "aria-hidden": "true", children: _jsx("path", { d: option.path }) }) }, option.value))) })] }));
|
|
23
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@momoi-labs/kiso-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Kiso React components built with shadcn/ui and Radix",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"prepack": "npm run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@momoi-labs/kiso": "^0.
|
|
35
|
+
"@momoi-labs/kiso": "^0.13.0",
|
|
36
36
|
"class-variance-authority": "0.7.1",
|
|
37
37
|
"clsx": "2.1.1",
|
|
38
38
|
"radix-ui": "1.6.7",
|