@kahitsan/ksui 0.10.1 → 0.11.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.
@@ -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
+ }
package/host-ui.d.ts DELETED
@@ -1,145 +0,0 @@
1
- // CANONICAL SDK type defs for the host UI kit (window.__KSERP_UI__, externalized
2
- // as "@kserp/host-ui"). This ships in @kahitsan/ksui and is the single
3
- // source of truth. Every plugin (ours, and any third-party with no kernel
4
- // source) gets it from the installed package via
5
- // `/// <reference types="@kahitsan/ksui/host-ui" />`; there are no more
6
- // per-plugin copies to drift. The host owns the runtime: its remote loader
7
- // (kserp src/lib/remote-loader.ts) populates the global from the host's kit
8
- // barrel (kserp src/lib/host-ui.tsx) before loading any remote. Keep this in sync
9
- // with that barrel. Every member here must be exported there, and vice versa.
10
- declare module "@kserp/host-ui" {
11
- import type { JSX, Accessor } from "solid-js";
12
-
13
- // --- Shared table types (mirror src/components/ui/DataTable/DataTable.tsx) ---
14
- export interface DataTableRow {
15
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
- [key: string]: any;
17
- }
18
- export interface DataTableColumn<T extends DataTableRow> {
19
- data: (keyof T & string) | null;
20
- title?: string;
21
- render?: (
22
- data: T[keyof T] | null,
23
- type: "display",
24
- row: T,
25
- meta: { row: number; col: number; search: string },
26
- ) => JSX.Element | string;
27
- orderable?: boolean;
28
- className?: string;
29
- }
30
- export interface FetchResult<T> {
31
- data: T[];
32
- total: number;
33
- }
34
- export interface FetchParams {
35
- page: number;
36
- limit: number;
37
- search: string;
38
- sortBy: string | null;
39
- sortDir: "asc" | "desc";
40
- dateFilter: string | null;
41
- dateFrom?: string | null;
42
- dateTo?: string | null;
43
- }
44
-
45
- export interface DataTableProps<T extends DataTableRow> {
46
- columns?: DataTableColumn<T>[];
47
- /**
48
- * Generic data fetcher. When provided, the table runs in server-side mode.
49
- * Optional: omit it and pass `data` for client-side mode.
50
- */
51
- fetchFn?: (params: FetchParams) => Promise<FetchResult<T>>;
52
- /** Static data (client-side mode). Ignored when `fetchFn` is set. */
53
- data?: T[];
54
- /**
55
- * Expose the table's refetch handle to the parent. The callback receives
56
- * `{ refetch, resetAndRefetch }`: `refetch()` re-fetches with the current
57
- * state; `resetAndRefetch()` resets pagination to page 1 (clearing loadMore
58
- * accumulators) before fetching.
59
- */
60
- onRefetch?: (api: { refetch: () => void; resetAndRefetch: () => void }) => void;
61
- // Remaining props are passed through; kept permissive so plugins can use the
62
- // full surface of the host component without re-declaring it here.
63
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
- [key: string]: any;
65
- }
66
- export function DataTable<T extends DataTableRow>(props: DataTableProps<T>): JSX.Element;
67
-
68
- // --- DatePicker ---
69
- export interface DateRangeValue {
70
- start: string | null;
71
- end: string | null;
72
- }
73
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
- export function DatePicker(props: any): JSX.Element;
75
-
76
- // --- Modal ---
77
- // The Modal is mounted/unmounted by the caller (wrap it in <Show when={open}>);
78
- // there is no `open` prop. onClose fires on Escape / backdrop / dismissal.
79
- export type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "5xl" | "7xl";
80
- export type ModalTone = "default" | "danger";
81
- export interface ModalProps {
82
- onClose: () => void;
83
- dismissable?: boolean;
84
- variant?: "default" | "sheet";
85
- size?: ModalSize;
86
- tone?: ModalTone;
87
- ariaLabel?: string;
88
- children: JSX.Element;
89
- }
90
- export function Modal(props: ModalProps): JSX.Element;
91
-
92
- // --- SearchableSelect ---
93
- export interface SearchableOption {
94
- value: string;
95
- label: string;
96
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
- [key: string]: any;
98
- }
99
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
- export function SearchableSelect(props: any): JSX.Element;
101
-
102
- // --- Other components (permissive: full prop surface lives in the host) ---
103
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
- export const Button: (props: any) => JSX.Element;
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- export const PageShell: (props: any) => JSX.Element;
107
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
- export const PageTitle: (props: any) => JSX.Element;
109
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
110
- export const PageShareButton: (props: any) => JSX.Element;
111
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
- export const Avatar: (props: any) => JSX.Element;
113
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
- export const PluginPageLoader: (props: any) => JSX.Element;
115
-
116
- // --- Confirm ---
117
- export function confirm(opts: {
118
- title?: string;
119
- message?: string;
120
- confirmLabel?: string;
121
- cancelLabel?: string;
122
- danger?: boolean;
123
- }): Promise<boolean>;
124
-
125
- // --- Host hooks (run on the host's Solid runtime + context providers) ---
126
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
- export function useActiveWorkspace(): any;
128
- export function useCan(code: string): Accessor<boolean>;
129
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
- export function usePermissions(): any;
131
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
- export function PermissionGate(props: any): JSX.Element;
133
-
134
- // --- Helpers ---
135
- export function highlightMatch(text: string, query: string, markClass?: string): JSX.Element;
136
- export function HighlightedText(props: {
137
- text: string;
138
- query: string;
139
- markClass?: string;
140
- }): JSX.Element;
141
- export function matchesQuery(text: string | null | undefined, query: string): boolean;
142
- export function matchesAny(query: string, ...fields: (string | null | undefined)[]): boolean;
143
- export function useFocusTrap(el: HTMLElement | undefined): () => void;
144
- export function autoFocusOnMount(el: HTMLElement | undefined): void;
145
- }