entasis 0.13.4 → 0.13.6

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.
Files changed (83) hide show
  1. package/dist/components/Address/Address.svelte +44 -0
  2. package/dist/components/Address/Address.svelte.d.ts +4 -0
  3. package/dist/components/Address/address.d.ts +99 -0
  4. package/dist/components/Address/address.google.d.ts +89 -0
  5. package/dist/components/Address/address.google.fixtures.json +808 -0
  6. package/dist/components/Address/address.google.js +184 -0
  7. package/dist/components/Address/address.js +109 -0
  8. package/dist/components/Address/address.mcp.d.ts +1 -0
  9. package/dist/components/Address/address.mcp.js +100 -0
  10. package/dist/components/Address/address.props.d.ts +26 -0
  11. package/dist/components/Address/address.props.js +1 -0
  12. package/dist/components/Address/address.provider.d.ts +19 -0
  13. package/dist/components/Address/address.provider.js +69 -0
  14. package/dist/components/Address/address.theme.d.ts +63 -0
  15. package/dist/components/Address/address.theme.js +42 -0
  16. package/dist/components/Address/index.d.ts +6 -0
  17. package/dist/components/Address/index.js +5 -0
  18. package/dist/components/Code/Code.svelte +7 -2
  19. package/dist/components/Code/CodeTheme.svelte +12 -1
  20. package/dist/components/Code/code.highlighter.js +2 -2
  21. package/dist/components/Code/code.mcp.d.ts +1 -1
  22. package/dist/components/Code/code.mcp.js +3 -1
  23. package/dist/components/Code/code.theme.js +6 -5
  24. package/dist/components/Form/AddressInput/AddressInput.svelte +482 -0
  25. package/dist/components/Form/AddressInput/AddressInput.svelte.d.ts +4 -0
  26. package/dist/components/Form/AddressInput/addressInput.mcp.d.ts +1 -0
  27. package/dist/components/Form/AddressInput/addressInput.mcp.js +110 -0
  28. package/dist/components/Form/AddressInput/addressInput.props.d.ts +72 -0
  29. package/dist/components/Form/AddressInput/addressInput.props.js +1 -0
  30. package/dist/components/Form/AddressInput/addressInput.theme.d.ts +63 -0
  31. package/dist/components/Form/AddressInput/addressInput.theme.js +55 -0
  32. package/dist/components/Form/AddressInput/index.d.ts +3 -0
  33. package/dist/components/Form/AddressInput/index.js +2 -0
  34. package/dist/components/Form/Field/field.d.ts +5 -2
  35. package/dist/components/Form/Field/field.state.svelte.js +4 -0
  36. package/dist/components/Form/Field/index.d.ts +1 -1
  37. package/dist/components/Form/Field/schemas.js +8 -0
  38. package/dist/components/Form/Form/FormInputRenderer.svelte +6 -0
  39. package/dist/components/Form/Form/form.d.ts +6 -0
  40. package/dist/components/Form/Form/form.mcp.d.ts +1 -1
  41. package/dist/components/Form/Form/form.mcp.js +1 -1
  42. package/dist/components/Form/Form/form.state.svelte.d.ts +142 -11
  43. package/dist/components/Form/Form/form.state.svelte.js +2 -0
  44. package/dist/components/Form/Form/visibility.d.ts +186 -20
  45. package/dist/components/Form/MultiStepForm/multiStepForm.state.svelte.d.ts +136 -0
  46. package/dist/components/Form/SignatureInput/SignatureInput.svelte +327 -0
  47. package/dist/components/Form/SignatureInput/SignatureInput.svelte.d.ts +4 -0
  48. package/dist/components/Form/SignatureInput/index.d.ts +4 -0
  49. package/dist/components/Form/SignatureInput/index.js +2 -0
  50. package/dist/components/Form/SignatureInput/signatureInput.mcp.d.ts +1 -0
  51. package/dist/components/Form/SignatureInput/signatureInput.mcp.js +74 -0
  52. package/dist/components/Form/SignatureInput/signatureInput.props.d.ts +29 -0
  53. package/dist/components/Form/SignatureInput/signatureInput.props.js +1 -0
  54. package/dist/components/Form/SignatureInput/signatureInput.strokes.d.ts +77 -0
  55. package/dist/components/Form/SignatureInput/signatureInput.strokes.js +149 -0
  56. package/dist/components/Form/SignatureInput/signatureInput.theme.d.ts +99 -0
  57. package/dist/components/Form/SignatureInput/signatureInput.theme.js +70 -0
  58. package/dist/components/Sidebar/sidebar-layout.js +3 -1
  59. package/dist/components/Timeline/Timeline.svelte +1 -1
  60. package/dist/components/Timeline/TimelineEntry.svelte +16 -2
  61. package/dist/components/Timeline/index.d.ts +1 -1
  62. package/dist/components/Timeline/timeline.items.d.ts +1 -1
  63. package/dist/components/Timeline/timeline.items.js +7 -2
  64. package/dist/components/Timeline/timeline.mcp.d.ts +1 -1
  65. package/dist/components/Timeline/timeline.mcp.js +35 -3
  66. package/dist/components/Timeline/timeline.props.d.ts +17 -1
  67. package/dist/components/Timeline/timeline.theme.d.ts +36 -0
  68. package/dist/components/Timeline/timeline.theme.js +27 -4
  69. package/dist/generated/componentAliases.d.ts +3 -0
  70. package/dist/generated/componentAliases.js +3 -0
  71. package/dist/generated/componentContract.d.ts +47 -2
  72. package/dist/generated/componentContract.js +114 -0
  73. package/dist/generated/componentMcpRegistry.js +6 -0
  74. package/dist/i18n/ar.js +18 -0
  75. package/dist/i18n/de.js +18 -0
  76. package/dist/i18n/en.d.ts +18 -0
  77. package/dist/i18n/en.js +18 -0
  78. package/dist/i18n/es.js +18 -0
  79. package/dist/i18n/fr.js +18 -0
  80. package/dist/i18n/ja.js +18 -0
  81. package/dist/i18n/pt.js +18 -0
  82. package/dist/i18n/zh.js +18 -0
  83. package/package.json +25 -1
