@petrarca/sonnet-core 0.6.0 → 0.8.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 CHANGED
@@ -1,28 +1,28 @@
1
1
  # @petrarca/sonnet-core
2
2
 
3
- Foundation utilities for the Petrarca Sonnet component library. Leaf package —
4
- all other Sonnet packages depend on it.
5
-
6
- ## Entry points
7
-
8
- | Import | What |
9
- |---|---|
10
- | `@petrarca/sonnet-core` | `cn`, `isEqual`, `formatDateTime`, `normalizeError`, logging |
11
- | `@petrarca/sonnet-core/schema` | JSON Schema types, resolution, validation, diffing |
12
- | `@petrarca/sonnet-core/search` | Filter expression types (`FilterExpr`, `buildListParams`) |
13
- | `@petrarca/sonnet-core/hooks` | React hooks (see below) |
14
- | `@petrarca/sonnet-core/entityOptions` | Entity option fetcher context for async select/autocomplete |
15
-
16
- ## Hooks
17
-
18
- | Hook | Purpose |
19
- |---|---|
20
- | `useClipboard` | Copy text to clipboard with copied-state timeout |
21
- | `useDisclosure` | Boolean open/close/toggle state |
22
- | `useFormState` | Generic form state with dirty tracking |
23
- | `useEntityOptions` | Async entity fetching for select/autocomplete components |
24
- | `useResizablePanel` | Drag-to-resize panel width |
25
- | `useTreeExpansion` | Expand/collapse state for `TreeView` components |
3
+ The foundation of the Petrarca Sonnet component library: schema handling,
4
+ filter expressions, React hooks and formatting helpers, with no components
5
+ attached. Every other `@petrarca/sonnet-*` package depends on it.
6
+
7
+ ## What it provides
8
+
9
+ **A JSON Schema toolkit.** The `JsonSchema` and `UISchema` types that the rest
10
+ of the workspace agrees on, plus `$ref` and conditional resolution, validation
11
+ through Ajv, visibility rules and form diffing.
12
+
13
+ **A Zod bridge.** Author a schema in Zod, carry the `x-ui-*` presentation hints
14
+ on `.meta()`, and convert once to the JSON Schema the renderers read.
15
+
16
+ **Filter expressions.** A serializable AST for search and faceted filtering,
17
+ and the helpers that turn it into list query parameters.
18
+
19
+ **Component configuration.** An app-wide default-props provider and locale
20
+ resolution, so a behavioural prop can be set once for a whole application
21
+ instead of at every call site.
22
+
23
+ **Hooks and utilities.** Clipboard, disclosure, form state, tree expansion,
24
+ resizable panels and async entity options, alongside date, time, number, id,
25
+ error and class-merging helpers.
26
26
 
27
27
  ## Install
28
28
 
@@ -30,6 +30,45 @@ all other Sonnet packages depend on it.
30
30
  pnpm add @petrarca/sonnet-core
