@kahitsan/ksui 0.10.2 → 0.12.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 +19 -21
- package/package.json +3 -7
- package/src/components/base/CameraCapture.tsx +2 -2
- package/src/components/base/DataTable.tsx +930 -0
- package/src/components/base/DatePicker.tsx +990 -0
- package/src/components/base/ExistingAttachmentTile.tsx +3 -3
- package/src/components/base/ImageCropper.tsx +3 -3
- package/src/components/base/Modal.tsx +198 -0
- package/src/components/composite/ComboBox.tsx +2 -2
- package/src/components/composite/FormActions.tsx +4 -4
- package/src/components/composite/MarkdownNotes.tsx +8 -11
- package/src/index.ts +55 -14
- package/src/utils/accounts-index.tsx +7 -6
- package/src/utils/confirm.tsx +84 -0
- package/src/utils/dom.ts +197 -0
- package/src/utils/highlight.tsx +67 -0
- package/src/utils/integration.ts +49 -0
- package/src/utils/parse-date.ts +595 -0
- package/host-ui.d.ts +0 -145
package/src/utils/dom.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Selector for elements a Tab key would normally land on. Picker dropdowns
|
|
2
|
+
// in this app render their popups via Portal outside the modal's subtree,
|
|
3
|
+
// so the trap also has to consider those, but options/inputs inside a
|
|
4
|
+
// Portal are still owned by the open modal logically. The trap solves this
|
|
5
|
+
// by also walking matching descendants of the document body that point back
|
|
6
|
+
// to the modal via aria-controls / aria-labelledby. For phase 1, the
|
|
7
|
+
// simpler implementation is sufficient: every modal in this app keeps its
|
|
8
|
+
// own focusable descendants inside the dialog node itself, and pickers
|
|
9
|
+
// close on Escape independently of the trap.
|
|
10
|
+
const TABBABLE_SELECTOR = [
|
|
11
|
+
'a[href]:not([tabindex="-1"])',
|
|
12
|
+
'button:not([disabled]):not([tabindex="-1"])',
|
|
13
|
+
'input:not([type="hidden"]):not([disabled]):not([tabindex="-1"])',
|
|
14
|
+
'select:not([disabled]):not([tabindex="-1"])',
|
|
15
|
+
'textarea:not([disabled]):not([tabindex="-1"])',
|
|
16
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
17
|
+
].join(", ");
|
|
18
|
+
|
|
19
|
+
function tabbablesIn(root: HTMLElement): HTMLElement[] {
|
|
20
|
+
// Walk the modal's subtree and keep visible, non-disabled focusables.
|
|
21
|
+
return Array.from(root.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)).filter((el) => {
|
|
22
|
+
if (el.hidden) return false;
|
|
23
|
+
// offsetParent is null for `display: none` and detached nodes; visibility
|
|
24
|
+
// hidden elements are excluded too. Don't trust just the selector list.
|
|
25
|
+
if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") return false;
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* While any modal is open, suppress the browser/PWA pull-to-refresh and
|
|
32
|
+
* overscroll-bounce gestures so a stray drag on the backdrop doesn't reload
|
|
33
|
+
* the page out from under a half-filled form. Counter handles nested modals
|
|
34
|
+
* (e.g. a Confirm dialog opening on top of a transactions sheet).
|
|
35
|
+
*
|
|
36
|
+
* Pair `lockPullToRefresh()` with `unlockPullToRefresh()` (e.g. via Solid
|
|
37
|
+
* `onCleanup`) so each lock has exactly one matching unlock.
|
|
38
|
+
*/
|
|
39
|
+
let openModalCount = 0;
|
|
40
|
+
let prevHtmlOverscroll: string | null = null;
|
|
41
|
+
let prevBodyOverscroll: string | null = null;
|
|
42
|
+
|
|
43
|
+
export function lockPullToRefresh() {
|
|
44
|
+
if (typeof document === "undefined") return;
|
|
45
|
+
if (openModalCount === 0) {
|
|
46
|
+
const html = document.documentElement;
|
|
47
|
+
const body = document.body;
|
|
48
|
+
prevHtmlOverscroll = html.style.overscrollBehavior;
|
|
49
|
+
prevBodyOverscroll = body.style.overscrollBehavior;
|
|
50
|
+
html.style.overscrollBehavior = "none";
|
|
51
|
+
body.style.overscrollBehavior = "none";
|
|
52
|
+
}
|
|
53
|
+
openModalCount += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function unlockPullToRefresh() {
|
|
57
|
+
if (typeof document === "undefined") return;
|
|
58
|
+
openModalCount = Math.max(0, openModalCount - 1);
|
|
59
|
+
if (openModalCount === 0) {
|
|
60
|
+
document.documentElement.style.overscrollBehavior = prevHtmlOverscroll ?? "";
|
|
61
|
+
document.body.style.overscrollBehavior = prevBodyOverscroll ?? "";
|
|
62
|
+
prevHtmlOverscroll = null;
|
|
63
|
+
prevBodyOverscroll = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Trap Tab and Shift+Tab inside `el` and restore focus on cleanup. Returns
|
|
69
|
+
* a teardown function the caller can use to deactivate the trap explicitly
|
|
70
|
+
* (also runs automatically when called inside a Solid `onCleanup`).
|
|
71
|
+
*
|
|
72
|
+
* Use this from a ref callback or Solid effect:
|
|
73
|
+
*
|
|
74
|
+
* <div ref={(el) => { onCleanup(useFocusTrap(el)); }}>
|
|
75
|
+
*
|
|
76
|
+
* Or from an effect:
|
|
77
|
+
*
|
|
78
|
+
* createEffect(() => {
|
|
79
|
+
* if (containerRef) onCleanup(useFocusTrap(containerRef));
|
|
80
|
+
* });
|
|
81
|
+
*
|
|
82
|
+
* The trap only intercepts Tab. Escape, click-outside, and submit handling
|
|
83
|
+
* remain the modal's responsibility, the trap is purely a focus-keeper.
|
|
84
|
+
*/
|
|
85
|
+
export function useFocusTrap(el: HTMLElement | undefined): () => void {
|
|
86
|
+
if (!el) return () => {};
|
|
87
|
+
|
|
88
|
+
// Snapshot the element that had focus before the modal mounted so we can
|
|
89
|
+
// hand it back when the trap deactivates. Skip the document body which is
|
|
90
|
+
// a non-actionable default.
|
|
91
|
+
const previouslyFocused =
|
|
92
|
+
document.activeElement instanceof HTMLElement && document.activeElement !== document.body
|
|
93
|
+
? document.activeElement
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
97
|
+
if (e.key !== "Tab") return;
|
|
98
|
+
const list = tabbablesIn(el);
|
|
99
|
+
if (list.length === 0) {
|
|
100
|
+
// No focusable descendants (an empty modal), pin focus on the
|
|
101
|
+
// container itself rather than letting the browser tab away.
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
el.focus();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const first = list[0];
|
|
107
|
+
const last = list[list.length - 1];
|
|
108
|
+
const active = document.activeElement as HTMLElement | null;
|
|
109
|
+
const inTrap = active && el.contains(active);
|
|
110
|
+
if (e.shiftKey) {
|
|
111
|
+
if (!inTrap || active === first) {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
last.focus();
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
if (!inTrap || active === last) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
first.focus();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Capture phase so we see the keydown before any descendant handler can
|
|
124
|
+
// swallow it. Solid's <Show>/<Portal> wrapping doesn't change that.
|
|
125
|
+
document.addEventListener("keydown", handleKeyDown, true);
|
|
126
|
+
|
|
127
|
+
return () => {
|
|
128
|
+
document.removeEventListener("keydown", handleKeyDown, true);
|
|
129
|
+
// Restore focus only when the snapshot is still in the DOM. If the
|
|
130
|
+
// caller removed the original button between mount and unmount,
|
|
131
|
+
// dropping focus on a detached element would just clear it instead.
|
|
132
|
+
if (previouslyFocused && document.contains(previouslyFocused)) {
|
|
133
|
+
try {
|
|
134
|
+
previouslyFocused.focus();
|
|
135
|
+
} catch {
|
|
136
|
+
/* element became unfocusable in the meantime, let the browser pick */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ref callback that moves focus into a freshly mounted container so keyboard
|
|
144
|
+
* users land somewhere meaningful. Used on modal content containers:
|
|
145
|
+
* `ref={autoFocusOnMount}`.
|
|
146
|
+
*
|
|
147
|
+
* Lookup order:
|
|
148
|
+
* 1. An explicit `[data-autofocus]` element (or its first focusable
|
|
149
|
+
* descendant). Lets a parent override the default heuristic when the
|
|
150
|
+
* first visible input is not the right place to land, e.g. a modal
|
|
151
|
+
* whose primary affordance is a search/picker rather than a date field.
|
|
152
|
+
* 2. The first text-entry control (input/textarea/select). Most modals open
|
|
153
|
+
* onto a form, and dropping focus directly into the first field is what
|
|
154
|
+
* sighted keyboard users expect.
|
|
155
|
+
* 3. Any other focusable element (button, link, anything with `tabindex`).
|
|
156
|
+
* A modal that opens onto an action grid (e.g. the Counter cart) has no
|
|
157
|
+
* inputs on first render; without this fallback `el.focus()` would never
|
|
158
|
+
* run and focus would stay on the button that opened the modal.
|
|
159
|
+
*/
|
|
160
|
+
export function autoFocusOnMount(el: HTMLElement | undefined) {
|
|
161
|
+
if (!el) return;
|
|
162
|
+
queueMicrotask(() => {
|
|
163
|
+
const focusableSelector =
|
|
164
|
+
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
|
165
|
+
const inputSelector =
|
|
166
|
+
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])';
|
|
167
|
+
|
|
168
|
+
const marker = el.querySelector<HTMLElement>("[data-autofocus]");
|
|
169
|
+
if (marker) {
|
|
170
|
+
// The marker itself may be focusable; prefer it. Otherwise pick the
|
|
171
|
+
// first focusable descendant (e.g. the trigger button inside a custom
|
|
172
|
+
// picker component).
|
|
173
|
+
if (marker.matches(focusableSelector)) {
|
|
174
|
+
marker.focus();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const inner = marker.querySelector<HTMLElement>(focusableSelector);
|
|
178
|
+
if (inner) {
|
|
179
|
+
inner.focus();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const input = el.querySelector<HTMLElement>(inputSelector);
|
|
185
|
+
if (input) {
|
|
186
|
+
input.focus();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Fall back to the first interactive element. tabindex="-1" is excluded
|
|
190
|
+
// so a programmatically-focusable container does not steal focus from a
|
|
191
|
+
// child the user can actually act on.
|
|
192
|
+
const focusable = el.querySelector<HTMLElement>(
|
|
193
|
+
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
|
|
194
|
+
);
|
|
195
|
+
focusable?.focus();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Search-match highlighting + tiny string-match helpers. Ported into ksui from
|
|
2
|
+
// the former host kit so the library is self-contained: ComboBox and
|
|
3
|
+
// MarkdownNotes used to import `highlightMatch` from "@kserp/host-ui"; they now
|
|
4
|
+
// import it from here. Pure functions plus a `<mark>` wrapper.
|
|
5
|
+
//
|
|
6
|
+
// The default highlight tint ships as injected CSS (a `.ksui-mark` class) so the
|
|
7
|
+
// library carries no Tailwind dependency — like Button/ProgressBar, the keyframe
|
|
8
|
+
// /helper class is added once per page via a runtime <style> tag. Callers can
|
|
9
|
+
// still pass their own `markClass` to override.
|
|
10
|
+
|
|
11
|
+
import { type JSX } from "solid-js";
|
|
12
|
+
|
|
13
|
+
const MARK_STYLE_ID = "ksui-mark-style";
|
|
14
|
+
|
|
15
|
+
function ensureMarkStyle(): void {
|
|
16
|
+
if (typeof document === "undefined") return;
|
|
17
|
+
if (document.getElementById(MARK_STYLE_ID)) return;
|
|
18
|
+
const style = document.createElement("style");
|
|
19
|
+
style.id = MARK_STYLE_ID;
|
|
20
|
+
style.textContent = `.ksui-mark{background-color:rgba(245,158,11,0.3);color:inherit;border-radius:2px;}`;
|
|
21
|
+
document.head.appendChild(style);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Case-insensitive substring test. Empty query matches everything. */
|
|
25
|
+
export function matchesQuery(text: string | null | undefined, query: string): boolean {
|
|
26
|
+
if (!query) return true;
|
|
27
|
+
if (!text) return false;
|
|
28
|
+
return text.toLowerCase().includes(query.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when the query matches any of the given fields. */
|
|
32
|
+
export function matchesAny(query: string, ...fields: (string | null | undefined)[]): boolean {
|
|
33
|
+
if (!query) return true;
|
|
34
|
+
return fields.some((f) => matchesQuery(f, query));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap every case-insensitive occurrence of `query` inside `text` in a `<mark>`.
|
|
39
|
+
* Pass `markClass` to override the default `.ksui-mark` tint.
|
|
40
|
+
*/
|
|
41
|
+
export function highlightMatch(text: string, query: string, markClass?: string): JSX.Element {
|
|
42
|
+
if (!query || !text) return <>{text}</>;
|
|
43
|
+
if (!markClass) ensureMarkStyle();
|
|
44
|
+
const lower = text.toLowerCase();
|
|
45
|
+
const q = query.toLowerCase();
|
|
46
|
+
const out: JSX.Element[] = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
let idx = lower.indexOf(q, i);
|
|
49
|
+
let key = 0;
|
|
50
|
+
while (idx !== -1) {
|
|
51
|
+
if (idx > i) out.push(<>{text.slice(i, idx)}</>);
|
|
52
|
+
out.push(
|
|
53
|
+
<mark class={markClass ?? "ksui-mark"} data-key={key++}>
|
|
54
|
+
{text.slice(idx, idx + q.length)}
|
|
55
|
+
</mark>,
|
|
56
|
+
);
|
|
57
|
+
i = idx + q.length;
|
|
58
|
+
idx = lower.indexOf(q, i);
|
|
59
|
+
}
|
|
60
|
+
if (i < text.length) out.push(<>{text.slice(i)}</>);
|
|
61
|
+
return <>{out}</>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Component form of {@link highlightMatch}. */
|
|
65
|
+
export function HighlightedText(props: { text: string; query: string; markClass?: string }): JSX.Element {
|
|
66
|
+
return <>{highlightMatch(props.text, props.query, props.markClass)}</>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Optional host integrations.
|
|
2
|
+
//
|
|
3
|
+
// A couple of ksui components can do MORE when the surrounding app feeds them
|
|
4
|
+
// runtime context — a permission check (MarkdownNotes gates its client-mention
|
|
5
|
+
// hover card on "clients.view") and the active workspace id (useAccountsIndex
|
|
6
|
+
// re-keys its fetch per workspace). The library used to pull these from
|
|
7
|
+
// "@kserp/host-ui"; it now owns a tiny opt-in registry instead, so it stays a
|
|
8
|
+
// standalone dependency-free package.
|
|
9
|
+
//
|
|
10
|
+
// Default behavior with nothing configured (any plain SolidJS consumer):
|
|
11
|
+
// - canAccess(code) === false → MarkdownNotes renders a plain, non-hovering
|
|
12
|
+
// mention chip.
|
|
13
|
+
// - getActiveWorkspaceId() === null → useAccountsIndex stays empty (its fetch
|
|
14
|
+
// short-circuits), so accounts resolve to the
|
|
15
|
+
// type-default glyph.
|
|
16
|
+
//
|
|
17
|
+
// A host app wires the real behavior once at startup:
|
|
18
|
+
// import { configurePermissions, configureActiveWorkspace } from "@kahitsan/ksui";
|
|
19
|
+
// configurePermissions((code) => usePermissions().has(code));
|
|
20
|
+
// configureActiveWorkspace(() => useActiveWorkspace().activeWorkspace()?.ws_id ?? null);
|
|
21
|
+
//
|
|
22
|
+
// The resolvers may read reactive sources; ksui calls them inside tracking
|
|
23
|
+
// scopes, so a signal-backed permission/workspace value stays reactive.
|
|
24
|
+
|
|
25
|
+
type PermissionResolver = (code: string) => boolean;
|
|
26
|
+
type WorkspaceResolver = () => number | string | null;
|
|
27
|
+
|
|
28
|
+
let permissionResolver: PermissionResolver | null = null;
|
|
29
|
+
let workspaceResolver: WorkspaceResolver | null = null;
|
|
30
|
+
|
|
31
|
+
/** Register the host's permission check. Pass a function so it can read a reactive source. */
|
|
32
|
+
export function configurePermissions(resolver: PermissionResolver): void {
|
|
33
|
+
permissionResolver = resolver;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when the host granted `code`. False when no resolver is configured. */
|
|
37
|
+
export function canAccess(code: string): boolean {
|
|
38
|
+
return permissionResolver ? !!permissionResolver(code) : false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Register the host's active-workspace id getter. Pass a function so it can read a reactive source. */
|
|
42
|
+
export function configureActiveWorkspace(resolver: WorkspaceResolver): void {
|
|
43
|
+
workspaceResolver = resolver;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The host's active workspace id, or null when no resolver is configured. */
|
|
47
|
+
export function getActiveWorkspaceId(): number | string | null {
|
|
48
|
+
return workspaceResolver ? workspaceResolver() : null;
|
|
49
|
+
}
|