@@ -108,8 +108,13 @@
108
108
  {/if}
109
109
 
110
110
  <!-- ScrollArea owns both axes: horizontal (long lines) natively, vertical when maxHeight caps
111
- the height. Its viewport is the focusable native scroll region (keyboard + focus ring). -->
112
- <ScrollArea type="hover" class="flex max-h-[var(--code-max-height)] flex-col rounded-b-lg">
111
+ the height or a height-bound flex column (a Dialog body) shrinks the block: `min-h-0` lets
112
+ it shrink below its content, so it scrolls under the kept header instead of the root
113
+ clipping its last lines. Its viewport is the focusable native scroll region. -->
114
+ <ScrollArea
115
+ type="hover"
116
+ class="flex max-h-[var(--code-max-height)] min-h-0 flex-col rounded-b-lg"
117
+ >
113
118
  {@render codeBody()}
114
119
  </ScrollArea>
115
120
 
@@ -52,11 +52,22 @@
52
52
  --code-token-property: color-mix(in oklab, var(--color-info) 70%, var(--color-neutral));
53
53
  }
54
54
 
55
- /* The pre stays transparent so the container background shows through. */
55
+ /* The pre stays transparent so the container background shows through. Code owns its layout
56
+ too: an app stylesheet may style every `pre.th-code` (edytor's `code.css` sets `white-space:
57
+ pre`, `tab-size` and `overflow-x`, unlayered, so it beats utilities), and these selectors
58
+ are scoped under the Code root so they win. The ScrollArea scrolls, never the pre. */
56
59
  :global([data-slot='code'] pre.th-code) {
57
60
  margin: 0;
61
+ overflow: visible;
58
62
  background-color: transparent;
59
63
  color: var(--code-token-plain);
64
+ white-space: pre;
65
+ word-break: normal;
66
+ tab-size: var(--code-tab-size, 2);
67
+ }
68
+
69
+ :global([data-slot='code'] pre.th-code[data-wrap]) {
70
+ white-space: pre-wrap;
60
71
  }
61
72
 
62
73
  /* Token classes emitted by TanStack Highlight (`th-token th-<role>`), mapped to our roles. */