31
31
  ```
32
32
 
33
+ | Peer | Range | Needed for |
34
+ |---|---|---|
35
+ | `react` | `>=19` | The `./hooks`, `./config` and `./entityOptions` subpaths |
36
+ | `@tanstack/react-query` | `>=5` | `useEntityOptions` only; optional otherwise |
37
+
38
+ Ajv, Zod, `nanoid`, `clsx`, `tailwind-merge` and `class-variance-authority`
39
+ arrive as regular dependencies. There is nothing else to install.
40
+
41
+ ## Usage
42
+
43
+ The package is six entry points, and the root deliberately re-exports none of
44
+ the other five: a caller who wants `formatDate` should not pay for Ajv, and one
45
+ who wants the filter AST should not pull in React.
46
+
47
+ ```ts
48
+ import { formatDate, generateId } from "@petrarca/sonnet-core";
49
+ import { validateFormData, type JsonSchema } from "@petrarca/sonnet-core/schema";
50
+ import { buildListParams } from "@petrarca/sonnet-core/search";
51
+ import { useDisclosure } from "@petrarca/sonnet-core/hooks";
52
+ import { FetcherContext } from "@petrarca/sonnet-core/entityOptions";
53
+ import { SonnetConfigProvider } from "@petrarca/sonnet-core/config";
54
+ ```
55
+
56
+ Which symbol lives in which subpath, and what each one costs to import, is in
57
+ the usage guides:
58
+ [the schema toolkit](docs/usage/schema-toolkit.md),
59
+ [schemas from Zod](docs/usage/zod-schemas.md),
60
+ [search filters](docs/usage/search-filters.md),
61
+ [component config](docs/usage/component-config.md) and
62
+ [hooks and utilities](docs/usage/hooks-and-utilities.md).
63
+
64
+ ## Documentation
65
+
66
+ - [Usage guides](docs/usage/index.md) -- task-oriented, for consumers.
67
+ - [Design docs](docs/design/index.md) -- what belongs in this package and why
68
+ it is split into six subpaths.
69
+ - [Workspace documentation](../../docs/README.md) -- how the packages layer, the
70
+ shared schema extension contract, and the architecture decision records.
71
+
33
72
  ## License
34
73
 
35
- See [LICENSE.md](../../LICENSE.md).
74
+ Apache 2.0. See [LICENSE.md](../../LICENSE.md).
@@ -28,6 +28,32 @@ import React__default from 'react';
28
28
  */
29
29
  interface SonnetComponentDefaults {
30
30
  }
31
+ /**
32
+ * When a form reveals a field's validation error.
33
+ *
34
+ * - `onTouched` (default) -- a field reveals its error once it has been
35
+ * edited then blurred, and every invalid field reveals on a save attempt.
36
+ * Feedback lands when the user leaves a field (rjsf's `liveValidate="onBlur"`).
37
+ * The blur-reveal grows the field in flow; the action row's buttons keep
38
+ * focus on press (FormActions preventDefault) so that growth never shifts a
39
+ * button out from under a click.
40
+ * - `onSubmit` -- quiet until the first save attempt, then reveal all. Matches
41
+ * rjsf's default (no live validation unless `liveValidate` is opted into).
42
+ * - `always` -- every invalid field shows its error immediately.
43
+ *
44
+ * This governs display only. Validity is always computed regardless.
45
+ */
46
+ type ValidationDisplay = "onTouched" | "always" | "onSubmit";
47
+ /**
48
+ * Whether a form's save action is available while the form is invalid.
49
+ *
50
+ * - `reveal` (default) -- Save stays enabled; pressing it on an invalid form
51
+ * reveals every field's error and does not submit. The button always
52
+ * explains why it did not save.
53
+ * - `disable` -- Save is disabled while invalid. Simpler, but a disabled
54
+ * button gives no reason, and it cannot trigger the save-attempt reveal.
55
+ */
56
+ type SaveWhenInvalid = "reveal" | "disable";
31
57
  /**
32
58
  * Cross-cutting global settings. Extend via declaration merging as needed.
33
59
  * Empty by default; packages add fields without editing this file.
@@ -40,6 +66,16 @@ interface SonnetGlobalConfig {
40
66
  * back to the browser locale (`navigator.language`), then `"en"`.
41
67
  */
42
68
  locale?: string;
69
+ /**
70
+ * App-wide default for when forms reveal validation errors. A form may
71
+ * override it with its own `validationDisplay` prop. Defaults to `onTouched`.
72
+ */
73
+ validationDisplay?: ValidationDisplay;
74
+ /**
75
+ * App-wide default for whether Save is available on an invalid form. A form
76
+ * may override it with its own `saveWhenInvalid` prop. Defaults to `reveal`.
77
+ */
78
+ saveWhenInvalid?: SaveWhenInvalid;
43
79
  }
44
80
  /** The full app-wide configuration object held by SonnetConfigProvider. */
45
81
  interface SonnetConfig {
@@ -97,4 +133,19 @@ declare function useComponentDefaults<Props extends Record<string, unknown>>(com
97
133
  declare function getBrowserLocale(): string;
98
134
  declare function useLocale(override?: string): string;
99
135
 
100
- export { type SonnetComponentDefaults, type SonnetConfig, SonnetConfigContext, SonnetConfigProvider, type SonnetConfigProviderProps, type SonnetGlobalConfig, getBrowserLocale, useComponentDefaults, useLocale };
136
+ /**
137
+ * useGlobalSetting -- resolve one SonnetGlobalConfig field.
138
+ *
139
+ * Resolution order: explicit override (a component's own prop) > provider
140
+ * global (SonnetConfigProvider) > the caller's fallback. This is the shared
141
+ * shape behind useLocale, useValidationDisplay and useSaveWhenInvalid: a house
142
+ * default set once at the root, overridable per instance.
143
+ */
144
+
145
+ declare function useGlobalSetting<K extends keyof SonnetGlobalConfig>(key: K, override: SonnetGlobalConfig[K], fallback: NonNullable<SonnetGlobalConfig[K]>): NonNullable<SonnetGlobalConfig[K]>;
146
+ /** Resolve when a form reveals validation errors. Defaults to `"onTouched"`. */
147
+ declare function useValidationDisplay(override?: SonnetGlobalConfig["validationDisplay"]): NonNullable<SonnetGlobalConfig["validationDisplay"]>;
148
+ /** Resolve whether Save is available on an invalid form. Defaults to `"reveal"`. */
149
+ declare function useSaveWhenInvalid(override?: SonnetGlobalConfig["saveWhenInvalid"]): NonNullable<SonnetGlobalConfig["saveWhenInvalid"]>;
150
+
151
+ export { type SaveWhenInvalid, type SonnetComponentDefaults, type SonnetConfig, SonnetConfigContext, SonnetConfigProvider, type SonnetConfigProviderProps, type SonnetGlobalConfig, type ValidationDisplay, getBrowserLocale, useComponentDefaults, useGlobalSetting, useLocale, useSaveWhenInvalid, useValidationDisplay };
@@ -68,11 +68,27 @@ function useLocale(override) {
68
68
  const config = useContext3(SonnetConfigContext);
69
69
  return override ?? config.globals?.locale ?? getBrowserLocale();
70
70
  }
71
+
72
+ // src/config/useGlobalSetting.ts
73
+ import { useContext as useContext4 } from "react";
74
+ function useGlobalSetting(key, override, fallback) {
75
+ const config = useContext4(SonnetConfigContext);
76
+ return override ?? config.globals?.[key] ?? fallback;
77
+ }
78
+ function useValidationDisplay(override) {
79
+ return useGlobalSetting("validationDisplay", override, "onTouched");
80
+ }
81
+ function useSaveWhenInvalid(override) {
82
+ return useGlobalSetting("saveWhenInvalid", override, "reveal");
83
+ }
71
84
  export {
72
85
  SonnetConfigContext,
73
86
  SonnetConfigProvider,
74
87
  getBrowserLocale,
75
88
  useComponentDefaults,
76
- useLocale
89
+ useGlobalSetting,
90
+ useLocale,
91
+ useSaveWhenInvalid,
92
+ useValidationDisplay
77
93
  };
78
94
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/config/SonnetConfigProvider.tsx","../../src/config/SonnetConfigContext.ts","../../src/config/useComponentDefaults.ts","../../src/config/useLocale.ts"],"sourcesContent":["/**\n * SonnetConfigProvider -- sets app-wide component defaults.\n *\n * Place once at the app root to define default props for components (and\n * cross-cutting globals). Individual instances still override via props.\n * Nested providers merge into their parent, so a subtree can refine or\n * override outer defaults.\n *\n * Resolution order for any prop (see useComponentDefaults):\n * instance prop > provider default > built-in default.\n */\n\nimport React, { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\nimport type { SonnetConfig } from \"./types\";\n\nexport interface SonnetConfigProviderProps {\n /** The configuration to apply. Merged onto any parent provider's config. */\n value: SonnetConfig;\n children: React.ReactNode;\n}\n\n// Merge child config onto parent. Globals shallow-merge; component defaults\n// merge per component name, with each component's defaultProps shallow-merged\n// (child wins). One level of nesting is sufficient for the defaultProps shape.\nfunction mergeConfig(parent: SonnetConfig, child: SonnetConfig): SonnetConfig {\n const components: Record<string, { defaultProps?: Record<string, unknown> }> =\n {\n ...(parent.components as Record<\n string,\n { defaultProps?: Record<string, unknown> }\n >),\n };\n const childComponents = child.components as\n | Record<string, { defaultProps?: Record<string, unknown> }>\n | undefined;\n if (childComponents) {\n for (const name of Object.keys(childComponents)) {\n components[name] = {\n ...components[name],\n ...childComponents[name],\n defaultProps: {\n ...components[name]?.defaultProps,\n ...childComponents[name]?.defaultProps,\n },\n };\n }\n }\n return {\n globals: { ...parent.globals, ...child.globals },\n components: components as SonnetConfig[\"components\"],\n };\n}\n\nexport function SonnetConfigProvider({\n value,\n children,\n}: SonnetConfigProviderProps): React.ReactElement {\n const parent = useContext(SonnetConfigContext);\n const merged = useMemo(() => mergeConfig(parent, value), [parent, value]);\n return (\n <SonnetConfigContext.Provider value={merged}>\n {children}\n </SonnetConfigContext.Provider>\n );\n}\n","/**\n * React context holding the resolved app-wide SonnetConfig.\n *\n * Nested providers merge into their parent (see SonnetConfigProvider), so a\n * provider deeper in the tree can refine or override an outer one for its\n * subtree. The default is an empty config -- with no provider, components fall\n * back to their built-in defaults, preserving existing behavior.\n */\n\nimport { createContext } from \"react\";\nimport type { SonnetConfig } from \"./types\";\n\nexport const SonnetConfigContext = createContext<SonnetConfig>({});\n","/**\n * useComponentDefaults -- resolve a component's props against app-wide config.\n *\n * Resolution order, per prop: instance prop > provider default > built-in.\n * A component calls this at the top of its body:\n *\n * const resolved = useComponentDefaults(\"EntityTable\", BUILTIN, props);\n *\n * where BUILTIN is a stable (module-level) object of the component's built-in\n * defaults. With no provider in the tree, the result is BUILTIN merged with\n * props -- identical to not using the config at all, so existing behavior is\n * preserved.\n */\n\nimport { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\nexport function useComponentDefaults<Props extends Record<string, unknown>>(\n componentName: string,\n builtinDefaults: Partial<Props>,\n props: Props,\n): Props {\n const config = useContext(SonnetConfigContext);\n const components = config.components as\n | Record<string, { defaultProps?: Partial<Props> } | undefined>\n | undefined;\n const providerDefaults = components?.[componentName]?.defaultProps;\n\n return useMemo(() => {\n // Spread order encodes precedence: builtin < provider < instance props.\n // Only undefined instance props fall through to provider/builtin, so an\n // explicit prop always wins.\n const resolved: Record<string, unknown> = {\n ...builtinDefaults,\n ...providerDefaults,\n };\n for (const key of Object.keys(props)) {\n if (props[key] !== undefined) resolved[key] = props[key];\n }\n return resolved as Props;\n }, [builtinDefaults, providerDefaults, props]);\n}\n","/**\n * useLocale -- resolve the active BCP-47 locale.\n *\n * Resolution order: explicit override > provider global (SonnetConfigProvider)\n * > browser (`navigator.language`) > `\"en\"`. Locale-sensitive components use\n * this for display and input parsing only; values exchanged with consumers\n * remain locale-independent (ISO).\n */\n\nimport { useContext } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\n/** The browser locale, or \"en\" when unavailable (e.g. SSR). */\nexport function getBrowserLocale(): string {\n if (typeof navigator !== \"undefined\" && navigator.language) {\n return navigator.language;\n }\n return \"en\";\n}\n\nexport function useLocale(override?: string): string {\n const config = useContext(SonnetConfigContext);\n return override ?? config.globals?.locale ?? getBrowserLocale();\n}\n"],"mappings":";AAYA,SAAgB,YAAY,eAAe;;;ACH3C,SAAS,qBAAqB;AAGvB,IAAM,sBAAsB,cAA4B,CAAC,CAAC;;;ADiD7D;AApCJ,SAAS,YAAY,QAAsB,OAAmC;AAC5E,QAAM,aACJ;AAAA,IACE,GAAI,OAAO;AAAA,EAIb;AACF,QAAM,kBAAkB,MAAM;AAG9B,MAAI,iBAAiB;AACnB,eAAW,QAAQ,OAAO,KAAK,eAAe,GAAG;AAC/C,iBAAW,IAAI,IAAI;AAAA,QACjB,GAAG,WAAW,IAAI;AAAA,QAClB,GAAG,gBAAgB,IAAI;AAAA,QACvB,cAAc;AAAA,UACZ,GAAG,WAAW,IAAI,GAAG;AAAA,UACrB,GAAG,gBAAgB,IAAI,GAAG;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,SAAS,EAAE,GAAG,OAAO,SAAS,GAAG,MAAM,QAAQ;AAAA,IAC/C;AAAA,EACF;AACF;AAEO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AACF,GAAkD;AAChD,QAAM,SAAS,WAAW,mBAAmB;AAC7C,QAAM,SAAS,QAAQ,MAAM,YAAY,QAAQ,KAAK,GAAG,CAAC,QAAQ,KAAK,CAAC;AACxE,SACE,oBAAC,oBAAoB,UAApB,EAA6B,OAAO,QAClC,UACH;AAEJ;;;AEnDA,SAAS,cAAAA,aAAY,WAAAC,gBAAe;AAG7B,SAAS,qBACd,eACA,iBACA,OACO;AACP,QAAM,SAASC,YAAW,mBAAmB;AAC7C,QAAM,aAAa,OAAO;AAG1B,QAAM,mBAAmB,aAAa,aAAa,GAAG;AAEtD,SAAOC,SAAQ,MAAM;AAInB,UAAM,WAAoC;AAAA,MACxC,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AACA,eAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAI,MAAM,GAAG,MAAM,OAAW,UAAS,GAAG,IAAI,MAAM,GAAG;AAAA,IACzD;AACA,WAAO;AAAA,EACT,GAAG,CAAC,iBAAiB,kBAAkB,KAAK,CAAC;AAC/C;;;AChCA,SAAS,cAAAC,mBAAkB;AAIpB,SAAS,mBAA2B;AACzC,MAAI,OAAO,cAAc,eAAe,UAAU,UAAU;AAC1D,WAAO,UAAU;AAAA,EACnB;AACA,SAAO;AACT;AAEO,SAAS,UAAU,UAA2B;AACnD,QAAM,SAASC,YAAW,mBAAmB;AAC7C,SAAO,YAAY,OAAO,SAAS,UAAU,iBAAiB;AAChE;","names":["useContext","useMemo","useContext","useMemo","useContext","useContext"]}
1
+ {"version":3,"sources":["../../src/config/SonnetConfigProvider.tsx","../../src/config/SonnetConfigContext.ts","../../src/config/useComponentDefaults.ts","../../src/config/useLocale.ts","../../src/config/useGlobalSetting.ts"],"sourcesContent":["/**\n * SonnetConfigProvider -- sets app-wide component defaults.\n *\n * Place once at the app root to define default props for components (and\n * cross-cutting globals). Individual instances still override via props.\n * Nested providers merge into their parent, so a subtree can refine or\n * override outer defaults.\n *\n * Resolution order for any prop (see useComponentDefaults):\n * instance prop > provider default > built-in default.\n */\n\nimport React, { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\nimport type { SonnetConfig } from \"./types\";\n\nexport interface SonnetConfigProviderProps {\n /** The configuration to apply. Merged onto any parent provider's config. */\n value: SonnetConfig;\n children: React.ReactNode;\n}\n\n// Merge child config onto parent. Globals shallow-merge; component defaults\n// merge per component name, with each component's defaultProps shallow-merged\n// (child wins). One level of nesting is sufficient for the defaultProps shape.\nfunction mergeConfig(parent: SonnetConfig, child: SonnetConfig): SonnetConfig {\n const components: Record<string, { defaultProps?: Record<string, unknown> }> =\n {\n ...(parent.components as Record<\n string,\n { defaultProps?: Record<string, unknown> }\n >),\n };\n const childComponents = child.components as\n | Record<string, { defaultProps?: Record<string, unknown> }>\n | undefined;\n if (childComponents) {\n for (const name of Object.keys(childComponents)) {\n components[name] = {\n ...components[name],\n ...childComponents[name],\n defaultProps: {\n ...components[name]?.defaultProps,\n ...childComponents[name]?.defaultProps,\n },\n };\n }\n }\n return {\n globals: { ...parent.globals, ...child.globals },\n components: components as SonnetConfig[\"components\"],\n };\n}\n\nexport function SonnetConfigProvider({\n value,\n children,\n}: SonnetConfigProviderProps): React.ReactElement {\n const parent = useContext(SonnetConfigContext);\n const merged = useMemo(() => mergeConfig(parent, value), [parent, value]);\n return (\n <SonnetConfigContext.Provider value={merged}>\n {children}\n </SonnetConfigContext.Provider>\n );\n}\n","/**\n * React context holding the resolved app-wide SonnetConfig.\n *\n * Nested providers merge into their parent (see SonnetConfigProvider), so a\n * provider deeper in the tree can refine or override an outer one for its\n * subtree. The default is an empty config -- with no provider, components fall\n * back to their built-in defaults, preserving existing behavior.\n */\n\nimport { createContext } from \"react\";\nimport type { SonnetConfig } from \"./types\";\n\nexport const SonnetConfigContext = createContext<SonnetConfig>({});\n","/**\n * useComponentDefaults -- resolve a component's props against app-wide config.\n *\n * Resolution order, per prop: instance prop > provider default > built-in.\n * A component calls this at the top of its body:\n *\n * const resolved = useComponentDefaults(\"EntityTable\", BUILTIN, props);\n *\n * where BUILTIN is a stable (module-level) object of the component's built-in\n * defaults. With no provider in the tree, the result is BUILTIN merged with\n * props -- identical to not using the config at all, so existing behavior is\n * preserved.\n */\n\nimport { useContext, useMemo } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\nexport function useComponentDefaults<Props extends Record<string, unknown>>(\n componentName: string,\n builtinDefaults: Partial<Props>,\n props: Props,\n): Props {\n const config = useContext(SonnetConfigContext);\n const components = config.components as\n | Record<string, { defaultProps?: Partial<Props> } | undefined>\n | undefined;\n const providerDefaults = components?.[componentName]?.defaultProps;\n\n return useMemo(() => {\n // Spread order encodes precedence: builtin < provider < instance props.\n // Only undefined instance props fall through to provider/builtin, so an\n // explicit prop always wins.\n const resolved: Record<string, unknown> = {\n ...builtinDefaults,\n ...providerDefaults,\n };\n for (const key of Object.keys(props)) {\n if (props[key] !== undefined) resolved[key] = props[key];\n }\n return resolved as Props;\n }, [builtinDefaults, providerDefaults, props]);\n}\n","/**\n * useLocale -- resolve the active BCP-47 locale.\n *\n * Resolution order: explicit override > provider global (SonnetConfigProvider)\n * > browser (`navigator.language`) > `\"en\"`. Locale-sensitive components use\n * this for display and input parsing only; values exchanged with consumers\n * remain locale-independent (ISO).\n */\n\nimport { useContext } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\n\n/** The browser locale, or \"en\" when unavailable (e.g. SSR). */\nexport function getBrowserLocale(): string {\n if (typeof navigator !== \"undefined\" && navigator.language) {\n return navigator.language;\n }\n return \"en\";\n}\n\nexport function useLocale(override?: string): string {\n const config = useContext(SonnetConfigContext);\n return override ?? config.globals?.locale ?? getBrowserLocale();\n}\n","/**\n * useGlobalSetting -- resolve one SonnetGlobalConfig field.\n *\n * Resolution order: explicit override (a component's own prop) > provider\n * global (SonnetConfigProvider) > the caller's fallback. This is the shared\n * shape behind useLocale, useValidationDisplay and useSaveWhenInvalid: a house\n * default set once at the root, overridable per instance.\n */\n\nimport { useContext } from \"react\";\nimport { SonnetConfigContext } from \"./SonnetConfigContext\";\nimport type { SonnetGlobalConfig } from \"./types\";\n\nexport function useGlobalSetting<K extends keyof SonnetGlobalConfig>(\n key: K,\n override: SonnetGlobalConfig[K],\n fallback: NonNullable<SonnetGlobalConfig[K]>,\n): NonNullable<SonnetGlobalConfig[K]> {\n const config = useContext(SonnetConfigContext);\n return override ?? config.globals?.[key] ?? fallback;\n}\n\n/** Resolve when a form reveals validation errors. Defaults to `\"onTouched\"`. */\nexport function useValidationDisplay(\n override?: SonnetGlobalConfig[\"validationDisplay\"],\n): NonNullable<SonnetGlobalConfig[\"validationDisplay\"]> {\n return useGlobalSetting(\"validationDisplay\", override, \"onTouched\");\n}\n\n/** Resolve whether Save is available on an invalid form. Defaults to `\"reveal\"`. */\nexport function useSaveWhenInvalid(\n override?: SonnetGlobalConfig[\"saveWhenInvalid\"],\n): NonNullable<SonnetGlobalConfig[\"saveWhenInvalid\"]> {\n return useGlobalSetting(\"saveWhenInvalid\", override, \"reveal\");\n}\n"],"mappings":";AAYA,SAAgB,YAAY,eAAe;;;ACH3C,SAAS,qBAAqB;AAGvB,IAAM,sBAAsB,cAA4B,CAAC,CAAC;;;ADiD7D;AApCJ,SAAS,YAAY,QAAsB,OAAmC;AAC5E,QAAM,aACJ;AAAA,IACE,GAAI,OAAO;AAAA,EAIb;AACF,QAAM,kBAAkB,MAAM;AAG9B,MAAI,iBAAiB;AACnB,eAAW,QAAQ,OAAO,KAAK,eAAe,GAAG;AAC/C,iBAAW,IAAI,IAAI;AAAA,QACjB,GAAG,WAAW,IAAI;AAAA,QAClB,GAAG,gBAAgB,IAAI;AAAA,QACvB,cAAc;AAAA,UACZ,GAAG,WAAW,IAAI,GAAG;AAAA,UACrB,GAAG,gBAAgB,IAAI,GAAG;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,SAAS,EAAE,GAAG,OAAO,SAAS,GAAG,MAAM,QAAQ;AAAA,IAC/C;AAAA,EACF;AACF;AAEO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AACF,GAAkD;AAChD,QAAM,SAAS,WAAW,mBAAmB;AAC7C,QAAM,SAAS,QAAQ,MAAM,YAAY,QAAQ,KAAK,GAAG,CAAC,QAAQ,KAAK,CAAC;AACxE,SACE,oBAAC,oBAAoB,UAApB,EAA6B,OAAO,QAClC,UACH;AAEJ;;;AEnDA,SAAS,cAAAA,aAAY,WAAAC,gBAAe;AAG7B,SAAS,qBACd,eACA,iBACA,OACO;AACP,QAAM,SAASC,YAAW,mBAAmB;AAC7C,QAAM,aAAa,OAAO;AAG1B,QAAM,mBAAmB,aAAa,aAAa,GAAG;AAEtD,SAAOC,SAAQ,MAAM;AAInB,UAAM,WAAoC;AAAA,MACxC,GAAG;AAAA,MACH,GAAG;AAAA,IACL;AACA,eAAW,OAAO,OAAO,KAAK,KAAK,GAAG;AACpC,UAAI,MAAM,GAAG,MAAM,OAAW,UAAS,GAAG,IAAI,MAAM,GAAG;AAAA,IACzD;AACA,WAAO;AAAA,EACT,GAAG,CAAC,iBAAiB,kBAAkB,KAAK,CAAC;AAC/C;;;AChCA,SAAS,cAAAC,mBAAkB;AAIpB,SAAS,mBAA2B;AACzC,MAAI,OAAO,cAAc,eAAe,UAAU,UAAU;AAC1D,WAAO,UAAU;AAAA,EACnB;AACA,SAAO;AACT;AAEO,SAAS,UAAU,UAA2B;AACnD,QAAM,SAASC,YAAW,mBAAmB;AAC7C,SAAO,YAAY,OAAO,SAAS,UAAU,iBAAiB;AAChE;;;ACdA,SAAS,cAAAC,mBAAkB;AAIpB,SAAS,iBACd,KACA,UACA,UACoC;AACpC,QAAM,SAASC,YAAW,mBAAmB;AAC7C,SAAO,YAAY,OAAO,UAAU,GAAG,KAAK;AAC9C;AAGO,SAAS,qBACd,UACsD;AACtD,SAAO,iBAAiB,qBAAqB,UAAU,WAAW;AACpE;AAGO,SAAS,mBACd,UACoD;AACpD,SAAO,iBAAiB,mBAAmB,UAAU,QAAQ;AAC/D;","names":["useContext","useMemo","useContext","useMemo","useContext","useContext","useContext","useContext"]}
@@ -62,6 +62,22 @@ interface UseDisclosureReturn {
62
62
  */
63
63
  declare function useDisclosure(initialState?: boolean): readonly [boolean, UseDisclosureReturn];
64
64
 
65
+ /**
66
+ * Subscribe to a CSS media query and re-render when it changes.
67
+ *
68
+ * SSR-safe: returns `false` on the server and during the first client render
69
+ * when `window.matchMedia` is unavailable, then syncs on mount.
70
+ *
71
+ * @param query - a media query string, e.g. "(max-width: 1023px)"
72
+ * @returns whether the query currently matches
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * const isTablet = useMediaQuery("(max-width: 1023px)");
77
+ * ```
78
+ */
79
+ declare function useMediaQuery(query: string): boolean;
80
+
65
81
  /**
66
82
  * Custom hook for resizable panel functionality.
67
83
  * Extracted from GraphViewer to be reusable across components.
@@ -234,4 +250,4 @@ interface QueryHookOptions {
234
250
  enabled?: boolean;
235
251
  }
236
252
 
237
- export { type EntitySelectMode, type QueryHookOptions, type UseClipboardOptions, type UseClipboardReturn, type UseEntityOptionsConfig, type UseEntityOptionsReturn, type UseTreeExpansionReturn, useClipboard, useDisclosure, useEntityOptions, useFormState, useResizablePanel, useTreeExpansion };
253
+ export { type EntitySelectMode, type QueryHookOptions, type UseClipboardOptions, type UseClipboardReturn, type UseEntityOptionsConfig, type UseEntityOptionsReturn, type UseTreeExpansionReturn, useClipboard, useDisclosure, useEntityOptions, useFormState, useMediaQuery, useResizablePanel, useTreeExpansion };
@@ -57,8 +57,26 @@ function useDisclosure(initialState = false) {
57
57
  return [opened, { open, close, toggle }];
58
58
  }
59
59
 
60
+ // src/hooks/useMediaQuery.ts
61
+ import { useState as useState4, useEffect } from "react";
62
+ function useMediaQuery(query) {
63
+ const [matches, setMatches] = useState4(() => {
64
+ if (typeof window === "undefined" || !window.matchMedia) return false;
65
+ return window.matchMedia(query).matches;
66
+ });
67
+ useEffect(() => {
68
+ if (typeof window === "undefined" || !window.matchMedia) return;
69
+ const mql = window.matchMedia(query);
70
+ const onChange = () => setMatches(mql.matches);
71
+ onChange();
72
+ mql.addEventListener("change", onChange);
73
+ return () => mql.removeEventListener("change", onChange);
74
+ }, [query]);
75
+ return matches;
76
+ }
77
+
60
78
  // src/hooks/useResizablePanel.ts
61
- import { useState as useState4, useRef, useCallback as useCallback4, useEffect } from "react";
79
+ import { useState as useState5, useRef, useCallback as useCallback4, useEffect as useEffect2 } from "react";
62
80
  function useResizablePanel(options = {}) {
63
81
  const {
64
82
  defaultWidth = 320,
@@ -66,7 +84,7 @@ function useResizablePanel(options = {}) {
66
84
  maxWidth = 640,
67
85
  direction = "left-edge"
68
86
  } = options;
69
- const [panelWidth, setPanelWidth] = useState4(defaultWidth);
87
+ const [panelWidth, setPanelWidth] = useState5(defaultWidth);
70
88
  const resizingRef = useRef(false);
71
89
  const startXRef = useRef(0);
72
90
  const startWidthRef = useRef(0);
@@ -101,7 +119,7 @@ function useResizablePanel(options = {}) {
101
119
  () => setPanelWidth(defaultWidth),
102
120
  [defaultWidth]
103
121
  );
104
- useEffect(() => {
122
+ useEffect2(() => {
105
123
  window.addEventListener("pointermove", handlePointerMove);
106
124
  window.addEventListener("pointerup", finishResize);
107
125
  window.addEventListener("pointercancel", finishResize);
@@ -134,10 +152,10 @@ function useResizablePanel(options = {}) {
134
152
  }
135
153
 
136
154
  // src/hooks/useFormState.ts
137
- import { useState as useState5, useCallback as useCallback5, useMemo } from "react";
155
+ import { useState as useState6, useCallback as useCallback5, useMemo } from "react";
138
156
  function useFormState(initialData) {
139
- const [data, setDataState] = useState5(initialData);
140
- const [original, setOriginal] = useState5(initialData);
157
+ const [data, setDataState] = useState6(initialData);
158
+ const [original, setOriginal] = useState6(initialData);
141
159
  const isDirty = useMemo(() => {
142
160
  return !isEqual(data, original);
143
161
  }, [data, original]);
@@ -169,7 +187,7 @@ function useFormState(initialData) {
169
187
  }
170
188
 
171
189
  // src/hooks/useEntityOptions.ts
172
- import { useState as useState6, useEffect as useEffect2, useCallback as useCallback6 } from "react";
190
+ import { useState as useState7, useEffect as useEffect3, useCallback as useCallback6 } from "react";
173
191
  import { useQuery } from "@tanstack/react-query";
174
192
  function resolveEnabled(enabled, mode, activeSearch, minChars, committedSearch) {
175
193
  if (!enabled) return false;
@@ -186,10 +204,10 @@ function useEntityOptions({
186
204
  limit = 50,
187
205
  enabled = true
188
206
  }) {
189
- const [search, setSearch] = useState6("");
190
- const [debouncedSearch, setDebouncedSearch] = useState6("");
191
- const [committedSearch, setCommittedSearch] = useState6(null);
192
- useEffect2(() => {
207
+ const [search, setSearch] = useState7("");
208
+ const [debouncedSearch, setDebouncedSearch] = useState7("");
209
+ const [committedSearch, setCommittedSearch] = useState7(null);
210
+ useEffect3(() => {
193
211
  if (mode === "explicit") return;
194
212
  const timer = setTimeout(() => setDebouncedSearch(search), debounceMs);
195
213
  return () => clearTimeout(timer);
@@ -219,6 +237,7 @@ export {
219
237
  useDisclosure,
220
238
  useEntityOptions,
221
239
  useFormState,
240
+ useMediaQuery,
222
241
  useResizablePanel,
223
242
  useTreeExpansion
224
243
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/useClipboard.ts","../../src/hooks/useTreeExpansion.ts","../../src/hooks/useDisclosure.ts","../../src/hooks/useResizablePanel.ts","../../src/hooks/useFormState.ts","../../src/hooks/useEntityOptions.ts"],"sourcesContent":["import { useState, useCallback } from \"react\";\n\nexport interface UseClipboardOptions {\n timeout?: number;\n}\n\nexport interface UseClipboardReturn {\n copy: (text: string) => Promise<void>;\n copied: boolean;\n error: Error | null;\n reset: () => void;\n}\n\n/**\n * Custom hook for clipboard operations\n *\n * Replaces Mantine's useClipboard hook with native Clipboard API\n *\n * @param options - Configuration options\n * @param options.timeout - Time in ms before copied state resets (default: 2000)\n * @returns Object with copy function, copied state, error, and reset function\n *\n * @example\n * ```tsx\n * const { copy, copied } = useClipboard({ timeout: 3000 });\n *\n * <button onClick={() => copy('Hello World')}>\n * {copied ? 'Copied!' : 'Copy'}\n * </button>\n * ```\n */\nexport function useClipboard({\n timeout = 2000,\n}: UseClipboardOptions = {}): UseClipboardReturn {\n const [copied, setCopied] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n\n const copy = useCallback(\n async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n setError(null);\n\n // Reset copied state after timeout\n setTimeout(() => setCopied(false), timeout);\n } catch (err) {\n setError(err instanceof Error ? err : new Error(String(err)));\n setCopied(false);\n }\n },\n [timeout],\n );\n\n const reset = useCallback(() => {\n setCopied(false);\n setError(null);\n }, []);\n\n return { copy, copied, error, reset };\n}\n","import { useState, useCallback } from \"react\";\n\nexport interface UseTreeExpansionReturn {\n expandedIds: Set<string>;\n onExpand: (id: string) => void;\n onCollapse: (id: string) => void;\n}\n\n/** Manages expand/collapse state for a TreeView. */\nexport function useTreeExpansion(): UseTreeExpansionReturn {\n const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());\n\n const onExpand = useCallback((id: string) => {\n setExpandedIds((prev) => new Set([...prev, id]));\n }, []);\n\n const onCollapse = useCallback((id: string) => {\n setExpandedIds((prev) => {\n const next = new Set(prev);\n next.delete(id);\n return next;\n });\n }, []);\n\n return { expandedIds, onExpand, onCollapse };\n}\n","import { useState, useCallback } from \"react\";\n\nexport interface UseDisclosureReturn {\n open: () => void;\n close: () => void;\n toggle: () => void;\n}\n\n/**\n * Custom hook for managing open/close state\n *\n * Replaces Mantine's useDisclosure hook with simple useState wrapper\n *\n * @param initialState - Initial open state (default: false)\n * @returns Tuple of [opened, handlers] where handlers contains open, close, toggle functions\n *\n * @example\n * ```tsx\n * const [opened, { open, close, toggle }] = useDisclosure();\n *\n * <button onClick={open}>Open Modal</button>\n * <Modal opened={opened} onClose={close}>\n * <button onClick={toggle}>Toggle</button>\n * </Modal>\n * ```\n */\nexport function useDisclosure(\n initialState = false,\n): readonly [boolean, UseDisclosureReturn] {\n const [opened, setOpened] = useState(initialState);\n\n const open = useCallback(() => setOpened(true), []);\n const close = useCallback(() => setOpened(false), []);\n const toggle = useCallback(() => setOpened((prev) => !prev), []);\n\n return [opened, { open, close, toggle }] as const;\n}\n","/**\n * Custom hook for resizable panel functionality.\n * Extracted from GraphViewer to be reusable across components.\n *\n * @module hooks/useResizablePanel\n */\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\n\ninterface UseResizablePanelOptions {\n defaultWidth?: number;\n minWidth?: number;\n maxWidth?: number;\n /**\n * Which edge the drag handle sits on relative to the panel content.\n *\n * - \"left-edge\" — handle is on the panel's left; dragging right shrinks the\n * panel (default, matches the right-side action panel in\n * GraphViewer where the separator is left of the panel).\n * - \"right-edge\" — handle is on the panel's right; dragging right grows the\n * panel (matches a left-side SidePane where the separator is\n * right of the panel).\n */\n direction?: \"left-edge\" | \"right-edge\";\n}\n\ninterface UseResizablePanelReturn {\n panelWidth: number;\n handlePointerDown: (e: React.PointerEvent) => void;\n handleDoubleClick: () => void;\n separatorProps: {\n role: string;\n \"aria-orientation\": \"vertical\";\n \"aria-valuenow\": number;\n \"aria-valuemin\": number;\n \"aria-valuemax\": number;\n };\n}\n\n/**\n * Hook for managing resizable panel state and interactions.\n * Handles pointer events, constraints, and keyboard shortcuts (Escape to cancel).\n */\nexport function useResizablePanel(\n options: UseResizablePanelOptions = {},\n): UseResizablePanelReturn {\n const {\n defaultWidth = 320,\n minWidth = 260,\n maxWidth = 640,\n direction = \"left-edge\",\n } = options;\n\n const [panelWidth, setPanelWidth] = useState<number>(defaultWidth);\n const resizingRef = useRef(false);\n const startXRef = useRef(0);\n const startWidthRef = useRef(0);\n\n const clamp = useCallback(\n (v: number) => Math.min(Math.max(v, minWidth), maxWidth),\n [minWidth, maxWidth],\n );\n\n const finishResize = useCallback(() => {\n if (!resizingRef.current) return;\n resizingRef.current = false;\n document.body.classList.remove(\"select-none\");\n }, []);\n\n const handlePointerMove = useCallback(\n (e: PointerEvent) => {\n if (!resizingRef.current) return;\n const delta = e.clientX - startXRef.current;\n // left-edge: separator left of panel, drag right shrinks (subtract delta)\n // right-edge: separator right of panel, drag right grows (add delta)\n const newWidth =\n direction === \"right-edge\"\n ? startWidthRef.current + delta\n : startWidthRef.current - delta;\n setPanelWidth(clamp(newWidth));\n },\n [clamp, direction],\n );\n\n const handlePointerDown = useCallback(\n (e: React.PointerEvent) => {\n resizingRef.current = true;\n startXRef.current = e.clientX;\n startWidthRef.current = panelWidth;\n document.body.classList.add(\"select-none\");\n },\n [panelWidth],\n );\n\n const handleDoubleClick = useCallback(\n () => setPanelWidth(defaultWidth),\n [defaultWidth],\n );\n\n useEffect(() => {\n window.addEventListener(\"pointermove\", handlePointerMove);\n window.addEventListener(\"pointerup\", finishResize);\n window.addEventListener(\"pointercancel\", finishResize);\n\n const handleKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\" && resizingRef.current) {\n setPanelWidth(startWidthRef.current);\n finishResize();\n }\n };\n window.addEventListener(\"keydown\", handleKey);\n\n return () => {\n window.removeEventListener(\"pointermove\", handlePointerMove);\n window.removeEventListener(\"pointerup\", finishResize);\n window.removeEventListener(\"pointercancel\", finishResize);\n window.removeEventListener(\"keydown\", handleKey);\n };\n }, [handlePointerMove, finishResize]);\n\n return {\n panelWidth,\n handlePointerDown,\n handleDoubleClick,\n separatorProps: {\n role: \"separator\",\n \"aria-orientation\": \"vertical\",\n \"aria-valuenow\": panelWidth,\n \"aria-valuemin\": minWidth,\n \"aria-valuemax\": maxWidth,\n },\n };\n}\n","/**\n * useFormState Hook\n *\n * Manages form state with automatic dirty tracking and common operations.\n * Tracks changes against original data to determine if form has been modified.\n *\n * @module hooks/useFormState\n */\n\nimport { useState, useCallback, useMemo } from \"react\";\nimport { isEqual } from \"../utils\";\n\n/**\n * Return type for useFormState hook\n */\nexport interface UseFormStateReturn<T> {\n /** Current form data */\n data: T;\n /** Update form data (partial or full) */\n setData: (data: T | ((prev: T) => T)) => void;\n /** Update a single field */\n setField: <K extends keyof T>(field: K, value: T[K]) => void;\n /** Whether form has been modified */\n isDirty: boolean;\n /** Load new data and mark as clean (for switching items) */\n reset: (newData: T) => void;\n /** Revert to original data */\n discard: () => void;\n /** Original data for comparison */\n original: T;\n}\n\n/**\n * Hook for managing form state with dirty tracking\n *\n * Provides state management for forms with automatic change detection.\n * Compares current data against original to determine if form has been modified.\n *\n * @template T - Type of the form data\n * @param initialData - Initial form data\n * @returns Form state and control functions\n *\n * @example\n * ```tsx\n * interface FormData {\n * name: string;\n * email: string;\n * }\n *\n * function MyForm() {\n * const form = useFormState<FormData>({\n * name: '',\n * email: '',\n * });\n *\n * // Update field\n * form.setField('name', 'John');\n *\n * // Check if dirty\n * if (form.isDirty) {\n * console.log('Form has unsaved changes');\n * }\n *\n * // Load new data (e.g., from API)\n * form.reset({ name: 'Jane', email: 'jane@example.com' });\n *\n * // Revert changes\n * form.discard();\n * }\n * ```\n */\nexport function useFormState<T>(initialData: T): UseFormStateReturn<T> {\n const [data, setDataState] = useState<T>(initialData);\n const [original, setOriginal] = useState<T>(initialData);\n\n /**\n * Compute dirty state by comparing current data with original\n */\n const isDirty = useMemo(() => {\n return !isEqual(data, original);\n }, [data, original]);\n\n /**\n * Update form data\n */\n const setData = useCallback((newData: T | ((prev: T) => T)) => {\n setDataState(newData);\n }, []);\n\n /**\n * Update a single field\n */\n const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {\n setDataState((prev) => ({\n ...prev,\n [field]: value,\n }));\n }, []);\n\n /**\n * Load new data and mark as clean\n * Use when loading data from API or switching between items\n */\n const reset = useCallback((newData: T) => {\n setDataState(newData);\n setOriginal(newData);\n }, []);\n\n /**\n * Revert to original data\n * Use when user clicks \"Cancel\" or \"Discard Changes\"\n */\n const discard = useCallback(() => {\n setDataState(original);\n }, [original]);\n\n return {\n data,\n setData,\n setField,\n isDirty,\n reset,\n discard,\n original,\n };\n}\n","/**\n * useEntityOptions -- fetch selectable options from any data source.\n *\n * Transport-agnostic: receives an EntityOptionFetcher and returns\n * TanStack Query state. Supports three search modes:\n *\n * eager -- fetches on mount, refetches on debounced search (default)\n * typeahead -- fetches only after the user types >= minChars (debounced)\n * explicit -- fetches only when submit() is called\n *\n * Extractable to @petrarca/sonnet-core/hooks -- no app-specific imports.\n */\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport type { EntityOption, EntityOptionFetcher } from \"../entityOptions\";\n\n/** Search mode for the entity options hook and EntitySelect component. */\nexport type EntitySelectMode = \"eager\" | \"typeahead\" | \"explicit\";\n\nexport interface UseEntityOptionsConfig {\n /** The fetcher function (created by a fetcher factory or provided directly). */\n fetcher: EntityOptionFetcher;\n /** TanStack Query cache key prefix. Must be stable. */\n queryKey: string;\n /**\n * Search mode. Default: \"eager\".\n * eager -- fetch on mount, refetch on debounced typing\n * typeahead -- fetch only after minChars are typed (debounced)\n * explicit -- fetch only when submit() is called\n */\n mode?: EntitySelectMode;\n /** Debounce delay in ms for the search input (default 300). */\n debounceMs?: number;\n /** Minimum characters before typeahead fires (default 1). Ignored in other modes. */\n minChars?: number;\n /** Maximum options to fetch (default 50). */\n limit?: number;\n /** Disable fetching (e.g. when a dependency is not yet satisfied). */\n enabled?: boolean;\n}\n\nexport interface UseEntityOptionsReturn {\n /** Current options from the server. */\n options: EntityOption[];\n /** Whether a fetch is in progress. */\n isLoading: boolean;\n /** Whether the fetch errored. */\n isError: boolean;\n /** The current search string (set by the combobox input). */\n search: string;\n /** Update the search string (drives debounced refetch). */\n setSearch: (value: string) => void;\n /** Trigger a fetch with the current search string. Only meaningful in explicit mode. */\n submit: () => void;\n}\n\n// Determine whether the query should be enabled based on mode and state.\nfunction resolveEnabled(\n enabled: boolean,\n mode: EntitySelectMode,\n activeSearch: string,\n minChars: number,\n committedSearch: string | null,\n): boolean {\n if (!enabled) return false;\n if (mode === \"typeahead\") return activeSearch.length >= minChars;\n if (mode === \"explicit\") return committedSearch !== null;\n return true;\n}\n\nexport function useEntityOptions({\n fetcher,\n queryKey,\n mode = \"eager\",\n debounceMs = 300,\n minChars = 1,\n limit = 50,\n enabled = true,\n}: UseEntityOptionsConfig): UseEntityOptionsReturn {\n const [search, setSearch] = useState(\"\");\n\n // Debounced search drives eager and typeahead modes.\n const [debouncedSearch, setDebouncedSearch] = useState(\"\");\n // Committed search drives explicit mode -- only updated on submit().\n const [committedSearch, setCommittedSearch] = useState<string | null>(null);\n\n useEffect(() => {\n if (mode === \"explicit\") return;\n const timer = setTimeout(() => setDebouncedSearch(search), debounceMs);\n return () => clearTimeout(timer);\n }, [search, debounceMs, mode]);\n\n const submit = useCallback(() => {\n setCommittedSearch(search);\n }, [search]);\n\n const activeSearch =\n mode === \"explicit\" ? (committedSearch ?? \"\") : debouncedSearch;\n\n const queryEnabled = resolveEnabled(\n enabled,\n mode,\n activeSearch,\n minChars,\n committedSearch,\n );\n\n const { data, isLoading, isError } = useQuery({\n queryKey: [queryKey, \"options\", { search: activeSearch, limit }],\n queryFn: () => fetcher({ search: activeSearch || undefined, limit }),\n enabled: queryEnabled,\n staleTime: 5 * 60 * 1000,\n });\n\n const options: EntityOption[] = data ?? [];\n\n return { options, isLoading, isError, search, setSearch, submit };\n}\n"],"mappings":"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","names":["useState","useCallback","useState","useCallback","useState","useCallback","useState","useCallback","useState","useCallback","useState","useEffect","useCallback"]}
1
+ {"version":3,"sources":["../../src/hooks/useClipboard.ts","../../src/hooks/useTreeExpansion.ts","../../src/hooks/useDisclosure.ts","../../src/hooks/useMediaQuery.ts","../../src/hooks/useResizablePanel.ts","../../src/hooks/useFormState.ts","../../src/hooks/useEntityOptions.ts"],"sourcesContent":["import { useState, useCallback } from \"react\";\n\nexport interface UseClipboardOptions {\n timeout?: number;\n}\n\nexport interface UseClipboardReturn {\n copy: (text: string) => Promise<void>;\n copied: boolean;\n error: Error | null;\n reset: () => void;\n}\n\n/**\n * Custom hook for clipboard operations\n *\n * Replaces Mantine's useClipboard hook with native Clipboard API\n *\n * @param options - Configuration options\n * @param options.timeout - Time in ms before copied state resets (default: 2000)\n * @returns Object with copy function, copied state, error, and reset function\n *\n * @example\n * ```tsx\n * const { copy, copied } = useClipboard({ timeout: 3000 });\n *\n * <button onClick={() => copy('Hello World')}>\n * {copied ? 'Copied!' : 'Copy'}\n * </button>\n * ```\n */\nexport function useClipboard({\n timeout = 2000,\n}: UseClipboardOptions = {}): UseClipboardReturn {\n const [copied, setCopied] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n\n const copy = useCallback(\n async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n setError(null);\n\n // Reset copied state after timeout\n setTimeout(() => setCopied(false), timeout);\n } catch (err) {\n setError(err instanceof Error ? err : new Error(String(err)));\n setCopied(false);\n }\n },\n [timeout],\n );\n\n const reset = useCallback(() => {\n setCopied(false);\n setError(null);\n }, []);\n\n return { copy, copied, error, reset };\n}\n","import { useState, useCallback } from \"react\";\n\nexport interface UseTreeExpansionReturn {\n expandedIds: Set<string>;\n onExpand: (id: string) => void;\n onCollapse: (id: string) => void;\n}\n\n/** Manages expand/collapse state for a TreeView. */\nexport function useTreeExpansion(): UseTreeExpansionReturn {\n const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());\n\n const onExpand = useCallback((id: string) => {\n setExpandedIds((prev) => new Set([...prev, id]));\n }, []);\n\n const onCollapse = useCallback((id: string) => {\n setExpandedIds((prev) => {\n const next = new Set(prev);\n next.delete(id);\n return next;\n });\n }, []);\n\n return { expandedIds, onExpand, onCollapse };\n}\n","import { useState, useCallback } from \"react\";\n\nexport interface UseDisclosureReturn {\n open: () => void;\n close: () => void;\n toggle: () => void;\n}\n\n/**\n * Custom hook for managing open/close state\n *\n * Replaces Mantine's useDisclosure hook with simple useState wrapper\n *\n * @param initialState - Initial open state (default: false)\n * @returns Tuple of [opened, handlers] where handlers contains open, close, toggle functions\n *\n * @example\n * ```tsx\n * const [opened, { open, close, toggle }] = useDisclosure();\n *\n * <button onClick={open}>Open Modal</button>\n * <Modal opened={opened} onClose={close}>\n * <button onClick={toggle}>Toggle</button>\n * </Modal>\n * ```\n */\nexport function useDisclosure(\n initialState = false,\n): readonly [boolean, UseDisclosureReturn] {\n const [opened, setOpened] = useState(initialState);\n\n const open = useCallback(() => setOpened(true), []);\n const close = useCallback(() => setOpened(false), []);\n const toggle = useCallback(() => setOpened((prev) => !prev), []);\n\n return [opened, { open, close, toggle }] as const;\n}\n","import { useState, useEffect } from \"react\";\n\n/**\n * Subscribe to a CSS media query and re-render when it changes.\n *\n * SSR-safe: returns `false` on the server and during the first client render\n * when `window.matchMedia` is unavailable, then syncs on mount.\n *\n * @param query - a media query string, e.g. \"(max-width: 1023px)\"\n * @returns whether the query currently matches\n *\n * @example\n * ```tsx\n * const isTablet = useMediaQuery(\"(max-width: 1023px)\");\n * ```\n */\nexport function useMediaQuery(query: string): boolean {\n const [matches, setMatches] = useState<boolean>(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return false;\n return window.matchMedia(query).matches;\n });\n\n useEffect(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return;\n const mql = window.matchMedia(query);\n const onChange = () => setMatches(mql.matches);\n // Sync immediately in case the query changed between render and effect.\n onChange();\n mql.addEventListener(\"change\", onChange);\n return () => mql.removeEventListener(\"change\", onChange);\n }, [query]);\n\n return matches;\n}\n","/**\n * Custom hook for resizable panel functionality.\n * Extracted from GraphViewer to be reusable across components.\n *\n * @module hooks/useResizablePanel\n */\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\n\ninterface UseResizablePanelOptions {\n defaultWidth?: number;\n minWidth?: number;\n maxWidth?: number;\n /**\n * Which edge the drag handle sits on relative to the panel content.\n *\n * - \"left-edge\" — handle is on the panel's left; dragging right shrinks the\n * panel (default, matches the right-side action panel in\n * GraphViewer where the separator is left of the panel).\n * - \"right-edge\" — handle is on the panel's right; dragging right grows the\n * panel (matches a left-side SidePane where the separator is\n * right of the panel).\n */\n direction?: \"left-edge\" | \"right-edge\";\n}\n\ninterface UseResizablePanelReturn {\n panelWidth: number;\n handlePointerDown: (e: React.PointerEvent) => void;\n handleDoubleClick: () => void;\n separatorProps: {\n role: string;\n \"aria-orientation\": \"vertical\";\n \"aria-valuenow\": number;\n \"aria-valuemin\": number;\n \"aria-valuemax\": number;\n };\n}\n\n/**\n * Hook for managing resizable panel state and interactions.\n * Handles pointer events, constraints, and keyboard shortcuts (Escape to cancel).\n */\nexport function useResizablePanel(\n options: UseResizablePanelOptions = {},\n): UseResizablePanelReturn {\n const {\n defaultWidth = 320,\n minWidth = 260,\n maxWidth = 640,\n direction = \"left-edge\",\n } = options;\n\n const [panelWidth, setPanelWidth] = useState<number>(defaultWidth);\n const resizingRef = useRef(false);\n const startXRef = useRef(0);\n const startWidthRef = useRef(0);\n\n const clamp = useCallback(\n (v: number) => Math.min(Math.max(v, minWidth), maxWidth),\n [minWidth, maxWidth],\n );\n\n const finishResize = useCallback(() => {\n if (!resizingRef.current) return;\n resizingRef.current = false;\n document.body.classList.remove(\"select-none\");\n }, []);\n\n const handlePointerMove = useCallback(\n (e: PointerEvent) => {\n if (!resizingRef.current) return;\n const delta = e.clientX - startXRef.current;\n // left-edge: separator left of panel, drag right shrinks (subtract delta)\n // right-edge: separator right of panel, drag right grows (add delta)\n const newWidth =\n direction === \"right-edge\"\n ? startWidthRef.current + delta\n : startWidthRef.current - delta;\n setPanelWidth(clamp(newWidth));\n },\n [clamp, direction],\n );\n\n const handlePointerDown = useCallback(\n (e: React.PointerEvent) => {\n resizingRef.current = true;\n startXRef.current = e.clientX;\n startWidthRef.current = panelWidth;\n document.body.classList.add(\"select-none\");\n },\n [panelWidth],\n );\n\n const handleDoubleClick = useCallback(\n () => setPanelWidth(defaultWidth),\n [defaultWidth],\n );\n\n useEffect(() => {\n window.addEventListener(\"pointermove\", handlePointerMove);\n window.addEventListener(\"pointerup\", finishResize);\n window.addEventListener(\"pointercancel\", finishResize);\n\n const handleKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\" && resizingRef.current) {\n setPanelWidth(startWidthRef.current);\n finishResize();\n }\n };\n window.addEventListener(\"keydown\", handleKey);\n\n return () => {\n window.removeEventListener(\"pointermove\", handlePointerMove);\n window.removeEventListener(\"pointerup\", finishResize);\n window.removeEventListener(\"pointercancel\", finishResize);\n window.removeEventListener(\"keydown\", handleKey);\n };\n }, [handlePointerMove, finishResize]);\n\n return {\n panelWidth,\n handlePointerDown,\n handleDoubleClick,\n separatorProps: {\n role: \"separator\",\n \"aria-orientation\": \"vertical\",\n \"aria-valuenow\": panelWidth,\n \"aria-valuemin\": minWidth,\n \"aria-valuemax\": maxWidth,\n },\n };\n}\n","/**\n * useFormState Hook\n *\n * Manages form state with automatic dirty tracking and common operations.\n * Tracks changes against original data to determine if form has been modified.\n *\n * @module hooks/useFormState\n */\n\nimport { useState, useCallback, useMemo } from \"react\";\nimport { isEqual } from \"../utils\";\n\n/**\n * Return type for useFormState hook\n */\nexport interface UseFormStateReturn<T> {\n /** Current form data */\n data: T;\n /** Update form data (partial or full) */\n setData: (data: T | ((prev: T) => T)) => void;\n /** Update a single field */\n setField: <K extends keyof T>(field: K, value: T[K]) => void;\n /** Whether form has been modified */\n isDirty: boolean;\n /** Load new data and mark as clean (for switching items) */\n reset: (newData: T) => void;\n /** Revert to original data */\n discard: () => void;\n /** Original data for comparison */\n original: T;\n}\n\n/**\n * Hook for managing form state with dirty tracking\n *\n * Provides state management for forms with automatic change detection.\n * Compares current data against original to determine if form has been modified.\n *\n * @template T - Type of the form data\n * @param initialData - Initial form data\n * @returns Form state and control functions\n *\n * @example\n * ```tsx\n * interface FormData {\n * name: string;\n * email: string;\n * }\n *\n * function MyForm() {\n * const form = useFormState<FormData>({\n * name: '',\n * email: '',\n * });\n *\n * // Update field\n * form.setField('name', 'John');\n *\n * // Check if dirty\n * if (form.isDirty) {\n * console.log('Form has unsaved changes');\n * }\n *\n * // Load new data (e.g., from API)\n * form.reset({ name: 'Jane', email: 'jane@example.com' });\n *\n * // Revert changes\n * form.discard();\n * }\n * ```\n */\nexport function useFormState<T>(initialData: T): UseFormStateReturn<T> {\n const [data, setDataState] = useState<T>(initialData);\n const [original, setOriginal] = useState<T>(initialData);\n\n /**\n * Compute dirty state by comparing current data with original\n */\n const isDirty = useMemo(() => {\n return !isEqual(data, original);\n }, [data, original]);\n\n /**\n * Update form data\n */\n const setData = useCallback((newData: T | ((prev: T) => T)) => {\n setDataState(newData);\n }, []);\n\n /**\n * Update a single field\n */\n const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {\n setDataState((prev) => ({\n ...prev,\n [field]: value,\n }));\n }, []);\n\n /**\n * Load new data and mark as clean\n * Use when loading data from API or switching between items\n */\n const reset = useCallback((newData: T) => {\n setDataState(newData);\n setOriginal(newData);\n }, []);\n\n /**\n * Revert to original data\n * Use when user clicks \"Cancel\" or \"Discard Changes\"\n */\n const discard = useCallback(() => {\n setDataState(original);\n }, [original]);\n\n return {\n data,\n setData,\n setField,\n isDirty,\n reset,\n discard,\n original,\n };\n}\n","/**\n * useEntityOptions -- fetch selectable options from any data source.\n *\n * Transport-agnostic: receives an EntityOptionFetcher and returns\n * TanStack Query state. Supports three search modes:\n *\n * eager -- fetches on mount, refetches on debounced search (default)\n * typeahead -- fetches only after the user types >= minChars (debounced)\n * explicit -- fetches only when submit() is called\n *\n * Extractable to @petrarca/sonnet-core/hooks -- no app-specific imports.\n */\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport type { EntityOption, EntityOptionFetcher } from \"../entityOptions\";\n\n/** Search mode for the entity options hook and EntitySelect component. */\nexport type EntitySelectMode = \"eager\" | \"typeahead\" | \"explicit\";\n\nexport interface UseEntityOptionsConfig {\n /** The fetcher function (created by a fetcher factory or provided directly). */\n fetcher: EntityOptionFetcher;\n /** TanStack Query cache key prefix. Must be stable. */\n queryKey: string;\n /**\n * Search mode. Default: \"eager\".\n * eager -- fetch on mount, refetch on debounced typing\n * typeahead -- fetch only after minChars are typed (debounced)\n * explicit -- fetch only when submit() is called\n */\n mode?: EntitySelectMode;\n /** Debounce delay in ms for the search input (default 300). */\n debounceMs?: number;\n /** Minimum characters before typeahead fires (default 1). Ignored in other modes. */\n minChars?: number;\n /** Maximum options to fetch (default 50). */\n limit?: number;\n /** Disable fetching (e.g. when a dependency is not yet satisfied). */\n enabled?: boolean;\n}\n\nexport interface UseEntityOptionsReturn {\n /** Current options from the server. */\n options: EntityOption[];\n /** Whether a fetch is in progress. */\n isLoading: boolean;\n /** Whether the fetch errored. */\n isError: boolean;\n /** The current search string (set by the combobox input). */\n search: string;\n /** Update the search string (drives debounced refetch). */\n setSearch: (value: string) => void;\n /** Trigger a fetch with the current search string. Only meaningful in explicit mode. */\n submit: () => void;\n}\n\n// Determine whether the query should be enabled based on mode and state.\nfunction resolveEnabled(\n enabled: boolean,\n mode: EntitySelectMode,\n activeSearch: string,\n minChars: number,\n committedSearch: string | null,\n): boolean {\n if (!enabled) return false;\n if (mode === \"typeahead\") return activeSearch.length >= minChars;\n if (mode === \"explicit\") return committedSearch !== null;\n return true;\n}\n\nexport function useEntityOptions({\n fetcher,\n queryKey,\n mode = \"eager\",\n debounceMs = 300,\n minChars = 1,\n limit = 50,\n enabled = true,\n}: UseEntityOptionsConfig): UseEntityOptionsReturn {\n const [search, setSearch] = useState(\"\");\n\n // Debounced search drives eager and typeahead modes.\n const [debouncedSearch, setDebouncedSearch] = useState(\"\");\n // Committed search drives explicit mode -- only updated on submit().\n const [committedSearch, setCommittedSearch] = useState<string | null>(null);\n\n useEffect(() => {\n if (mode === \"explicit\") return;\n const timer = setTimeout(() => setDebouncedSearch(search), debounceMs);\n return () => clearTimeout(timer);\n }, [search, debounceMs, mode]);\n\n const submit = useCallback(() => {\n setCommittedSearch(search);\n }, [search]);\n\n const activeSearch =\n mode === \"explicit\" ? (committedSearch ?? \"\") : debouncedSearch;\n\n const queryEnabled = resolveEnabled(\n enabled,\n mode,\n activeSearch,\n minChars,\n committedSearch,\n );\n\n const { data, isLoading, isError } = useQuery({\n queryKey: [queryKey, \"options\", { search: activeSearch, limit }],\n queryFn: () => fetcher({ search: activeSearch || undefined, limit }),\n enabled: queryEnabled,\n staleTime: 5 * 60 * 1000,\n });\n\n const options: EntityOption[] = data ?? [];\n\n return { options, isLoading, isError, search, setSearch, submit };\n}\n"],"mappings":"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","names":["useState","useCallback","useState","useCallback","useState","useState","useCallback","useEffect","useState","useCallback","useState","useCallback","useState","useEffect","useCallback"]}