@jini-ai/ui 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +20 -0
- package/dist/core/theme/default.d.ts +4 -0
- package/dist/core/theme/default.js +11 -0
- package/dist/core/theme/stylesheet.d.ts +8 -0
- package/dist/core/theme/stylesheet.js +14 -0
- package/dist/core/theme/types.d.ts +39 -0
- package/dist/core/theme/types.js +2 -0
- package/dist/core/theme/validation.d.ts +15 -0
- package/dist/core/theme/validation.js +55 -0
- package/dist/features/admin-widgets/components/Select/Select.d.ts +11 -3
- package/dist/features/admin-widgets/components/Select/Select.hooks.d.ts +8 -13
- package/dist/features/admin-widgets/components/Select/Select.hooks.js +37 -215
- package/dist/features/admin-widgets/components/Select/Select.js +4 -3
- package/dist/features/appearance/constants.js +1 -1
- package/dist/features/connectors/rules.d.ts +1 -6
- package/dist/features/connectors/types.d.ts +1 -6
- package/dist/features/connectors/types.js +1 -6
- package/dist/features/execution/http-adapter.d.ts +111 -0
- package/dist/features/execution/http-adapter.js +160 -0
- package/dist/features/execution/index.d.ts +4 -0
- package/dist/features/execution/index.js +2 -0
- package/dist/features/execution/react/hooks/use-execution-credential.hooks.d.ts +232 -0
- package/dist/features/execution/react/hooks/use-execution-credential.hooks.js +217 -0
- package/dist/features/html-editor/canvas-style.d.ts +1 -1
- package/dist/features/html-editor/canvas-style.js +2 -2
- package/dist/features/i18n/dictionaries/settings-dialog.ar.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.bn.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.de.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.en.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.es.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fa.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.fr.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hi.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.hu.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.id.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.it.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ja.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ko.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pl.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.pt-BR.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ru.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.th.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.tr.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.uk.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.ur.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-CN.js +2 -0
- package/dist/features/i18n/dictionaries/settings-dialog.zh-TW.js +2 -0
- package/dist/features/i18n/dictionary-translator.d.ts +5 -2
- package/dist/features/i18n/dictionary-translator.js +1 -1
- package/dist/features/i18n/index.d.ts +1 -1
- package/dist/features/i18n/index.js +1 -1
- package/dist/features/i18n/template-i18n.d.ts +11 -0
- package/dist/features/i18n/template-i18n.js +12 -0
- package/dist/features/interactive-ui/interactive-ui.css +1 -1
- package/dist/features/interactive-ui/providers/recharts/bar-chart.js +3 -2
- package/dist/features/interactive-ui/providers/recharts/chart-tokens.d.ts +43 -0
- package/dist/features/interactive-ui/providers/recharts/chart-tokens.js +44 -0
- package/dist/features/interactive-ui/providers/recharts/line-chart.js +3 -2
- package/dist/features/interactive-ui/providers/recharts/pie-chart.js +2 -2
- package/dist/features/lexical-rich-text-editor/deserialize.d.ts +0 -5
- package/dist/features/lexical-rich-text-editor/deserialize.js +0 -5
- package/dist/features/lexical-rich-text-editor/mention-node.d.ts +5 -17
- package/dist/features/lexical-rich-text-editor/mention-node.js +5 -17
- package/dist/features/lexical-rich-text-editor/mention-parser.d.ts +2 -4
- package/dist/features/lexical-rich-text-editor/serialize.d.ts +0 -4
- package/dist/features/lexical-rich-text-editor/serialize.js +0 -4
- package/dist/features/lexical-rich-text-editor/types.d.ts +3 -13
- package/dist/features/lexical-rich-text-editor/types.js +3 -13
- package/dist/features/mcp-ui/early-message-buffer.d.ts +1 -2
- package/dist/features/mcp-ui/early-message-buffer.js +1 -2
- package/dist/features/mcp-ui/escape.d.ts +4 -0
- package/dist/features/mcp-ui/escape.js +2 -9
- package/dist/features/mcp-ui/index.d.ts +1 -0
- package/dist/features/mcp-ui/index.js +1 -0
- package/dist/features/mcp-ui/protocol.d.ts +2 -5
- package/dist/features/mcp-ui/protocol.js +2 -5
- package/dist/features/mcp-ui/resource.d.ts +47 -7
- package/dist/features/mcp-ui/resource.js +5 -8
- package/dist/features/mcp-ui/secret-card.d.ts +115 -0
- package/dist/features/mcp-ui/secret-card.js +137 -0
- package/dist/features/mcp-ui/surfaces/confirmation.js +5 -0
- package/dist/features/mcp-ui/surfaces/fields.d.ts +5 -2
- package/dist/features/mcp-ui/surfaces/fields.js +3 -1
- package/dist/features/mcp-ui/surfaces/form.d.ts +4 -1
- package/dist/features/mcp-ui/surfaces/form.js +65 -3
- package/dist/features/mcp-ui/surfaces/outcome.d.ts +2 -0
- package/dist/features/mcp-ui/surfaces/text-input.d.ts +14 -6
- package/dist/features/mcp-ui/surfaces/text-input.js +16 -2
- package/dist/features/mcp-ui/surfaces/tokens.js +1 -1
- package/dist/features/memory/react/components/MemoryConnectedPanel.d.ts +1 -1
- package/dist/features/memory/react/components/MemoryConnectedPanel.js +0 -1
- package/dist/features/panel-kit/helpers/format-timestamp.d.ts +5 -3
- package/dist/features/panel-kit/helpers/format-timestamp.js +13 -4
- package/dist/features/panel-kit/helpers/permissions.js +2 -0
- package/dist/features/panel-kit/helpers/resolve-active-tab-id.js +2 -0
- package/dist/features/panel-kit/helpers/standing-draft-local-backup.d.ts +51 -0
- package/dist/features/panel-kit/helpers/standing-draft-local-backup.js +77 -0
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.d.ts +8 -0
- package/dist/features/panel-kit/helpers/standing-draft-recovery-message.js +10 -0
- package/dist/features/panel-kit/hooks/use-async-action.hooks.d.ts +1 -0
- package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.d.ts +1 -0
- package/dist/features/panel-kit/hooks/use-dirty-guard.hooks.js +1 -0
- package/dist/features/panel-kit/hooks/use-serial-writes.hooks.d.ts +8 -0
- package/dist/features/panel-kit/hooks/use-serial-writes.hooks.js +3 -0
- package/dist/features/panel-kit/hooks/use-settings-slice.hooks.d.ts +211 -0
- package/dist/features/panel-kit/hooks/use-settings-slice.hooks.js +382 -0
- package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.d.ts +179 -0
- package/dist/features/panel-kit/hooks/use-standing-draft-autosave.hooks.js +300 -0
- package/dist/features/panel-kit/index.d.ts +7 -1
- package/dist/features/panel-kit/index.js +5 -1
- package/dist/features/resource-dashboard/types.d.ts +8 -41
- package/dist/features/resource-dashboard/types.js +3 -26
- package/dist/features/tab-strip/constants.d.ts +3 -4
- package/dist/features/tab-strip/constants.js +3 -4
- package/dist/features/tab-strip/dependencies.d.ts +1 -6
- package/dist/features/tab-strip/dependencies.js +1 -6
- package/dist/features/tab-strip/react/components/TabBar.d.ts +33 -1
- package/dist/features/tab-strip/react/components/TabBar.hooks.d.ts +57 -5
- package/dist/features/tab-strip/react/components/TabBar.hooks.js +63 -5
- package/dist/features/tab-strip/react/components/TabBar.js +12 -1
- package/dist/features/tab-strip/rules.d.ts +4 -17
- package/dist/features/tab-strip/rules.js +4 -17
- package/dist/features/tab-strip/types.d.ts +2 -13
- package/dist/icon-name.d.ts +1 -1
- package/dist/icon-name.js +1 -0
- package/dist/index.d.ts +42 -417
- package/dist/index.js +42 -430
- package/dist/react/components/CustomSelect.d.ts +6 -2
- package/dist/react/components/CustomSelect.js +20 -91
- package/dist/react/components/Icon.d.ts +6 -0
- package/dist/react/components/Icon.js +12 -0
- package/dist/react/components/select-popover.d.ts +106 -0
- package/dist/react/components/select-popover.js +210 -0
- package/dist/renderers/sandbox-scripts.d.ts +29 -0
- package/dist/renderers/sandbox-scripts.js +127 -0
- package/dist/renderers/sandboxed-document.js +3 -86
- package/dist/renderers/srcdoc/build.d.ts +10 -17
- package/dist/renderers/srcdoc/build.js +31 -234
- package/dist/styles/variables.css +30 -0
- package/dist/theme/apply.d.ts +1 -1
- package/dist/theme/apply.js +2 -2
- package/dist/theme/color-scheme.d.ts +1 -1
- package/dist/theme/default.d.ts +1 -3
- package/dist/theme/default.js +2 -10
- package/dist/theme/index.d.ts +3 -3
- package/dist/theme/index.js +2 -2
- package/dist/theme/stylesheet.d.ts +1 -7
- package/dist/theme/stylesheet.js +2 -13
- package/dist/theme/types.d.ts +1 -38
- package/dist/theme/types.js +2 -1
- package/dist/theme/validation.d.ts +1 -14
- package/dist/theme/validation.js +2 -54
- package/dist/utils/endpoint-policy.d.ts +21 -39
- package/dist/utils/endpoint-policy.js +19 -107
- package/dist/utils/file-system-errors.d.ts +1 -2
- package/dist/utils/file-system-errors.js +1 -2
- package/dist/utils/ime-composing.d.ts +0 -3
- package/dist/utils/ime-composing.js +0 -3
- package/dist/utils/platform.d.ts +1 -4
- package/dist/utils/platform.js +1 -4
- package/dist/utils/smooth-scroll-to-top.d.ts +1 -3
- package/dist/utils/smooth-scroll-to-top.js +3 -13
- package/dist/utils/timezone.js +1 -5
- package/dist/utils/uuid.d.ts +0 -3
- package/dist/utils/uuid.js +0 -3
- package/dist/utils/visual-stability.d.ts +1 -3
- package/dist/utils/visual-stability.js +1 -3
- package/package.json +74 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @jini-ai/ui
|
|
2
2
|
|
|
3
|
+
## 0.4.5 — pending release
|
|
4
|
+
|
|
5
|
+
- Add universal `./mcp-ui/secret-card`: `defineSecretCardTool` owns fail-closed emission, cancellation, blank-secret policy, exact-byte submission and safe save-failure reporting. Specs retain domain validation, copy, dynamic fields, persistence and result projections.
|
|
6
|
+
- Inject the daemon's existing `askThenReport` alongside the store; core owns the shared exchange ABI. No daemon or React runtime dependency in this entry.
|
|
7
|
+
- Requires core 0.4.2; build and publish the release set before consumer adoption.
|
|
8
|
+
|
|
9
|
+
## 0.4.4 — 2026-10-06
|
|
10
|
+
|
|
11
|
+
- Recharts charts paint with host chart tokens `--jini-chart-1..6` / `-grid` / `-axis` / `-cursor` / `-surface` / `-text`, each falling back to a base token; a host that sets no `--jini-chart-N` gets the new warm orange `--jini-chart-default-1..6` palette (light and dark) instead of `--jini-primary`.
|
|
12
|
+
- Behavior change: grid and axis use `--jini-border`/`--jini-muted`, no vertical grid lines, bars get a 4px rounded top and 64px max width, lines are 2px, and the tooltip uses theme surface/ink with a soft accent hover band.
|
|
13
|
+
|
|
3
14
|
## 0.4.3 — 2026-10-05
|
|
4
15
|
|
|
5
16
|
- MCP-UI resources can carry an answer deadline: `createUIResource`/`buildConfirmationSurface` take `expiresAtMs`, written to `MCP_UI_EXPIRES_AT_META_KEY`; `readExpiresAt` reads it back (undefined when absent or not a finite number).
|
package/README.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# `@jini-ai/ui` — generic, product-neutral UI primitives
|
|
2
2
|
|
|
3
|
+
`@jini-ai/ui/mcp-ui/secret-card` exports the framework-free `defineSecretCardTool` lifecycle.
|
|
4
|
+
Provide a spec with `prepare`, `form`, `save`, `outcome` and `result`, then register
|
|
5
|
+
`tool.handler({ surfaceExchanges, askThenReport })`; both dependencies come from
|
|
6
|
+
`@jini-ai/daemon/surface-exchanges`. The UI entry has no daemon or React runtime import.
|
|
7
|
+
Transport ports, including `emitSurface`, remain in the handler's optional second object.
|
|
8
|
+
|
|
9
|
+
Secret fields use `{ kind: 'string', name, label, secret: true }` and cannot have `value`.
|
|
10
|
+
Use `allowBlank: true` for optional credentials or a host's preserve-on-update policy; the
|
|
11
|
+
engine sends the blank to `save`, where the host implements that policy. Domain save ports
|
|
12
|
+
receive only declared field names, exact secret bytes and the execution's abort signal.
|
|
13
|
+
Whitespace-only input is refused even with `allowBlank`; nonblank secrets are never trimmed.
|
|
14
|
+
Return safe summaries only; result and outcome projections never receive submitted values.
|
|
15
|
+
`form` retains the builders' translation, locale, details, warning and app options, with an
|
|
16
|
+
optional `cancelLabel`. `outcome` may return undefined for locally reported dismiss/expiry.
|
|
17
|
+
Use `safeError` only to map known error kinds to fixed text and `logFailure` for fixed metadata.
|
|
18
|
+
|
|
19
|
+
The store ABI binds actor, tool and channel. The host must also enforce run scope when routing
|
|
20
|
+
human answers (or inject a store scoped to the run); the exchange ABI does not carry a run ID.
|
|
21
|
+
After coordinator verification, publish core 0.4.2, daemon 0.5.4 and UI 0.4.5 before adoption.
|
|
22
|
+
|
|
3
23
|
Renamed from `@jini-ai/components` in this session (2026-07-16) once it became clear
|
|
4
24
|
one "components" bucket undersold the scope: this package is meant to hold more
|
|
5
25
|
than flat presentational components — pure business logic, hooks, and injectable
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Neutral chrome. Products supply their own brand theme instead of forking CSS. */
|
|
2
|
+
export const defaultAdminTheme = Object.freeze({
|
|
3
|
+
name: 'neutral',
|
|
4
|
+
fonts: Object.freeze({ body: 'system-ui, sans-serif', heading: 'system-ui, sans-serif', mono: 'ui-monospace, monospace' }),
|
|
5
|
+
light: Object.freeze({ primary: '#363636', primaryInk: '#ffffff', bg: '#ffffff', surface: '#f5f5f5',
|
|
6
|
+
text: '#202020', muted: '#666666', border: '#d9d9d9', danger: '#b42318', success: '#18763b', warning: '#845800' }),
|
|
7
|
+
dark: Object.freeze({ primary: '#e0e0e0', primaryInk: '#202020', bg: '#171717', surface: '#242424',
|
|
8
|
+
text: '#f0f0f0', muted: '#b3b3b3', border: '#454545', danger: '#ff8b80', success: '#79d69b', warning: '#ebc36b' }),
|
|
9
|
+
radius: '8px', density: 1,
|
|
10
|
+
});
|
|
11
|
+
//# sourceMappingURL=default.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AdminTheme, ColorScheme } from './types.js';
|
|
2
|
+
/** Internal document adapter: isolated previews cannot inherit the admin's stylesheet. */
|
|
3
|
+
export declare function renderAdminThemeVariables({ theme }: {
|
|
4
|
+
readonly theme: AdminTheme;
|
|
5
|
+
}, { colorScheme }?: {
|
|
6
|
+
readonly colorScheme?: ColorScheme;
|
|
7
|
+
}): string;
|
|
8
|
+
//# sourceMappingURL=stylesheet.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { adminThemeColorKeys, validateAdminTheme } from './validation.js';
|
|
2
|
+
import { defaultAdminTheme } from './default.js';
|
|
3
|
+
/** Internal document adapter: isolated previews cannot inherit the admin's stylesheet. */
|
|
4
|
+
export function renderAdminThemeVariables({ theme }, { colorScheme = 'light' } = {}) {
|
|
5
|
+
if (colorScheme !== 'light' && colorScheme !== 'dark')
|
|
6
|
+
throw new TypeError('Invalid resolved color scheme');
|
|
7
|
+
const checked = validateAdminTheme({ theme });
|
|
8
|
+
if (!checked.valid)
|
|
9
|
+
throw new TypeError(checked.errors.join('; '));
|
|
10
|
+
const values = adminThemeColorKeys.map((key) => `--jini-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}:${theme[colorScheme][key]};`);
|
|
11
|
+
values.push(`--jini-font-body:${theme.fonts.body};`, `--jini-font-heading:${theme.fonts.heading};`, `--jini-font-mono:${theme.fonts.mono ?? defaultAdminTheme.fonts.mono};`, `--jini-radius:${theme.radius ?? defaultAdminTheme.radius};`);
|
|
12
|
+
return `:root{color-scheme:${colorScheme};${values.join('')}}`;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=stylesheet.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The intentionally small palette every admin theme supplies in both schemes. */
|
|
2
|
+
export interface AdminThemeColors {
|
|
3
|
+
readonly primary: string;
|
|
4
|
+
readonly primaryInk: string;
|
|
5
|
+
readonly bg: string;
|
|
6
|
+
readonly surface: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly muted: string;
|
|
9
|
+
readonly border: string;
|
|
10
|
+
readonly danger: string;
|
|
11
|
+
readonly success: string;
|
|
12
|
+
readonly warning: string;
|
|
13
|
+
}
|
|
14
|
+
/** Product fonts and palette are data. Font loading URLs are stylesheet URLs. */
|
|
15
|
+
export interface AdminTheme {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly fonts: {
|
|
18
|
+
readonly body: string;
|
|
19
|
+
readonly heading: string;
|
|
20
|
+
readonly mono?: string;
|
|
21
|
+
readonly load?: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
readonly light: AdminThemeColors;
|
|
24
|
+
readonly dark: AdminThemeColors;
|
|
25
|
+
readonly radius?: string;
|
|
26
|
+
/** Unitless spacing multiplier, from 0.5 to 2. */
|
|
27
|
+
readonly density?: number;
|
|
28
|
+
}
|
|
29
|
+
export type ColorScheme = 'light' | 'dark';
|
|
30
|
+
export type ColorSchemePreference = ColorScheme | 'system';
|
|
31
|
+
export type MatchMediaPort = (query: string) => {
|
|
32
|
+
readonly matches: boolean;
|
|
33
|
+
addEventListener?: (type: 'change', listener: () => void) => void;
|
|
34
|
+
removeEventListener?: (type: 'change', listener: () => void) => void;
|
|
35
|
+
};
|
|
36
|
+
/** Only these DOM capabilities are needed; no ambient document is consulted. */
|
|
37
|
+
export type AdminThemeTarget = Pick<HTMLElement, 'style' | 'getAttribute' | 'setAttribute' | 'removeAttribute'>;
|
|
38
|
+
export type AdminThemeDocument = Pick<Document, 'head' | 'createElement' | 'baseURI'>;
|
|
39
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AdminTheme } from './types.js';
|
|
2
|
+
export declare const adminThemeColorKeys: readonly ["primary", "primaryInk", "bg", "surface", "text", "muted", "border", "danger", "success", "warning"];
|
|
3
|
+
type Validation = {
|
|
4
|
+
readonly valid: true;
|
|
5
|
+
readonly theme: AdminTheme;
|
|
6
|
+
} | {
|
|
7
|
+
readonly valid: false;
|
|
8
|
+
readonly errors: readonly string[];
|
|
9
|
+
};
|
|
10
|
+
/** Validate serializable theme data before any DOM or font-loading effect. */
|
|
11
|
+
export declare function validateAdminTheme({ theme }: {
|
|
12
|
+
readonly theme: unknown;
|
|
13
|
+
}): Validation;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=validation.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const adminThemeColorKeys = ['primary', 'primaryInk', 'bg', 'surface', 'text', 'muted', 'border', 'danger', 'success', 'warning'];
|
|
2
|
+
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
const safeValue = (value) => typeof value === 'string' && value.trim().length > 0
|
|
4
|
+
&& value.length <= 512 && !/[;{}\x00-\x1f<>\\]/.test(value) && !/url\s*\(|\/\*|\*\//i.test(value);
|
|
5
|
+
// Concrete palette values, deliberately excluding dependent var()/url() expressions.
|
|
6
|
+
const component = '(?:[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:%|deg|rad|grad|turn)?|none)';
|
|
7
|
+
const components = new RegExp(`^(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\\(\\s*${component}(?:\\s*,\\s*|\\s+)${component}(?:\\s*,\\s*|\\s+)${component}(?:(?:\\s*[,/]\\s*)${component})?\\s*\\)$`, 'i');
|
|
8
|
+
const colorValue = (value) => safeValue(value)
|
|
9
|
+
&& (/^#[\da-f]{3}(?:[\da-f]{1}|[\da-f]{3}|[\da-f]{5})?$/i.test(value)
|
|
10
|
+
|| components.test(value)
|
|
11
|
+
|| /^(?:transparent|black|white|red|green|blue|gray|grey)$/i.test(value));
|
|
12
|
+
/** Validate serializable theme data before any DOM or font-loading effect. */
|
|
13
|
+
export function validateAdminTheme({ theme }) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
if (!record(theme))
|
|
16
|
+
return { valid: false, errors: ['Theme must be an object'] };
|
|
17
|
+
if (!safeValue(theme.name))
|
|
18
|
+
errors.push('name must be a nonempty safe string');
|
|
19
|
+
const fonts = theme.fonts;
|
|
20
|
+
if (!record(fonts) || !safeValue(fonts.body) || !safeValue(fonts.heading) || (fonts.mono !== undefined && !safeValue(fonts.mono))) {
|
|
21
|
+
errors.push('fonts must provide body and heading font stacks');
|
|
22
|
+
}
|
|
23
|
+
if (record(fonts) && fonts.load !== undefined) {
|
|
24
|
+
if (!Array.isArray(fonts.load) || fonts.load.length > 16 || !fonts.load.every((url) => {
|
|
25
|
+
if (typeof url !== 'string' || url.length > 4096 || /[\x00-\x20<>]/.test(url))
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
return new URL(url, 'https://font-base.invalid').protocol === 'https:';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}))
|
|
34
|
+
errors.push('fonts.load must contain relative or HTTPS stylesheet URLs');
|
|
35
|
+
}
|
|
36
|
+
for (const scheme of ['light', 'dark']) {
|
|
37
|
+
const colors = theme[scheme];
|
|
38
|
+
if (!record(colors)) {
|
|
39
|
+
errors.push(`${scheme} must be a complete palette`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const key of adminThemeColorKeys)
|
|
43
|
+
if (!Object.hasOwn(colors, key) || !colorValue(colors[key]))
|
|
44
|
+
errors.push(`${scheme}.${key} must be a concrete CSS color`);
|
|
45
|
+
for (const key of Object.keys(colors))
|
|
46
|
+
if (!adminThemeColorKeys.includes(key))
|
|
47
|
+
errors.push(`${scheme}.${key} is not a theme color`);
|
|
48
|
+
}
|
|
49
|
+
if (theme.radius !== undefined && (typeof theme.radius !== 'string' || !/^(?:0|\d+(?:\.\d+)?(?:px|rem|em))$/.test(theme.radius)))
|
|
50
|
+
errors.push('radius must be a nonnegative CSS length');
|
|
51
|
+
if (theme.density !== undefined && (typeof theme.density !== 'number' || !Number.isFinite(theme.density) || theme.density < 0.5 || theme.density > 2))
|
|
52
|
+
errors.push('density must be between 0.5 and 2');
|
|
53
|
+
return errors.length ? { valid: false, errors } : { valid: true, theme: theme };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=validation.js.map
|
|
@@ -4,7 +4,9 @@ export type { SelectOption };
|
|
|
4
4
|
/** Use this custom listbox when in-panel search is needed: a native OS option list cannot host it.
|
|
5
5
|
* Native selects otherwise avoid the accessibility/behavior tradeoffs of a simulated listbox.
|
|
6
6
|
* JSX stays separate from dropdown state/DOM effects so injected useDropdown can render against a
|
|
7
|
-
* fake without geometry or listeners. The body portal escapes clipping/stacking ancestors
|
|
7
|
+
* fake without geometry or listeners. The body portal escapes clipping/stacking ancestors;
|
|
8
|
+
* inside a native dialog, a dialog-local portal also keeps options in its top layer and out of
|
|
9
|
+
* the inert page. Hosts can supply a container outside their own clipping regions.
|
|
8
10
|
* Agent selection uses trigger/option clicks rather than native select_option; the -option namespace
|
|
9
11
|
* keeps caller values from colliding with literal segments. Explicit base handles are not silently
|
|
10
12
|
* sanitized: invalid caller choices should fail rather than publish a different handle. */
|
|
@@ -20,6 +22,12 @@ export interface SelectProps {
|
|
|
20
22
|
/** Injectable state/effect seam; rendering tests need not drive portal positioning or listeners. */
|
|
21
23
|
useDropdown?: typeof useSelectDropdown | undefined;
|
|
22
24
|
agentHandle?: string | undefined;
|
|
25
|
+
/** Host-owned portal destination. Defaults to the trigger's nearest dialog, then document.body.
|
|
26
|
+
* Within a modal dialog, supply a descendant of that dialog so the panel remains interactive.
|
|
27
|
+
* Keep this container outside scrolling/clipping regions and free of transforms that establish
|
|
28
|
+
* a fixed-position containing block; panel coordinates are viewport-relative.
|
|
29
|
+
* @example <Select {...selectionProps} portalContainer={dialogOverlayElement} /> */
|
|
30
|
+
portalContainer?: Element | DocumentFragment | null | undefined;
|
|
23
31
|
/** Caller-bound translation function; source copy is the default. */
|
|
24
32
|
translate?: Translate | undefined;
|
|
25
33
|
/** Legacy alias for callers porting the original select. */
|
|
@@ -34,6 +42,6 @@ export declare function resolveSelectTriggerLabel({ selectedOption, placeholder
|
|
|
34
42
|
text: string;
|
|
35
43
|
className: string;
|
|
36
44
|
};
|
|
37
|
-
/** Controlled searchable listbox with
|
|
38
|
-
export declare function Select(props: SelectProps): import("react").JSX.Element;
|
|
45
|
+
/** Controlled searchable listbox with viewport positioning, dialog-local portals and keyboard navigation. */
|
|
46
|
+
export declare function Select(props: SelectProps, _optional?: Record<string, never>): import("react").JSX.Element;
|
|
39
47
|
//# sourceMappingURL=Select.d.ts.map
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
+
export { focusableInDomOrder, resolveTabTarget } from '../../../../react/components/select-popover.js';
|
|
1
2
|
export interface SelectOption {
|
|
2
3
|
value: string;
|
|
3
4
|
label: string;
|
|
4
5
|
}
|
|
5
|
-
export declare function focusableInDomOrder({ exclude }: {
|
|
6
|
-
exclude: HTMLElement | null;
|
|
7
|
-
}): HTMLElement[];
|
|
8
6
|
export interface PanelPosition {
|
|
9
7
|
top?: number | undefined;
|
|
10
8
|
bottom?: number | undefined;
|
|
@@ -35,20 +33,17 @@ export declare function usePanelPosition({ open, triggerRef, panelRef, searchInp
|
|
|
35
33
|
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
|
36
34
|
showSearch: boolean;
|
|
37
35
|
onOutOfView: () => void;
|
|
38
|
-
}): {
|
|
36
|
+
}, _optional?: Record<string, never>): {
|
|
39
37
|
position: PanelPosition | null;
|
|
40
38
|
setPosition: import("react").Dispatch<import("react").SetStateAction<PanelPosition | null>>;
|
|
39
|
+
updatePosition: () => void;
|
|
41
40
|
};
|
|
41
|
+
/** Compatibility geometry seam; lifecycle decisions are owned by the shared popover. */
|
|
42
42
|
export declare function repositionOrClose({ trigger, onOutOfView, setPosition }: {
|
|
43
43
|
trigger: HTMLElement;
|
|
44
44
|
onOutOfView: () => void;
|
|
45
45
|
setPosition: (position: PanelPosition) => void;
|
|
46
|
-
}): void;
|
|
47
|
-
export declare function resolveTabTarget({ panel, trigger, shiftKey }: {
|
|
48
|
-
panel: HTMLElement | null;
|
|
49
|
-
trigger: HTMLElement | null;
|
|
50
|
-
shiftKey: boolean;
|
|
51
|
-
}): HTMLElement | null;
|
|
46
|
+
}, _optional?: Record<string, never>): void;
|
|
52
47
|
export declare function edgeHighlightIndex({ filteredLength, edge }: {
|
|
53
48
|
filteredLength: number;
|
|
54
49
|
edge: "first" | "last";
|
|
@@ -94,12 +89,12 @@ export declare function useSelectDropdown({ value, onChange, options, }: {
|
|
|
94
89
|
filtered: SelectOption[];
|
|
95
90
|
selectedOption: SelectOption | null;
|
|
96
91
|
openPanel: () => void;
|
|
97
|
-
closePanel: (
|
|
92
|
+
closePanel: ({ refocusTrigger }: {
|
|
98
93
|
refocusTrigger: boolean;
|
|
99
94
|
}) => void;
|
|
100
95
|
selectOption: (option: SelectOption) => void;
|
|
101
|
-
handleTriggerKeyDown: (
|
|
102
|
-
handlePanelKeyDown: (
|
|
96
|
+
handleTriggerKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
|
|
97
|
+
handlePanelKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
103
98
|
optionId: (index: number) => string;
|
|
104
99
|
};
|
|
105
100
|
//# sourceMappingURL=Select.hooks.d.ts.map
|
|
@@ -5,18 +5,14 @@
|
|
|
5
5
|
* React hook ordering. Extracting the live DOM key handling from pure highlight decisions
|
|
6
6
|
* reduces the review burden without changing focus or event-order contracts.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { useSelectPopover, useSelectPopoverPosition, repositionSelectPopover, openSelectOnKey, applySelectNavigationKey } from '../../../../react/components/select-popover.js';
|
|
9
|
+
export { focusableInDomOrder, resolveTabTarget } from '../../../../react/components/select-popover.js';
|
|
10
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
9
11
|
// Below eight options, a search field adds clutter to a list that is already easy to scan.
|
|
10
12
|
const SEARCH_VISIBILITY_THRESHOLD = 8;
|
|
11
13
|
// 280px is only a placement heuristic; the panel actual maxHeight and scroll limit use
|
|
12
14
|
// the remaining viewport space so a low trigger does not open a mostly off-screen panel.
|
|
13
15
|
const ESTIMATED_PANEL_HEIGHT = 280;
|
|
14
|
-
// The panel is portaled to the end of document.body. Native Tab would follow that DOM
|
|
15
|
-
// position toward browser chrome; walk the trigger real DOM-order neighbours instead.
|
|
16
|
-
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
17
|
-
export function focusableInDomOrder({ exclude }) {
|
|
18
|
-
return Array.from(document.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => !exclude || !exclude.contains(el));
|
|
19
|
-
}
|
|
20
16
|
/**
|
|
21
17
|
* Use fixed viewport-relative coordinates to escape scrolling/overflow-hidden ancestors
|
|
22
18
|
* without coupling to their layout. maxHeight uses actual remaining space, not the estimate,
|
|
@@ -41,97 +37,14 @@ export function buildOptionId({ listboxId, index }) {
|
|
|
41
37
|
* Positioning reports a clipped or obscured trigger through onOutOfView; deciding whether
|
|
42
38
|
* to close belongs to the dropdown lifecycle rather than this reusable measurement hook.
|
|
43
39
|
*/
|
|
44
|
-
export function usePanelPosition({ open, triggerRef, panelRef, searchInputRef, showSearch, onOutOfView, }) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// — `panelRef`/`searchInputRef` are still unattached, so focusing them here would silently no-op.
|
|
49
|
-
// Computing `position` schedules a state update; because this is a `useLayoutEffect`, React
|
|
50
|
-
// re-renders and re-runs layout effects synchronously before the browser paints, rather than
|
|
51
|
-
// waiting a frame. (2) On that second pass `position` is non-null, the portal now exists in the
|
|
52
|
-
// DOM, and the refs are populated — only now is it correct to move focus into it. Found live via
|
|
53
|
-
// this component's own tests: every keyboard-driven test failed until this was split out, because
|
|
54
|
-
// `panelRef.current`/`searchInputRef.current` were both still `null` when focus was attempted in
|
|
55
|
-
// the same pass that first computed the position.
|
|
56
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: two-phase focus effect — see comment above; refs/showSearch intentionally excluded, only open/position redrive it.
|
|
57
|
-
useLayoutEffect(() => {
|
|
58
|
-
if (!open)
|
|
59
|
-
return;
|
|
60
|
-
if (!position) {
|
|
61
|
-
if (triggerRef.current)
|
|
62
|
-
setPosition(computePosition({ trigger: triggerRef.current }));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (showSearch) {
|
|
66
|
-
searchInputRef.current?.focus();
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
panelRef.current?.focus();
|
|
70
|
-
}
|
|
71
|
-
}, [open, position]);
|
|
72
|
-
// Keeps the panel anchored to the trigger if the surrounding page/dialog scrolls or resizes while
|
|
73
|
-
// it's open — `.widget-picker-body`'s own `overflow-y: auto` made this a real, live scenario
|
|
74
|
-
// rather than a hypothetical one. `scroll` needs the capture phase: an inner element's own scroll
|
|
75
|
-
// does not bubble to `window` in the bubble phase. The decision logic itself is
|
|
76
|
-
// `repositionOrClose`, above; this effect is only the DOM listener wiring.
|
|
77
|
-
//
|
|
78
|
-
// Found live in a real browser, not by this component's own test suite: scrolling a host's
|
|
79
|
-
// scrolling ancestor far enough that the trigger scrolls out of its own visible clip used to leave
|
|
80
|
-
// this effect happily recomputing a mathematically-correct `position` for it — the panel just
|
|
81
|
-
// ended up following the trigger to an off-screen (or behind-the-header, obscured) spot, floating
|
|
82
|
-
// there indefinitely, nominally still "open" with no visible way to tell.
|
|
83
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll/resize listener wiring — see comment above; triggerRef/onOutOfView intentionally excluded, only `open` re-arms it.
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
if (!open)
|
|
86
|
-
return;
|
|
87
|
-
function reposition() {
|
|
88
|
-
const el = triggerRef.current;
|
|
89
|
-
if (!el)
|
|
90
|
-
return;
|
|
91
|
-
repositionOrClose({ trigger: el, onOutOfView: onOutOfView, setPosition: setPosition });
|
|
92
|
-
}
|
|
93
|
-
window.addEventListener("scroll", reposition, true);
|
|
94
|
-
window.addEventListener("resize", reposition);
|
|
95
|
-
return () => {
|
|
96
|
-
window.removeEventListener("scroll", reposition, true);
|
|
97
|
-
window.removeEventListener("resize", reposition);
|
|
98
|
-
};
|
|
99
|
-
}, [open]);
|
|
100
|
-
return { position, setPosition };
|
|
101
|
-
}
|
|
102
|
-
export function repositionOrClose({ trigger, onOutOfView, setPosition }) {
|
|
103
|
-
const rect = trigger.getBoundingClientRect();
|
|
104
|
-
const outOfViewport = rect.bottom <= 0 || rect.top >= window.innerHeight || rect.right <= 0 || rect.left >= window.innerWidth;
|
|
105
|
-
if (outOfViewport) {
|
|
106
|
-
onOutOfView();
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if (typeof document.elementFromPoint === "function") {
|
|
110
|
-
const topmostAtCenter = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
111
|
-
const obscured = !topmostAtCenter || !(trigger.contains(topmostAtCenter) || topmostAtCenter.contains(trigger));
|
|
112
|
-
if (obscured) {
|
|
113
|
-
onOutOfView();
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
setPosition(computePosition({ trigger: trigger }));
|
|
40
|
+
export function usePanelPosition({ open, triggerRef, panelRef, searchInputRef, showSearch, onOutOfView, }, _optional = {}) {
|
|
41
|
+
return useSelectPopoverPosition({ open, triggerRef, panelRef, measurePosition: computePosition }, {
|
|
42
|
+
focusInitial: ({ panel }) => (showSearch ? searchInputRef.current : panel)?.focus(), onOutOfView,
|
|
43
|
+
});
|
|
118
44
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return;
|
|
123
|
-
function onDocMouseDown(e) {
|
|
124
|
-
const target = e.target;
|
|
125
|
-
if (triggerRef.current?.contains(target))
|
|
126
|
-
return;
|
|
127
|
-
if (panelRef.current?.contains(target))
|
|
128
|
-
return;
|
|
129
|
-
onOutside();
|
|
130
|
-
}
|
|
131
|
-
document.addEventListener("mousedown", onDocMouseDown);
|
|
132
|
-
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
|
133
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
-
}, [open]);
|
|
45
|
+
/** Compatibility geometry seam; lifecycle decisions are owned by the shared popover. */
|
|
46
|
+
export function repositionOrClose({ trigger, onOutOfView, setPosition }, _optional = {}) {
|
|
47
|
+
repositionSelectPopover({ trigger, measurePosition: computePosition, setPosition }, { onOutOfView });
|
|
135
48
|
}
|
|
136
49
|
// A search can remove the previously highlighted option; re-anchor to the first match
|
|
137
50
|
// rather than keep a stale index outside the narrowed list.
|
|
@@ -160,13 +73,6 @@ function useScrollHighlightedIntoView({ open, highlightedIndex, optionRefs, }) {
|
|
|
160
73
|
el.scrollIntoView({ block: "nearest" });
|
|
161
74
|
}, [open, highlightedIndex]);
|
|
162
75
|
}
|
|
163
|
-
export function resolveTabTarget({ panel, trigger, shiftKey }) {
|
|
164
|
-
const nodes = focusableInDomOrder({ exclude: panel });
|
|
165
|
-
const triggerIndex = trigger ? nodes.indexOf(trigger) : -1;
|
|
166
|
-
if (triggerIndex < 0)
|
|
167
|
-
return null;
|
|
168
|
-
return nodes[triggerIndex + (shiftKey ? -1 : 1)] ?? null;
|
|
169
|
-
}
|
|
170
76
|
export function edgeHighlightIndex({ filteredLength, edge }) {
|
|
171
77
|
if (filteredLength === 0)
|
|
172
78
|
return -1;
|
|
@@ -176,93 +82,25 @@ export function highlightedOptionOrNull({ filtered, highlightedIndex }) {
|
|
|
176
82
|
return highlightedIndex >= 0 ? (filtered[highlightedIndex] ?? null) : null;
|
|
177
83
|
}
|
|
178
84
|
export function applyHighlightKey({ key, filtered, highlightedIndex, actions }) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
actions.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
case "Home":
|
|
189
|
-
actions.preventDefault();
|
|
190
|
-
actions.setHighlightedIndex(edgeHighlightIndex({ filteredLength: filtered.length, edge: "first" }));
|
|
191
|
-
return true;
|
|
192
|
-
case "End":
|
|
193
|
-
actions.preventDefault();
|
|
194
|
-
actions.setHighlightedIndex(edgeHighlightIndex({ filteredLength: filtered.length, edge: "last" }));
|
|
195
|
-
return true;
|
|
196
|
-
case "Enter": {
|
|
197
|
-
actions.preventDefault();
|
|
198
|
-
const option = highlightedOptionOrNull({ filtered: filtered, highlightedIndex: highlightedIndex });
|
|
199
|
-
if (option)
|
|
200
|
-
actions.selectOption(option);
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
default:
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
85
|
+
return applySelectNavigationKey({ key, actions: {
|
|
86
|
+
preventDefault: actions.preventDefault, move: actions.moveHighlight,
|
|
87
|
+
edge: (edge) => actions.setHighlightedIndex(edgeHighlightIndex({ filteredLength: filtered.length, edge })),
|
|
88
|
+
choose: () => {
|
|
89
|
+
const option = highlightedOptionOrNull({ filtered, highlightedIndex });
|
|
90
|
+
if (option)
|
|
91
|
+
actions.selectOption(option);
|
|
92
|
+
},
|
|
93
|
+
} });
|
|
206
94
|
}
|
|
207
95
|
// Keep live event/focus effects in the key handler; move the trigger-neighbour calculation
|
|
208
96
|
// out so changing the DOM effects does not bury the independently testable focus decision.
|
|
209
|
-
function useSelectKeyboardHandlers({ disabled, open, openPanel, closePanel, moveHighlight, selectOption, highlightedIndex, setHighlightedIndex, filtered, triggerRef, panelRef, }) {
|
|
210
|
-
function handleTriggerKeyDown(e) {
|
|
211
|
-
if (disabled || open)
|
|
212
|
-
return;
|
|
213
|
-
switch (e.key) {
|
|
214
|
-
case "Enter":
|
|
215
|
-
case " ":
|
|
216
|
-
case "ArrowDown":
|
|
217
|
-
case "ArrowUp":
|
|
218
|
-
e.preventDefault();
|
|
219
|
-
openPanel();
|
|
220
|
-
break;
|
|
221
|
-
default:
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
// Escape/Tab need live propagation and focus effects. Highlight/selection keys delegate
|
|
226
|
-
// to the pure helper; preventDefault is injected to preserve its order before each action.
|
|
227
|
-
function handlePanelKeyDown(e) {
|
|
228
|
-
if (e.key === "Escape") {
|
|
229
|
-
// Must not bubble: a host dialog (`WidgetPickerDialog`) listens for Escape on `document`
|
|
230
|
-
// to cancel the whole modal. Without stopping propagation here, closing just this dropdown
|
|
231
|
-
// would also close the dialog underneath it — the regression `Select.unit.test.tsx` pins.
|
|
232
|
-
e.preventDefault();
|
|
233
|
-
e.stopPropagation();
|
|
234
|
-
closePanel({ refocusTrigger: true });
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
if (e.key === "Tab") {
|
|
238
|
-
// See `resolveTabTarget`'s own comment: walk the trigger's real DOM-order neighbours rather
|
|
239
|
-
// than let native Tab handling run, since this event is bubbling from a panel portaled to
|
|
240
|
-
// the end of `document.body`, not sitting next to the trigger in the DOM.
|
|
241
|
-
const target = resolveTabTarget({ panel: panelRef.current, trigger: triggerRef.current, shiftKey: e.shiftKey });
|
|
242
|
-
e.preventDefault();
|
|
243
|
-
closePanel({ refocusTrigger: false });
|
|
244
|
-
target?.focus();
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
applyHighlightKey({ key: e.key, filtered: filtered, highlightedIndex: highlightedIndex, actions: {
|
|
248
|
-
preventDefault: () => e.preventDefault(),
|
|
249
|
-
moveHighlight,
|
|
250
|
-
setHighlightedIndex,
|
|
251
|
-
selectOption,
|
|
252
|
-
} });
|
|
253
|
-
}
|
|
254
|
-
return { handleTriggerKeyDown, handlePanelKeyDown };
|
|
255
|
-
}
|
|
256
97
|
/**
|
|
257
98
|
* Separate hook state from rendering so search visibility, wraparound, placement and
|
|
258
99
|
* out-of-view closing can be asserted directly without coupling every case to portal DOM.
|
|
259
100
|
*/
|
|
260
101
|
export function useSelectDropdown({ value, onChange, options, }, { disabled } = {}) {
|
|
261
|
-
const [open, setOpen] = useState(false);
|
|
262
102
|
const [query, setQuery] = useState("");
|
|
263
103
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
264
|
-
const triggerRef = useRef(null);
|
|
265
|
-
const panelRef = useRef(null);
|
|
266
104
|
const searchInputRef = useRef(null);
|
|
267
105
|
// Index -> `<li>` node, so the highlight-follow effect below can scroll the right row into view
|
|
268
106
|
// without an id-based `querySelector` (this component's ids come from `useId()`, which can
|
|
@@ -271,6 +109,11 @@ export function useSelectDropdown({ value, onChange, options, }, { disabled } =
|
|
|
271
109
|
const optionRefs = useRef(new Map());
|
|
272
110
|
const listboxId = useId();
|
|
273
111
|
const showSearch = options.length >= SEARCH_VISIBILITY_THRESHOLD;
|
|
112
|
+
const popover = useSelectPopover({ measurePosition: computePosition }, {
|
|
113
|
+
disabled, dismissWhenHidden: true,
|
|
114
|
+
focusInitial: ({ panel }) => (showSearch ? searchInputRef.current : panel)?.focus(),
|
|
115
|
+
});
|
|
116
|
+
const { open, position, triggerRef, panelRef, closePanel } = popover;
|
|
274
117
|
const filtered = useMemo(() => {
|
|
275
118
|
if (!showSearch || !query.trim())
|
|
276
119
|
return options;
|
|
@@ -283,15 +126,9 @@ export function useSelectDropdown({ value, onChange, options, }, { disabled } =
|
|
|
283
126
|
return;
|
|
284
127
|
const initialIndex = options.findIndex((o) => o.value === value);
|
|
285
128
|
setQuery("");
|
|
286
|
-
|
|
129
|
+
popover.openPanel();
|
|
287
130
|
setHighlightedIndex(initialIndex >= 0 ? initialIndex : options.length ? 0 : -1);
|
|
288
131
|
}
|
|
289
|
-
function closePanel(opts) {
|
|
290
|
-
setOpen(false);
|
|
291
|
-
setPosition(null); // forces the effect below through its "measure, then focus" two-phase sequence again on the next open, rather than focusing at a stale, pre-close position
|
|
292
|
-
if (opts.refocusTrigger)
|
|
293
|
-
triggerRef.current?.focus();
|
|
294
|
-
}
|
|
295
132
|
function selectOption(option) {
|
|
296
133
|
onChange(option.value);
|
|
297
134
|
closePanel({ refocusTrigger: true });
|
|
@@ -303,35 +140,20 @@ export function useSelectDropdown({ value, onChange, options, }, { disabled } =
|
|
|
303
140
|
return (current + delta + filtered.length) % filtered.length;
|
|
304
141
|
});
|
|
305
142
|
}
|
|
306
|
-
const { position, setPosition } = usePanelPosition({
|
|
307
|
-
open,
|
|
308
|
-
triggerRef,
|
|
309
|
-
panelRef,
|
|
310
|
-
searchInputRef,
|
|
311
|
-
showSearch,
|
|
312
|
-
onOutOfView: () => closePanel({ refocusTrigger: false }),
|
|
313
|
-
});
|
|
314
|
-
useCloseOnOutsideClick({
|
|
315
|
-
open,
|
|
316
|
-
triggerRef,
|
|
317
|
-
panelRef,
|
|
318
|
-
onOutside: () => closePanel({ refocusTrigger: false }),
|
|
319
|
-
});
|
|
320
143
|
useResetHighlightOnQueryChange({ open, query, hasMatches: filtered.length > 0, setHighlightedIndex });
|
|
321
144
|
useScrollHighlightedIntoView({ open, highlightedIndex, optionRefs });
|
|
322
|
-
|
|
323
|
-
disabled
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
});
|
|
145
|
+
function handleTriggerKeyDown(event) {
|
|
146
|
+
openSelectOnKey({ event, open, openPanel }, { disabled });
|
|
147
|
+
}
|
|
148
|
+
// Escape/Tab need live propagation and focus effects. Highlight/selection keys delegate
|
|
149
|
+
// to the pure helper; preventDefault is injected to preserve its order before each action.
|
|
150
|
+
function handlePanelKeyDown(event) {
|
|
151
|
+
if (popover.handleDismissKey(event))
|
|
152
|
+
return;
|
|
153
|
+
applyHighlightKey({ key: event.key, filtered, highlightedIndex, actions: {
|
|
154
|
+
preventDefault: () => event.preventDefault(), moveHighlight, setHighlightedIndex, selectOption,
|
|
155
|
+
} });
|
|
156
|
+
}
|
|
335
157
|
const optionId = (index) => buildOptionId({ listboxId: listboxId, index: index });
|
|
336
158
|
return {
|
|
337
159
|
open,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { resolveSelectPortalContainer } from '../../../../react/components/select-popover.js';
|
|
2
3
|
import { createPortal } from "react-dom";
|
|
3
4
|
import { agentHandle } from "@jini-ai/agentic";
|
|
4
5
|
import { buildAgentListHandles } from "@jini-ai/agentic";
|
|
@@ -20,8 +21,8 @@ function resolveSelectTriggerActiveDescendant(open, highlightedIndex, optionId)
|
|
|
20
21
|
function resolveSelectTriggerHandleProps(base, ariaLabel, placeholder) {
|
|
21
22
|
return base ? agentHandle({ handle: base }, { role: "button", label: ariaLabel ?? placeholder ?? "Select an option" }) : {};
|
|
22
23
|
}
|
|
23
|
-
/** Controlled searchable listbox with
|
|
24
|
-
export function Select(props) {
|
|
24
|
+
/** Controlled searchable listbox with viewport positioning, dialog-local portals and keyboard navigation. */
|
|
25
|
+
export function Select(props, _optional = {}) {
|
|
25
26
|
const { value, onChange, options, placeholder, id, disabled, useDropdown = useSelectDropdown, agentHandle: base, translate, t = translate ?? ((key) => key) } = props;
|
|
26
27
|
const ariaLabel = props["aria-label"];
|
|
27
28
|
const ariaLabelledBy = props["aria-labelledby"];
|
|
@@ -40,7 +41,7 @@ export function Select(props) {
|
|
|
40
41
|
// when `base` itself is unset, so `SelectPanel` can tell "opted out" apart from "no options match".
|
|
41
42
|
const optionHandles = base ? buildAgentListHandles({ prefix: `${base}-option`, ids: filtered.map((option) => option.value) }) : undefined;
|
|
42
43
|
return (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", ref: triggerRef, id: id, className: "select-trigger", role: "combobox", "aria-expanded": open, "aria-haspopup": "listbox", "aria-controls": open ? listboxId : undefined, "aria-activedescendant": activeDescendant, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, onClick: () => (open ? closePanel({ refocusTrigger: false }) : openPanel()), onKeyDown: handleTriggerKeyDown, ...resolveSelectTriggerHandleProps(base, ariaLabel, placeholder), children: [_jsx("span", { className: triggerLabel.className, children: triggerLabel.text }), _jsx("span", { className: "select-trigger-chevron", "aria-hidden": "true" })] }), open && position
|
|
43
|
-
? createPortal(_jsx(SelectPanel, { panelRef: panelRef, position: position, showSearch: showSearch, searchInputRef: searchInputRef, query: query, setQuery: setQuery, filtered: filtered, value: value, highlightedIndex: highlightedIndex, listboxId: listboxId, optionId: optionId, setOptionRef: setOptionRef, onHighlight: setHighlightedIndex, onSelect: selectOption, onKeyDown: handlePanelKeyDown, searchHandle: base ? `${base}-search` : undefined, optionHandles: optionHandles, t: t }),
|
|
44
|
+
? createPortal(_jsx(SelectPanel, { panelRef: panelRef, position: position, showSearch: showSearch, searchInputRef: searchInputRef, query: query, setQuery: setQuery, filtered: filtered, value: value, highlightedIndex: highlightedIndex, listboxId: listboxId, optionId: optionId, setOptionRef: setOptionRef, onHighlight: setHighlightedIndex, onSelect: selectOption, onKeyDown: handlePanelKeyDown, searchHandle: base ? `${base}-search` : undefined, optionHandles: optionHandles, t: t }), resolveSelectPortalContainer({ trigger: triggerRef.current }, { container: props.portalContainer }))
|
|
44
45
|
: null] }));
|
|
45
46
|
}
|
|
46
47
|
//# sourceMappingURL=Select.js.map
|