@@ -16,11 +16,11 @@ function getHighlighter() {
16
16
  // Applied to the highlighter's `<pre>`. Scrolling is owned by the wrapping ScrollArea (see
17
17
  // Code.svelte), so the pre itself does not scroll: when not wrapping it takes its natural
18
18
  // (max-content) width so the ScrollArea sees the horizontal overflow; when wrapping it fills
19
- // and soft-wraps. `has-[.th-line]:px-0` lets the gutter sit flush against the edge. A narrow
19
+ // and soft-wraps (its white-space is set in CodeTheme.svelte). `has-[.th-line]:px-0` lets the gutter sit flush against the edge. A narrow
20
20
  // block (under 28rem) tightens its padding, as its text steps down (see `code.theme.ts`).
21
21
  const preClass = (wrap) => [
22
22
  'px-4 py-3.5 @max-md:px-3 @max-md:py-2.5 outline-none has-[.th-line]:px-0',
23
- wrap ? 'w-full whitespace-pre-wrap break-words' : 'w-max'
23
+ wrap ? 'w-full break-words' : 'w-max'
24
24
  ].join(' ');
25
25
  /**
26
26
  * Highlights `code` to a `<pre class="th-code"><code>…</code></pre>` HTML string
@@ -1 +1 @@
1
- export declare const codeDescription = "\n# Code Component\n\nDisplays a syntax-highlighted code block with an optional header (language label\n+ copy button), line numbers, and header/footer slots. Highlighting is powered by\nTanStack Highlight with every language bundled (synchronous, SSR-safe) and a CSS-variable token palette, so colors\nadapt to light and dark automatically from the design tokens.\n\n## Basic Usage\n\n```svelte\n<Code language=\"typescript\" code={`const hello: string = 'world';`} />\n```\n\n## Props\n\n### Core Props\n- **code**: string (required) - Source code to highlight. Also what the copy button writes to the clipboard.\n- **language**: string (default: 'text') - Language id (`ts`, `svelte`, `css`, `shell`, `json`, `python`, \u2026). Aliases like `typescript`, `js`, `bash` resolve automatically; every language TanStack Highlight ships is bundled, and unknown ids fall back to plain text.\n- **title**: string - Header label. Defaults to the language's display name (e.g. \"TypeScript\").\n\n### Display Props\n- **showLineNumbers**: boolean (default: false) - Renders a line-number gutter.\n- **showHeader**: boolean (default: true) - Renders the default header row (title + copy button). Set to false to show only the code block. Ignored when a custom `header` snippet is provided.\n- **copyable**: boolean (default: true) - Shows the copy-to-clipboard button. In the header when one is shown, otherwise a floating button in the top-right corner that appears on hover.\n- **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally.\n- **maxHeight**: number | string (default: none) - Caps the block height and makes it vertically scrollable (via ScrollArea). A number is pixels; a string is used as-is (e.g. `'20rem'`).\n- **tabSize**: number (default: 2) - Tab width in spaces.\n\n### Styling Props\n- **class**: string - Additional CSS classes on the root element.\n- **theme**: CodeThemeProps - Theme overrides for the `code`, `header`, `title`, `container`, and `footer` parts.\n\n### Slots\n- **header**: Snippet<[CodeHeaderPayload]> - Replaces the default header row. Receives `{ language, label, copied, copy }`.\n- **footer**: Snippet - Optional footer row (e.g. a token count or a run button).\n\n## Examples\n\n### With a title and line numbers\n```svelte\n<Code\n language=\"svelte\"\n title=\"Counter.svelte\"\n showLineNumbers\n code={svelteSource}\n/>\n```\n\n### Custom header\n```svelte\n<Code language=\"json\" code={json}>\n {#snippet header({ label, copied, copy })}\n <div class=\"flex items-center justify-between px-3 py-1.5\">\n <span>{label}</span>\n <button onclick={copy}>{copied ? 'Copied!' : 'Copy'}</button>\n </div>\n {/snippet}\n</Code>\n```\n\n### With a footer\n```svelte\n<Code language=\"bash\" code={installCmd}>\n {#snippet footer()}\n <span>Run in your project root</span>\n {/snippet}\n</Code>\n```\n\n## Theming\n\nThe block chrome (border, header, footer, container) is styled with cva parts and\nuses `bg-neutral-muted` / `text-neutral/70` for the header and footer.\nThe syntax colors are NOT part of cva \u2014 they come from the highlighter's `th-*` token classes plus the\ncolocated `CodeTheme.svelte`, which maps each `--code-token-*` variable onto our\n`--color-*` design tokens (keyword \u2192 primary, string \u2192 success, number \u2192 warning,\nfunction \u2192 info, tag \u2192 danger, \u2026) and re-tunes a few roles under the dark selector.\n\n### Theme Structure\n- **root**: Root container (border, radius, background).\n- **header**: Header row (language label + copy button).\n- **title**: The language/title label text.\n- **container**: The scrollable code area wrapping the highlighted `<pre>`.\n- **footer**: Optional footer row.\n\n### Theme Type Definition\n\n```typescript\nimport type { CodeThemeProps } from 'entasis/code';\n\nconst customTheme: CodeThemeProps = {\n root: { base: 'rounded-xl raised-4' },\n header: { base: 'bg-primary text-primary-contrast px-4 py-2' }\n};\n```\n\n## Accessibility\n- The copy button carries an `aria-label` that reflects its state (\"Copy\" / \"Copied\").\n- Clipboard access is guarded (`navigator.clipboard`) and only runs client-side.\n\n## Notes\n- The highlighter is a singleton constructed at module load \u2014 SSR-safe, no top-level await.\n- Copied state resets automatically after 2 seconds and whenever `code` or `language` changes.\n";
1
+ export declare const codeDescription = "\n# Code Component\n\nDisplays a syntax-highlighted code block with an optional header (language label\n+ copy button), line numbers, and header/footer slots. Highlighting is powered by\nTanStack Highlight with every language bundled (synchronous, SSR-safe) and a CSS-variable token palette, so colors\nadapt to light and dark automatically from the design tokens.\n\n## Basic Usage\n\n```svelte\n<Code language=\"typescript\" code={`const hello: string = 'world';`} />\n```\n\n## Props\n\n### Core Props\n- **code**: string (required) - Source code to highlight. Also what the copy button writes to the clipboard.\n- **language**: string (default: 'text') - Language id (`ts`, `svelte`, `css`, `shell`, `json`, `python`, \u2026). Aliases like `typescript`, `js`, `bash` resolve automatically; every language TanStack Highlight ships is bundled, and unknown ids fall back to plain text.\n- **title**: string - Header label. Defaults to the language's display name (e.g. \"TypeScript\").\n\n### Display Props\n- **showLineNumbers**: boolean (default: false) - Renders a line-number gutter.\n- **showHeader**: boolean (default: true) - Renders the default header row (title + copy button). Set to false to show only the code block. Ignored when a custom `header` snippet is provided.\n- **copyable**: boolean (default: true) - Shows the copy-to-clipboard button. In the header when one is shown, otherwise a floating button in the top-right corner that appears on hover.\n- **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally. Code sets its `<pre>`'s `white-space` and `tab-size` itself, scoped under its root, so an app stylesheet that styles every `pre.th-code` (edytor's `code.css` does) changes neither.\n- **maxHeight**: number | string (default: none) - Caps the block height and makes it vertically scrollable (via ScrollArea). A number is pixels; a string is used as-is (e.g. `'20rem'`).\n- **tabSize**: number (default: 2) - Tab width in spaces.\n\nIn a height-bound flex column (a Dialog or Drawer body, `flex min-h-0 flex-1 flex-col`), a Code taller than the room left shrinks to it and scrolls inside: the header and footer stay put and every line stays reachable. To keep its full height and let the column scroll instead, pass `class=\"shrink-0\"`.\n\n### Styling Props\n- **class**: string - Additional CSS classes on the root element.\n- **theme**: CodeThemeProps - Theme overrides for the `code`, `header`, `title`, `container`, and `footer` parts.\n\n### Slots\n- **header**: Snippet<[CodeHeaderPayload]> - Replaces the default header row. Receives `{ language, label, copied, copy }`.\n- **footer**: Snippet - Optional footer row (e.g. a token count or a run button).\n\n## Examples\n\n### With a title and line numbers\n```svelte\n<Code\n language=\"svelte\"\n title=\"Counter.svelte\"\n showLineNumbers\n code={svelteSource}\n/>\n```\n\n### Custom header\n```svelte\n<Code language=\"json\" code={json}>\n {#snippet header({ label, copied, copy })}\n <div class=\"flex items-center justify-between px-3 py-1.5\">\n <span>{label}</span>\n <button onclick={copy}>{copied ? 'Copied!' : 'Copy'}</button>\n </div>\n {/snippet}\n</Code>\n```\n\n### With a footer\n```svelte\n<Code language=\"bash\" code={installCmd}>\n {#snippet footer()}\n <span>Run in your project root</span>\n {/snippet}\n</Code>\n```\n\n## Theming\n\nThe block chrome (border, header, footer, container) is styled with cva parts and\nuses `bg-neutral-muted` / `text-neutral/70` for the header and footer.\nThe syntax colors are NOT part of cva \u2014 they come from the highlighter's `th-*` token classes plus the\ncolocated `CodeTheme.svelte`, which maps each `--code-token-*` variable onto our\n`--color-*` design tokens (keyword \u2192 primary, string \u2192 success, number \u2192 warning,\nfunction \u2192 info, tag \u2192 danger, \u2026) and re-tunes a few roles under the dark selector.\n\n### Theme Structure\n- **root**: Root container (border, radius, background).\n- **header**: Header row (language label + copy button).\n- **title**: The language/title label text.\n- **container**: The scrollable code area wrapping the highlighted `<pre>`.\n- **footer**: Optional footer row.\n\n### Theme Type Definition\n\n```typescript\nimport type { CodeThemeProps } from 'entasis/code';\n\nconst customTheme: CodeThemeProps = {\n root: { base: 'rounded-xl raised-4' },\n header: { base: 'bg-primary text-primary-contrast px-4 py-2' }\n};\n```\n\n## Accessibility\n- The copy button carries an `aria-label` that reflects its state (\"Copy\" / \"Copied\").\n- Clipboard access is guarded (`navigator.clipboard`) and only runs client-side.\n\n## Notes\n- The highlighter is a singleton constructed at module load \u2014 SSR-safe, no top-level await.\n- Copied state resets automatically after 2 seconds and whenever `code` or `language` changes.\n";
@@ -23,10 +23,12 @@ adapt to light and dark automatically from the design tokens.
23
23
  - **showLineNumbers**: boolean (default: false) - Renders a line-number gutter.
24
24
  - **showHeader**: boolean (default: true) - Renders the default header row (title + copy button). Set to false to show only the code block. Ignored when a custom \`header\` snippet is provided.
25
25
  - **copyable**: boolean (default: true) - Shows the copy-to-clipboard button. In the header when one is shown, otherwise a floating button in the top-right corner that appears on hover.
26
- - **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally.
26
+ - **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally. Code sets its \`<pre>\`'s \`white-space\` and \`tab-size\` itself, scoped under its root, so an app stylesheet that styles every \`pre.th-code\` (edytor's \`code.css\` does) changes neither.
27
27
  - **maxHeight**: number | string (default: none) - Caps the block height and makes it vertically scrollable (via ScrollArea). A number is pixels; a string is used as-is (e.g. \`'20rem'\`).
28
28
  - **tabSize**: number (default: 2) - Tab width in spaces.
29
29
 
30
+ In a height-bound flex column (a Dialog or Drawer body, \`flex min-h-0 flex-1 flex-col\`), a Code taller than the room left shrinks to it and scrolls inside: the header and footer stay put and every line stays reachable. To keep its full height and let the column scroll instead, pass \`class="shrink-0"\`.
31
+
30
32
  ### Styling Props
31
33
  - **class**: string - Additional CSS classes on the root element.
32
34
  - **theme**: CodeThemeProps - Theme overrides for the \`code\`, \`header\`, \`title\`, \`container\`, and \`footer\` parts.
@@ -9,20 +9,21 @@ const defaultCode = cva({
9
9
  const defaultCodeFloatingCopy = cva({
10
10
  base: 'absolute top-2 right-2 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100'
11
11
  });
12
+ // Header and footer never shrink: in a height-bound column the code scrolls between them.
12
13
  const defaultCodeHeader = cva({
13
- base: 'flex items-center justify-between gap-md border-b border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
14
+ base: 'flex shrink-0 items-center justify-between gap-md border-b border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
14
15
  });
15
16
  const defaultCodeTitle = cva({
16
17
  base: 'font-mono'
17
18
  });
18
19
  const defaultCodeContainer = cva({
19
20
  // The scroll + padding live on the inner highlighter <pre> (classes added by codeToHtml)
20
- // so the focusable node is the scroller; the container is just the surface. `tab-size` is
21
- // driven by a CSS var set on the root (defaults to 2).
22
- base: 'min-w-0 bg-surface text-sm @max-md:text-xs [&_pre]:!bg-transparent [&_pre]:[tab-size:var(--code-tab-size,2)] [&_code]:font-mono'
21
+ // so the focusable node is the scroller; the container is just the surface. The pre's
22
+ // white-space and `tab-size` (from `--code-tab-size` on the root) live in CodeTheme.svelte.
23
+ base: 'min-w-0 bg-surface text-sm @max-md:text-xs [&_pre]:!bg-transparent [&_code]:font-mono'
23
24
  });
24
25
  const defaultCodeFooter = cva({
25
- base: 'flex items-center justify-between gap-md border-t border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
26
+ base: 'flex shrink-0 items-center justify-between gap-md border-t border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
26
27
  });
27
28
  export const codeTheme = {
28
29
  root: defaultCode,
@@ -0,0 +1,482 @@
1
+ <script lang="ts">
2
+ import { onDestroy, setContext, untrack } from 'svelte';
3
+ import Button from '../../Button/Button.svelte';
4
+ import { builtInAddressProvider, getAddressProvider } from '../../Address/address.provider.js';
5
+ import {
6
+ COUNTRY_CODES,
7
+ countryName,
8
+ formatAddressLine,
9
+ type Address,
10
+ type AddressField
11
+ } from '../../Address/address.js';
12
+ import Autocomplete from '../Autocomplete/Autocomplete.svelte';
13
+ import type { AutocompleteItemsRequest } from '../Autocomplete/autocomplete.props.js';
14
+ import Combobox from '../Combobox/Combobox.svelte';
15
+ import Field from '../Field/Field.svelte';
16
+ import FieldHiddenInputs from '../Field/FieldHiddenInputs.svelte';
17
+ import { createFieldState } from '../Field/field.state.svelte.js';
18
+ import TextInput from '../TextInput/TextInput.svelte';
19
+ import { useI18n } from '../../../i18n/context.svelte.js';
20
+ import type {
21
+ AddressFieldConfig,
22
+ AddressFieldRow,
23
+ AddressInputProps
24
+ } from './addressInput.props.js';
25
+ import { useAddressInputTheme } from './addressInput.theme.js';
26
+
27
+ const DEFAULT_FIELDS: readonly AddressFieldRow[] = [
28
+ 'line1',
29
+ 'line2',
30
+ [
31
+ { field: 'postalCode', width: 1 },
32
+ { field: 'city', width: 2 }
33
+ ],
34
+ 'country'
35
+ ];
36
+
37
+ let {
38
+ defaultValue = null,
39
+ value = $bindable(),
40
+ errors = $bindable([]),
41
+ focused = $bindable(false),
42
+ required = false,
43
+ disabled,
44
+ readOnly,
45
+ messages,
46
+ name,
47
+ onValidate,
48
+ onValueChange,
49
+ visible,
50
+ size = 'normal',
51
+ density = 'normal',
52
+ labelPosition,
53
+ provider: providerProp,
54
+ fields = DEFAULT_FIELDS,
55
+ editableFields,
56
+ requiredFields,
57
+ countries,
58
+ defaultCountry,
59
+ layout = 'fields',
60
+ minSearchLength = 3,
61
+ placeholder,
62
+ onResolve,
63
+ i18n,
64
+ label,
65
+ theme,
66
+ fieldAttrs,
67
+ ...rest
68
+ }: AddressInputProps = $props();
69
+ if (value === undefined) value = untrack(() => defaultValue);
70
+
71
+ const id = $props.id();
72
+ const t = $derived(useI18n(i18n));
73
+ const classes = $derived(useAddressInputTheme(theme));
74
+
75
+ const text = (part: string | null | undefined) => (part ?? '').trim();
76
+
77
+ // The layout: rows of parts, each with its settings.
78
+ type Part = AddressFieldConfig & { width: number };
79
+ const rows = $derived.by(() => {
80
+ const seen: AddressField[] = [];
81
+ return fields.map((row) =>
82
+ (Array.isArray(row) ? row : [row]).map((item: AddressField | AddressFieldConfig): Part => {
83
+ const part = typeof item === 'string' ? { field: item, width: 1 } : { width: 1, ...item };
84
+ if (seen.includes(part.field)) {
85
+ throw new TypeError(
86
+ `AddressInput fields list the "${part.field}" part twice. Each part appears once.`
87
+ );
88
+ }
89
+ seen.push(part.field);
90
+ return part;
91
+ })
92
+ );
93
+ });
94
+ const parts = $derived(rows.flat());
95
+ const shownParts = $derived(parts.map((part) => part.field));
96
+ const shown = $derived(new Set(shownParts));
97
+ const editable = $derived(new Set(editableFields ?? shownParts));
98
+ const requiredParts = $derived(
99
+ (
100
+ requiredFields ??
101
+ (required ? shownParts.filter((part) => part !== 'line2' && part !== 'region') : [])
102
+ ).filter((part) => shown.has(part))
103
+ );
104
+
105
+ /** Whether `address` holds anything a person entered (a defaulted country alone does not count). */
106
+ const hasContent = (address: Address | null | undefined) =>
107
+ !!address &&
108
+ shownParts.some((part) => {
109
+ if (part !== 'country') return text(address[part]) !== '';
110
+ const code = text(address.countryCode).toUpperCase();
111
+ if (code) return code !== defaultCountry?.toUpperCase();
112
+ return text(address.country) !== '';
113
+ });
114
+
115
+ const partLabel = (part: AddressField) =>
116
+ parts.find((entry) => entry.field === part)?.label ??
117
+ {
118
+ line1: t.addressLine1,
119
+ line2: t.addressLine2,
120
+ postalCode: t.addressPostalCode,
121
+ city: t.addressCity,
122
+ region: t.addressRegion,
123
+ country: t.addressCountry
124
+ }[part];
125
+
126
+ const missingParts = (address: Address | null | undefined) =>
127
+ hasContent(address) ? requiredParts.filter((part) => text(address?.[part]) === '') : [];
128
+
129
+ const field = createFieldState({
130
+ id,
131
+ get value() {
132
+ return value;
133
+ },
134
+ set value(nextValue) {
135
+ value = nextValue;
136
+ },
137
+ get errors() {
138
+ return errors;
139
+ },
140
+ set errors(nextErrors: string[] | boolean) {
141
+ errors = nextErrors;
142
+ },
143
+ get focused() {
144
+ return focused;
145
+ },
146
+ set focused(nextFocused: boolean) {
147
+ focused = nextFocused;
148
+ },
149
+ onValueChange: (nextValue) => onValueChange?.(nextValue),
150
+ get disabled() {
151
+ return disabled;
152
+ },
153
+ set disabled(nextDisabled: boolean | undefined) {
154
+ disabled = nextDisabled;
155
+ },
156
+ get readOnly() {
157
+ return readOnly;
158
+ },
159
+ get messages() {
160
+ return messages;
161
+ },
162
+ get required() {
163
+ return required;
164
+ },
165
+ get name() {
166
+ return name;
167
+ },
168
+ set name(nextName: string | undefined) {
169
+ name = nextName;
170
+ },
171
+ // The parts a started address still misses come first, then the app's own rule.
172
+ onValidate: (address) => {
173
+ const missing = missingParts(address).map((part) => t.addressPartRequired(partLabel(part)));
174
+ if (missing.length) return missing;
175
+ return onValidate?.(address) ?? false;
176
+ },
177
+ get visible() {
178
+ return visible;
179
+ },
180
+ type: 'address'
181
+ });
182
+
183
+ // The search: this field's own `provider`, else the one set for the app, else Google Places
184
+ // through the app's `/api/places` route. `null` at either level turns it off.
185
+ const contextProvider = getAddressProvider();
186
+ const provider = $derived(
187
+ providerProp !== undefined
188
+ ? providerProp
189
+ : contextProvider
190
+ ? contextProvider.provider
191
+ : builtInAddressProvider()
192
+ );
193
+
194
+ // The parts are inputs of their own, but only this field belongs to a surrounding Form: they
195
+ // edit its value, they are not entries of the Form.
196
+ setContext('form', null);
197
+
198
+ const address = $derived<Address>(field.value ?? {});
199
+ const countryCode = $derived(
200
+ text(address.countryCode).toUpperCase() || defaultCountry?.toUpperCase() || ''
201
+ );
202
+
203
+ const commit = (next: Address) => field.setValue(hasContent(next) ? next : null);
204
+
205
+ /** A manual edit: the provider's id, coordinates and one-line address no longer describe it. */
206
+ const edit = (part: AddressField, input: string | null) => {
207
+ const next: Address = {
208
+ ...address,
209
+ [part]: input?.trim() ? input : null,
210
+ formatted: null,
211
+ placeId: null,
212
+ location: null
213
+ };
214
+ if (part === 'line1') {
215
+ next.streetNumber = null;
216
+ next.street = null;
217
+ }
218
+ if (!next.countryCode && defaultCountry) {
219
+ next.countryCode = defaultCountry.toUpperCase();
220
+ next.country = countryName(next.countryCode, t.locale);
221
+ }
222
+ commit(next);
223
+ };
224
+
225
+ const editCountry = (code: string | null) => {
226
+ commit({
227
+ ...address,
228
+ countryCode: code,
229
+ country: code ? countryName(code, t.locale) : null,
230
+ formatted: null,
231
+ placeId: null,
232
+ location: null
233
+ });
234
+ };
235
+
236
+ const countryItems = $derived(
237
+ (countries?.length ? countries.map((code) => code.toUpperCase()) : COUNTRY_CODES)
238
+ .map((code) => ({ value: code, label: countryName(code, t.locale) }))
239
+ .sort((a, b) => a.label.localeCompare(b.label, t.locale))
240
+ );
241
+
242
+ // One session per search, from the first keystroke to the resolved address: a provider that
243
+ // bills by session (Google Places) counts the suggestions and the details as one.
244
+ const newSession = () =>
245
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
246
+ ? crypto.randomUUID()
247
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
248
+ let session = newSession();
249
+ let resolving = $state(false);
250
+ let resolveController: AbortController | null = null;
251
+ onDestroy(() => resolveController?.abort());
252
+
253
+ const search = async (query: string, { signal }: AutocompleteItemsRequest) => {
254
+ if (!provider || query.trim().length < minSearchLength) return [];
255
+ const suggestions = await provider.search(query, {
256
+ signal,
257
+ session,
258
+ language: t.locale,
259
+ countries
260
+ });
261
+ return suggestions.map((suggestion) => ({
262
+ value: suggestion.id,
263
+ label: suggestion.label,
264
+ description: suggestion.description
265
+ }));
266
+ };
267
+
268
+ const choose = async (suggestionId: string) => {
269
+ if (!provider) return;
270
+ resolveController?.abort();
271
+ const controller = new AbortController();
272
+ resolveController = controller;
273
+ resolving = true;
274
+ try {
275
+ const resolved = await provider.resolve(suggestionId, {
276
+ signal: controller.signal,
277
+ session,
278
+ language: t.locale,
279
+ countries
280
+ });
281
+ if (controller.signal.aborted) return;
282
+ session = newSession();
283
+ if (!resolved) return;
284
+ commit(resolved);
285
+ searchText = formatAddressLine(resolved, { locale: t.locale });
286
+ manual = false;
287
+ onResolve?.(resolved);
288
+ } catch (error) {
289
+ if (controller.signal.aborted) return;
290
+ field.errors = [t.addressLookupFailed];
291
+ void error;
292
+ } finally {
293
+ if (resolveController === controller) {
294
+ resolving = false;
295
+ resolveController = null;
296
+ }
297
+ }
298
+ };
299
+
300
+ // The search layout's input shows the chosen address on one line; typing only searches.
301
+ let searchText = $state(untrack(() => formatAddressLine(field.value, { locale: t.locale })));
302
+ let manual = $state(false);
303
+ $effect(() => {
304
+ const line = formatAddressLine(field.value, { locale: t.locale });
305
+ untrack(() => {
306
+ if (!resolving) searchText = line;
307
+ });
308
+ });
309
+ const showFields = $derived(layout === 'fields' || manual);
310
+ const searchable = $derived(!!provider && editable.has('line1'));
311
+
312
+ // Labels on the left: every part takes its own row, its label in the column all the parts
313
+ // share, and the address's legend heads them.
314
+ const partsLabelLeft = $derived(labelPosition === 'left' && showFields);
315
+ const layoutRows = $derived(partsLabelLeft ? parts.map((part) => [part]) : rows);
316
+ const rowColumns = (row: readonly Part[]) =>
317
+ row.map((part) => `minmax(0, ${Math.max(part.width, 0)}fr)`).join(' ');
318
+
319
+ const autofill: Record<AddressField, string> = {
320
+ line1: 'address-line1',
321
+ line2: 'address-line2',
322
+ postalCode: 'postal-code',
323
+ city: 'address-level2',
324
+ region: 'address-level1',
325
+ country: 'country'
326
+ };
327
+
328
+ // After a failed validation: the required parts still empty, all of them for an empty
329
+ // required address.
330
+ const missingShown = $derived.by(() => {
331
+ if (!field.hasError) return [];
332
+ if (!hasContent(field.value)) return required ? requiredParts : [];
333
+ return missingParts(field.value);
334
+ });
335
+ // A missing part's input carries its own message, so it is described and marked invalid, but
336
+ // only the address announces it: the part's own error block stays hidden.
337
+ const partErrors = (part: AddressField) =>
338
+ missingShown.includes(part) ? [t.addressPartRequired(partLabel(part))] : [];
339
+ const partTheme = $derived({ errorsContainer: { base: classes.partErrors() } });
340
+ const partRequired = (part: AddressField) =>
341
+ requiredParts.includes(part) && (required || hasContent(field.value));
342
+ const partReadOnly = (part: AddressField) => field.readOnly || !editable.has(part);
343
+
344
+ // No part is the field's control: Field would mark it invalid and describe it by every error
345
+ // of the address. The parts a validation finds missing carry their own message instead, and a
346
+ // Form focuses the first part of an invalid address.
347
+ let frame = $state<HTMLElement | null>(null);
348
+ const onfocusin = () => (field.focused = true);
349
+ const onfocusout = (event: FocusEvent) => {
350
+ if (!frame?.contains(event.relatedTarget as Node | null)) field.focused = false;
351
+ };
352
+
353
+ // Submitted under `name[part]` (and `name[countryCode]`), only when the app named the field.
354
+ const baseName = $derived(
355
+ field.name === `${field.type}-input-${field.id}` ? undefined : field.name
356
+ );
357
+ const submitted = $derived(
358
+ baseName
359
+ ? [...shownParts, 'countryCode' as const].map((part) => ({
360
+ name: `${baseName}[${part}]`,
361
+ value: part === 'countryCode' ? address.countryCode || null : text(address[part]) || null
362
+ }))
363
+ : []
364
+ );
365
+ </script>
366
+
367
+ <Field
368
+ as="fieldset"
369
+ {field}
370
+ {label}
371
+ {size}
372
+ {density}
373
+ labelPosition={partsLabelLeft ? 'top' : labelPosition}
374
+ {theme}
375
+ fieldAttrs={{ ...fieldAttrs, ...(label ? {} : { 'aria-label': t.addressInput }) }}
376
+ {...rest}
377
+ >
378
+ <div
379
+ bind:this={frame}
380
+ data-slot="address-input"
381
+ data-layout={layout}
382
+ data-resolving={resolving ? 'true' : undefined}
383
+ aria-busy={resolving || undefined}
384
+ class={classes.parts()}
385
+ {onfocusin}
386
+ {onfocusout}
387
+ >
388
+ {#if !showFields}
389
+ <div data-slot="address-search" class={classes.search()}>
390
+ <Autocomplete
391
+ {size}
392
+ inputAttrs={{ 'aria-label': t.addressSearch }}
393
+ mode="none"
394
+ items={search}
395
+ value={searchText}
396
+ placeholder={placeholder ?? t.addressSearchPlaceholder}
397
+ disabled={field.disabled}
398
+ readOnly={field.readOnly}
399
+ validationMode="submit"
400
+ errors={field.hasError}
401
+ onValueChange={(next) => (searchText = next ?? '')}
402
+ onSelect={({ item }) => choose(item.value)}
403
+ />
404
+ {#if !field.disabled && !field.readOnly}
405
+ <Button variant="link" size="small" color="neutral" onclick={() => (manual = true)}>
406
+ {t.addressEnterManually}
407
+ </Button>
408
+ {/if}
409
+ </div>
410
+ {:else}
411
+ <div data-slot="address-parts" class={classes.grid({ density })}>
412
+ {#each layoutRows as row (row.map((part) => part.field).join())}
413
+ <div
414
+ data-slot="address-row"
415
+ class={classes.row({ density })}
416
+ style:--address-row-columns={rowColumns(row)}
417
+ >
418
+ {#each row as { field: part, placeholder: partPlaceholder } (part)}
419
+ <div data-slot="address-part" data-part={part} class={classes.part()}>
420
+ {#if part === 'line1' && searchable && !partReadOnly('line1')}
421
+ <Autocomplete
422
+ label={partLabel('line1')}
423
+ {size}
424
+ {density}
425
+ {labelPosition}
426
+ mode="none"
427
+ items={search}
428
+ value={address.line1 ?? ''}
429
+ placeholder={partPlaceholder ?? placeholder ?? t.addressSearchPlaceholder}
430
+ required={partRequired('line1')}
431
+ disabled={field.disabled}
432
+ validationMode="submit"
433
+ errors={partErrors('line1')}
434
+ theme={partTheme}
435
+ onValueChange={(next) => edit('line1', next)}
436
+ onSelect={({ item }) => choose(item.value)}
437
+ />
438
+ {:else if part === 'country'}
439
+ <Combobox
440
+ label={partLabel('country')}
441
+ {size}
442
+ {labelPosition}
443
+ items={countryItems}
444
+ value={countryCode || null}
445
+ placeholder={partPlaceholder}
446
+ required={partRequired('country')}
447
+ disabled={field.disabled}
448
+ readOnly={partReadOnly('country')}
449
+ validationMode="submit"
450
+ errors={partErrors('country')}
451
+ theme={partTheme}
452
+ onValueChange={({ value: code }) => editCountry(code)}
453
+ />
454
+ {:else}
455
+ <TextInput
456
+ label={partLabel(part)}
457
+ {size}
458
+ {density}
459
+ {labelPosition}
460
+ value={address[part] ?? ''}
461
+ placeholder={partPlaceholder}
462
+ required={partRequired(part)}
463
+ disabled={field.disabled}
464
+ readOnly={partReadOnly(part)}
465
+ validationMode="submit"
466
+ errors={partErrors(part)}
467
+ theme={partTheme}
468
+ inputAttrs={{ autocomplete: autofill[part] as never }}
469
+ onValueChange={(next) => edit(part, next)}
470
+ />
471
+ {/if}
472
+ </div>
473
+ {/each}
474
+ </div>
475
+ {/each}
476
+ </div>
477
+ {/if}
478
+ </div>
479
+ {#each submitted as entry (entry.name)}
480
+ <FieldHiddenInputs {field} name={entry.name} value={entry.value} required={false} />
481
+ {/each}
482
+ </Field>
@@ -0,0 +1,4 @@
1
+ import type { AddressInputProps } from './addressInput.props.js';
2
+ declare const AddressInput: import("svelte").Component<AddressInputProps, {}, "value" | "errors" | "focused">;
3
+ type AddressInput = ReturnType<typeof AddressInput>;
4
+ export default AddressInput;
@@ -0,0 +1 @@
1
+ export declare const addressInputDescription = "\n# AddressInput\n\nA form field for a postal address. Its value is an `AddressValue` object (see `entasis/address`). Choose which parts it shows, which a person may edit and which are required. It searches Google Places by itself, and choosing a suggestion fills every part, with its coordinates (`location: { lat, lng }`).\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n import { AddressInput } from './index.ts';\n import type { AddressValue } from '../../Address/index.ts';\n\n let address = $state<AddressValue | null>(null);\n</script>\n\n<AddressInput label=\"Delivery address\" required defaultCountry=\"FR\" bind:value={address} />\n```\n\n## Setup\n\nThe search goes through the app's own `/api/places` route, so the Google key stays on the server. Four steps:\n\n1. **A Google key.** In Google Cloud, enable **Places API (New)** on a project with billing, create an API key and restrict it to Places API (New). Give it no website (referrer) restriction: only your server calls Google, and a server request carries no referrer.\n2. **The key in the server environment**, as `GOOGLE_PLACE_API_KEY`: in `.env` locally, and as a secret of your host in production (for example `wrangler secret put GOOGLE_PLACE_API_KEY` on Cloudflare). SvelteKit 3 reads declared variables only:\n\n ```ts\n // src/env.ts\n import { defineEnvVars } from '@sveltejs/kit/env';\n\n export const variables = defineEnvVars({\n GOOGLE_PLACE_API_KEY: {}\n });\n ```\n\n SvelteKit 2 needs no declaration: read `env.GOOGLE_PLACE_API_KEY` from `$env/dynamic/private`.\n3. **The route**, one file:\n\n ```ts nocheck\n // src/routes/api/places/[...path]/+server.ts\n import { createGooglePlacesProxy } from '../../Address/index.ts';\n import { GOOGLE_PLACE_API_KEY } from '$app/env/private';\n\n export const { GET, POST } = createGooglePlacesProxy({ apiKey: GOOGLE_PLACE_API_KEY });\n ```\n\n It forwards only the address search and the place lookup, with the key; any other path or method, or a missing key, answers 404.\n4. **The field**: `<AddressInput label=\"Address\" />`. No provider to build.\n\nWithout the route the search turns itself off (the dev server warns once) and the parts are typed by hand. To search elsewhere: `setAddressProvider(createGooglePlacesProvider({ endpoint: '/my/route' }))` in a layout, `createGooglePlacesProvider({ apiKey })` with a browser key restricted to your sites, or any `{ search, resolve }` provider. `provider={null}` turns one field's search off.\n\nIn a Form or `ask()`, use `type: 'address'` with the same props:\n\n```ts\nconst inputs = { address: { type: 'address', label: 'Address', required: true } } as const;\n```\n\n## Props\n\nExtends the shared Field props (`label`, `description`, `helper`, `required`, `disabled`, `readOnly`, `name`, `size`, `density`, `labelPosition`, `messages`, `onValidate`, `errors`, `focused`, `visible`) plus:\n\n- **value** / **defaultValue**: `AddressValue | null` (bindable). `null` while nothing is entered.\n- **onValueChange**: `(value: AddressValue | null) => void` \u2014 every edit of a part and every resolved suggestion.\n- **provider**: `AddressProvider | null` \u2014 the search. Defaults to the one `setAddressProvider` set for the app, else to Google Places through `/api/places`. `null` turns the search off: every part is typed by hand.\n- **fields**: `AddressFieldRow[]` \u2014 the parts shown and their layout. Each entry is a row: one part, or an array of parts side by side. A part is its name (`'line1'`, `'line2'`, `'postalCode'`, `'city'`, `'region'`, `'country'`) or its settings `{ field, width?, label?, placeholder? }`: `width` is its share of the row by proportion (default 1), `label` and `placeholder` replace the catalog's. Default `['line1', 'line2', [{ field: 'postalCode', width: 1 }, { field: 'city', width: 2 }], 'country']`. Under 28rem of the field's width every part takes its own row. A part listed twice throws.\n- **editableFields**: `AddressField[]` (default: every shown part) \u2014 parts a person may type in. The others show what the search filled, read-only.\n- **requiredFields**: `AddressField[]` \u2014 parts that must be filled once the address is started, or always when `required`. Defaults to the shown parts except `line2` and `region` when `required`, and to none otherwise. A missing part reads \"Required: City\" under the field and marks its input invalid.\n- **countries**: `string[]` \u2014 ISO codes the search and the country list are limited to.\n- **defaultCountry**: `string` \u2014 country code a new address starts in. A defaulted country alone does not make the address filled.\n- **labelPosition**: `'top' | 'left'`. With `left`, every part has its label on the left, in one column the parts share, one part per row, under the address's legend. In the `search` layout the legend itself sits on the left of the search input.\n- **layout**: `'fields' | 'search'` (default `'fields'`). `fields`: one input per part; the street line searches. `search`: one search input showing the chosen address on one line, with a button to enter or edit the parts by hand.\n- **minSearchLength**: `number` (default 3) \u2014 characters typed before the search runs.\n- **placeholder**: hint in the search input (the street line, unless its settings give one, or the single input of `layout=\"search\"`).\n- **onResolve**: `(address: AddressValue) => void` \u2014 the address a chosen suggestion resolved to, after the value changed.\n- **i18n**, **theme** (parts `parts`, `grid`, `row`, `part`, `search`, `partErrors`, plus Field parts).\n\n## Layout\n\n```svelte\n<AddressInput\n label=\"Address\"\n fields={[\n [{ field: 'line1', width: 3 }, { field: 'line2', label: 'Building', placeholder: 'Bldg B, floor 3' }],\n [{ field: 'postalCode', width: 1 }, { field: 'city', width: 2 }],\n ['region', 'country']\n ]}\n/>\n```\n\n`editableFields` and `requiredFields` name the parts, whatever row they sit in. The theme's `grid` part holds the rows, `row` lays one out through `--address-row-columns`, and `part` wraps each input.\n\n## Behaviour\n\n- Typing in the street line edits `line1` and searches. Choosing a suggestion resolves it and replaces the whole value (with `line2` from the provider, usually empty): `streetNumber`, `street`, `formatted`, `placeId` and `location` come with it.\n- A manual edit clears `formatted`, `placeId` and `location`; editing `line1` also clears `streetNumber` and `street`.\n- The country is a searchable list of countries named in the active locale; its value is `countryCode`, and `country` follows in the same locale.\n- A failed resolve shows \"This address could not be loaded. Fill it in by hand.\" and leaves the parts editable.\n- One session id covers the searches and the resolve of each chosen address.\n\n## Native forms and Form\n\n- In a Form, only the address is an entry: the part inputs inside it are not Form fields.\n- With a `name`, hidden inputs submit `name[line1]`, `name[postalCode]`\u2026 for each shown part, plus `name[countryCode]`.\n\n## Accessibility\n\n- The field is a `<fieldset>` whose legend is the label (or \"Address\" without one). Each part is a labelled input with its browser autofill hint (`address-line1`, `postal-code`, `address-level2`\u2026; the street line turns the browser's off while a provider suggests).\n- Required parts show the required marker. Errors are linked to the fieldset, and the missing parts' inputs are marked invalid.\n- The search is an `Autocomplete`: a combobox with a listbox of suggestions.\n- While a suggestion resolves, the parts have `aria-busy=\"true\"`.\n";