@santi020k/lumen-astro 3.0.1 → 4.1.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.
Files changed (109) hide show
  1. package/README.md +424 -7
  2. package/components/AmountField.astro +21 -0
  3. package/components/AnimatedNumber.astro +1 -1
  4. package/components/ApprovalCard.astro +30 -0
  5. package/components/AttachmentList.astro +15 -0
  6. package/components/AttachmentPreview.astro +51 -0
  7. package/components/Avatar.astro +2 -0
  8. package/components/BarChart.astro +95 -31
  9. package/components/BoxPlot.astro +77 -0
  10. package/components/Breadcrumb.astro +2 -1
  11. package/components/BulletChart.astro +64 -0
  12. package/components/Button.astro +1 -1
  13. package/components/Calendar.astro +54 -65
  14. package/components/CalendarHeatmap.astro +76 -0
  15. package/components/Card.astro +4 -0
  16. package/components/ChangeSummary.astro +36 -0
  17. package/components/ChartDatumActions.astro +24 -0
  18. package/components/ChartMotion.astro +11 -0
  19. package/components/Code.astro +18 -3
  20. package/components/CodeTabs.astro +12 -0
  21. package/components/ComboChart.astro +93 -11
  22. package/components/CountryFlag.astro +23 -0
  23. package/components/DataTable.astro +15 -7
  24. package/components/DatePicker.astro +24 -17
  25. package/components/DescriptionDetail.astro +18 -0
  26. package/components/DescriptionItem.astro +18 -0
  27. package/components/DescriptionTerm.astro +18 -0
  28. package/components/DeviceFrame.astro +79 -0
  29. package/components/DialogBody.astro +20 -0
  30. package/components/DialogClose.astro +21 -0
  31. package/components/DialogFooter.astro +20 -0
  32. package/components/DialogHeader.astro +20 -0
  33. package/components/DialogTitle.astro +21 -0
  34. package/components/DumbbellChart.astro +8 -0
  35. package/components/FileUpload.astro +3 -1
  36. package/components/FilterBar.astro +25 -0
  37. package/components/FunnelChart.astro +50 -0
  38. package/components/Grid.astro +2 -2
  39. package/components/Heatmap.astro +61 -12
  40. package/components/Histogram.astro +30 -0
  41. package/components/Icon.astro +7 -3
  42. package/components/ImageComparison.astro +74 -0
  43. package/components/Input.astro +2 -2
  44. package/components/LineChart.astro +122 -66
  45. package/components/ListBox.astro +2 -1
  46. package/components/LollipopChart.astro +8 -0
  47. package/components/MediaFilmstrip.astro +19 -0
  48. package/components/MediaThumbnail.astro +29 -0
  49. package/components/MediaViewport.astro +38 -0
  50. package/components/Mentions.astro +2 -1
  51. package/components/MessageScroller.astro +6 -0
  52. package/components/MotionGroup.astro +19 -0
  53. package/components/NativeSelect.astro +2 -2
  54. package/components/Pagination.astro +2 -1
  55. package/components/PasswordField.astro +1 -1
  56. package/components/PhoneInput.astro +60 -9
  57. package/components/PhoneNumber.astro +19 -0
  58. package/components/PieChart.astro +26 -3
  59. package/components/Progress.astro +2 -4
  60. package/components/PromptComposer.astro +36 -0
  61. package/components/RangeChart.astro +38 -8
  62. package/components/ScatterChart.astro +62 -14
  63. package/components/Segmented.astro +5 -5
  64. package/components/Select.astro +8 -8
  65. package/components/Skeleton.astro +46 -5
  66. package/components/SourceCitation.astro +18 -0
  67. package/components/Sparkline.astro +9 -10
  68. package/components/Spinner.astro +4 -4
  69. package/components/Stack.astro +2 -2
  70. package/components/StreamMessage.astro +26 -0
  71. package/components/Tabs.astro +3 -0
  72. package/components/ToolActivity.astro +17 -0
  73. package/components/TreeSelect.astro +1 -0
  74. package/components/VirtualList.astro +5 -2
  75. package/components/VisualEffect.astro +21 -0
  76. package/components/WaterfallChart.astro +27 -0
  77. package/components/WorldMap.astro +213 -0
  78. package/forms.ts +17 -4
  79. package/index.ts +37 -0
  80. package/internal/ChartInspection.astro +27 -0
  81. package/internal/ComparisonChart.astro +69 -0
  82. package/internal/IntervalChart.astro +66 -0
  83. package/package.json +6 -4
  84. package/runtime/UIPrimitives.astro +739 -711
  85. package/runtime/chart-actions.ts +19 -0
  86. package/runtime/controllers/amount-field.ts +12 -0
  87. package/runtime/controllers/attachments.ts +13 -0
  88. package/runtime/controllers/chart-activation.ts +33 -0
  89. package/runtime/controllers/charts.ts +33 -0
  90. package/runtime/controllers/combobox.ts +39 -0
  91. package/runtime/controllers/comparison-reset.ts +20 -0
  92. package/runtime/controllers/data-table.ts +142 -0
  93. package/runtime/controllers/dialogs.ts +32 -32
  94. package/runtime/controllers/document-navigation.ts +54 -22
  95. package/runtime/controllers/file-upload.ts +89 -0
  96. package/runtime/controllers/image-comparison.ts +53 -0
  97. package/runtime/controllers/list-box-proxy.ts +209 -0
  98. package/runtime/controllers/media-selection.ts +20 -0
  99. package/runtime/controllers/media-viewport.ts +54 -0
  100. package/runtime/controllers/media-workspace.ts +8 -0
  101. package/runtime/controllers/message-scroller.ts +13 -0
  102. package/runtime/controllers/motion.ts +15 -16
  103. package/runtime/controllers/optional-media.ts +29 -0
  104. package/runtime/controllers/phone-input.ts +128 -39
  105. package/runtime/controllers/progress.ts +52 -0
  106. package/runtime/controllers/visual-interactions.ts +75 -0
  107. package/runtime/controllers/world-map.ts +154 -0
  108. package/styles/critical.css +696 -66
  109. package/styles/lumen.css +101 -0
@@ -1,36 +1,77 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import {
5
+ type LumenSkeletonAnimation,
6
+ type LumenSkeletonShape,
7
+ type LumenSkeletonSize,
8
+ resolveLumenAstroProps,
9
+ resolveLumenLoadingSemantics,
10
+ resolveLumenSkeletonAnimation,
11
+ resolveLumenSkeletonShape,
12
+ resolveLumenSkeletonSize
13
+ } from '@santi020k/lumen-core'
5
14
 
6
15
  interface Props extends HTMLAttributes<'div'> {
16
+ /** `shimmer` (default) keeps the existing sweep; `pulse` fades; `none` is static. */
17
+ animation?: LumenSkeletonAnimation
7
18
  class?: string | null
8
19
  className?: string
20
+ /** Pixels for numbers, or a CSS length such as `0.75rem` or `60%`. */
21
+ height?: LumenSkeletonSize
22
+ shape?: LumenSkeletonShape
23
+ width?: LumenSkeletonSize
9
24
  }
10
25
 
11
26
  const {
27
+ animation,
12
28
  'aria-hidden': ariaHidden,
13
29
  'aria-label': ariaLabel,
14
30
  'aria-labelledby': ariaLabelledby,
15
31
  class: classProp = '',
16
32
  className = '',
33
+ height,
17
34
  role,
35
+ shape,
36
+ style,
37
+ width,
18
38
  ...rest
19
39
  } = Astro.props
20
40
 
21
- const isAccessible = Boolean(ariaLabel || ariaLabelledby)
41
+ const semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })
42
+ const resolvedShape = resolveLumenSkeletonShape(shape)
43
+ const resolvedAnimation = resolveLumenSkeletonAnimation(animation)
44
+ const sizeVariables: Record<string, string> = {}
45
+ const resolvedHeight = resolveLumenSkeletonSize(height)
46
+ const resolvedWidth = resolveLumenSkeletonSize(width)
47
+
48
+ if (resolvedHeight) sizeVariables['--ui-skeleton-height'] = resolvedHeight
49
+
50
+ if (resolvedWidth) sizeVariables['--ui-skeleton-width'] = resolvedWidth
51
+
52
+ // Consumer styles follow the size variables so explicit declarations still win.
53
+ const skeletonStyle = typeof style === 'object' && style !== null ?
54
+ { ...sizeVariables, ...style } :
55
+ [
56
+ ...Object.entries(sizeVariables).map(([name, value]) => `${name}:${value}`),
57
+ typeof style === 'string' ? style : ''
58
+ ].filter(Boolean).join(';')
22
59
 
23
60
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
24
- 'ui-skeleton'
61
+ 'ui-skeleton',
62
+ resolvedShape === 'rectangle' ? '' : `ui-skeleton--${resolvedShape}`,
63
+ resolvedAnimation === 'pulse' ? 'ui-skeleton--pulse' : '',
64
+ resolvedAnimation === 'none' ? 'ui-skeleton--static' : ''
25
65
  ], classProp, className)
26
66
  ---
27
67
 
28
68
  <div
29
- aria-hidden={ariaHidden ?? (isAccessible ? undefined : 'true')}
69
+ aria-hidden={ariaHidden ?? semantics.ariaHidden}
30
70
  aria-label={ariaLabel}
31
71
  aria-labelledby={ariaLabelledby}
32
72
  class:list={classList}
33
- role={role ?? (isAccessible ? 'status' : undefined)}
73
+ role={role ?? semantics.role}
74
+ style={skeletonStyle || undefined}
34
75
  {...passthrough}
35
76
  >
36
77
  <slot />
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { resolveLumenCitationHref } from '@santi020k/lumen-core'
5
+
6
+ import Link from './Link.astro'
7
+
8
+ interface Props extends Omit<HTMLAttributes<'a'>, 'href'> { href: string, label: string }
9
+
10
+ const { class: className, href, label, ...rest } = Astro.props
11
+ const safeHref = resolveLumenCitationHref(href)
12
+ ---
13
+
14
+ {safeHref ?
15
+ <Link {...rest} class:list={['ui-source-citation', className]} href={safeHref} rel="noopener noreferrer">{label}<slot /></Link> :
16
+ (
17
+ <span class:list={['ui-source-citation', className]}>{label}<slot /></span>
18
+ )}
@@ -57,16 +57,15 @@ const endpoint = geometry.points[geometry.points.length - 1]
57
57
  ))
58
58
  }
59
59
  <path class="ui-sparkline__line" d={geometry.path} />
60
- {
61
- showEndpoint && endpoint && (
62
- <circle
63
- class="ui-sparkline__endpoint"
64
- cx={endpoint.xCoordinate}
65
- cy={endpoint.yCoordinate}
66
- r="2.5"
67
- />
68
- )
69
- }
70
60
  </svg>
61
+ {
62
+ showEndpoint && endpoint && (
63
+ <span
64
+ aria-hidden="true"
65
+ class="ui-sparkline__endpoint"
66
+ style={{ left: `${endpoint.xCoordinate / 120 * 100}%`, top: `${endpoint.yCoordinate / 40 * 100}%` }}
67
+ />
68
+ )
69
+ }
71
70
  <span class="ui-sr-only">{label}</span>
72
71
  </span>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import { resolveLumenAstroProps, resolveLumenLoadingSemantics } from '@santi020k/lumen-core'
5
5
 
6
6
  interface Props extends HTMLAttributes<'span'> {
7
7
  class?: string | null
@@ -18,7 +18,7 @@ const {
18
18
  ...rest
19
19
  } = Astro.props
20
20
 
21
- const isAccessible = Boolean(ariaLabel || ariaLabelledby)
21
+ const semantics = resolveLumenLoadingSemantics({ ariaHidden, ariaLabel, ariaLabelledby, role })
22
22
 
23
23
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
24
24
  'ui-spinner'
@@ -26,11 +26,11 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
26
26
  ---
27
27
 
28
28
  <span
29
- aria-hidden={ariaHidden ?? (isAccessible ? undefined : 'true')}
29
+ aria-hidden={ariaHidden ?? semantics.ariaHidden}
30
30
  aria-label={ariaLabel}
31
31
  aria-labelledby={ariaLabelledby}
32
32
  class:list={classList}
33
- role={role ?? (isAccessible ? 'status' : undefined)}
33
+ role={role ?? semantics.role}
34
34
  {...passthrough}
35
35
  >
36
36
  <slot />
@@ -9,7 +9,7 @@ interface Props extends HTMLAttributes<'div'> {
9
9
  class?: string | null
10
10
  className?: string
11
11
  direction?: 'horizontal' | 'vertical'
12
- gap?: 'lg' | 'md' | 'none' | 'sm' | 'xl'
12
+ gap?: '2xl' | '3xl' | 'group' | 'lg' | 'md' | 'none' | 'related' | 'section' | 'sm' | 'xl' | 'xs'
13
13
  justify?: 'between' | 'center' | 'end' | 'start'
14
14
  wrap?: boolean
15
15
  }
@@ -20,7 +20,7 @@ const {
20
20
  class: classProp = '',
21
21
  className = '',
22
22
  direction = 'vertical',
23
- gap = 'md',
23
+ gap = 'group',
24
24
  justify = 'start',
25
25
  wrap = false,
26
26
  ...rest
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import type { LumenStreamStatus } from '@santi020k/lumen-core'
5
+
6
+ import Message from './Message.astro'
7
+ import Prose from './Prose.astro'
8
+ import Stack from './Stack.astro'
9
+
10
+ interface Props extends HTMLAttributes<'article'> {
11
+ label?: string
12
+ status?: LumenStreamStatus
13
+ statusLabel: string
14
+ }
15
+
16
+ const { class: className, label = 'Assistant', status = 'idle', statusLabel, ...rest } = Astro.props
17
+ ---
18
+
19
+ <Message {...rest} aria-label={label} class:list={['ui-stream-message', className]} data-status={status} from="assistant">
20
+ <Prose aria-busy={status === 'streaming'} aria-live="off" data-ui-stream-content><slot /></Prose>
21
+ <Stack direction="horizontal" gap="related" wrap>
22
+ <span aria-live="polite" role="status">{statusLabel}</span>
23
+ <slot name="actions" />
24
+ </Stack>
25
+ <slot name="sources" />
26
+ </Message>
@@ -7,6 +7,7 @@ interface Props extends HTMLAttributes<'div'> {
7
7
  class?: string | null
8
8
  className?: string
9
9
  glass?: boolean | 'subtle' | 'strong'
10
+ indicator?: boolean
10
11
  initialValue?: string
11
12
  storageKey?: string
12
13
  }
@@ -15,6 +16,7 @@ const {
15
16
  class: classProp = '',
16
17
  className = '',
17
18
  glass = false,
19
+ indicator = false,
18
20
  initialValue,
19
21
  storageKey,
20
22
  ...rest
@@ -29,6 +31,7 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
29
31
  ---
30
32
 
31
33
  <div
34
+ data-ui-tab-indicator={indicator || undefined}
32
35
  data-initial-value={initialValue}
33
36
  data-storage-key={storageKey}
34
37
  data-ui-tabs
@@ -0,0 +1,17 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import type { LumenToolStatus } from '@santi020k/lumen-core'
5
+
6
+ import Collapsible from './Collapsible.astro'
7
+ import Stack from './Stack.astro'
8
+
9
+ interface Props extends HTMLAttributes<'details'> { label: string, status?: LumenToolStatus, statusLabel: string }
10
+
11
+ const { class: className, label, status = 'queued', statusLabel, ...rest } = Astro.props
12
+ ---
13
+
14
+ <Collapsible {...rest} class:list={['ui-tool-activity', className]} data-status={status}>
15
+ <summary>{label}</summary>
16
+ <Stack gap="related"><span role="status">{statusLabel}</span><slot /></Stack>
17
+ </Collapsible>
@@ -59,6 +59,7 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
59
59
  <div class="ui-tree-select__tree" role="tree">
60
60
  {rows.map(row => (
61
61
  <button
62
+ aria-level={row.depth + 1}
62
63
  class="ui-tree-select__option"
63
64
  data-value={row.value}
64
65
  role="treeitem"
@@ -7,14 +7,17 @@ interface Props extends HTMLAttributes<'div'> {
7
7
  class?: string | null
8
8
  className?: string
9
9
  glass?: boolean | 'subtle' | 'strong'
10
+ itemSize?: number | string
11
+ overscan?: number | string
12
+ mode?: 'data' | 'mounted'
10
13
  }
11
14
 
12
- const { class: classProp = '', className = '', glass = false, ...rest } = Astro.props
15
+ const { class: classProp = '', className = '', glass = false, itemSize = 44, overscan = 4, mode = 'mounted', tabindex = 0, ...rest } = Astro.props
13
16
 
14
17
  const { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-virtual-list', glass && 'ui-virtual-list--glass', glass === 'subtle' && 'ui-glass-subtle', glass === 'strong' && 'ui-glass-strong'
15
18
  ], classProp, className)
16
19
  ---
17
20
 
18
- <div class:list={classList} data-ui-virtual-list {...passthrough}>
21
+ <div class:list={classList} data-ui-virtual-list data-ui-virtual-list-mode={mode} data-ui-item-size={itemSize} data-ui-overscan={overscan} tabindex={tabindex} {...passthrough}>
19
22
  <slot />
20
23
  </div>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { type LumenVisualEffectVariant, normalizeLumenEffectIntensity, resolveLumenAstroProps } from '@santi020k/lumen-core'
5
+
6
+ interface Props extends HTMLAttributes<'div'> {
7
+ animated?: boolean
8
+ class?: string | null
9
+ className?: string
10
+ intensity?: number
11
+ variant?: LumenVisualEffectVariant
12
+ }
13
+
14
+ const { animated = false, class: classProp, className, intensity = 0.5, style, variant = 'mesh', ...rest } = Astro.props
15
+ const { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-visual-effect'], classProp, className)
16
+ const effectStyle = `${typeof style === 'string' ? style : ''};--ui-effect-intensity:${normalizeLumenEffectIntensity(intensity)}`
17
+ ---
18
+
19
+ <div class:list={classList} data-ui-visual-effect={variant} data-ui-effect-animated={String(animated)} style={effectStyle} {...passthrough}>
20
+ <slot />
21
+ </div>
@@ -0,0 +1,27 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { createLumenWaterfallGeometry, type LumenChartLabels, type LumenWaterfallDatum, resolveLumenChartLabels } from '@santi020k/lumen-core'
5
+
6
+ import IntervalChart from '../internal/IntervalChart.astro'
7
+
8
+ interface Props extends HTMLAttributes<'figure'> {
9
+ caption?: string
10
+ class?: string | null
11
+ className?: string
12
+ data: readonly LumenWaterfallDatum[]
13
+ description?: string
14
+ formatValue?: (value: number) => string
15
+ heading?: string
16
+ labels?: Partial<LumenChartLabels>
17
+ presentation?: 'bare' | 'default'
18
+ showTable?: boolean
19
+ summary?: string
20
+ valueLabel?: string
21
+ }
22
+
23
+ const { data, class: classProp, className, formatValue = String, labels, valueLabel = resolveLumenChartLabels(labels).value, ...rest } = Astro.props
24
+ const model = createLumenWaterfallGeometry(data, { formatValue })
25
+ ---
26
+
27
+ <IntervalChart {...rest} class:list={['ui-waterfall-chart', classProp, className]} {model} {labels} {formatValue} formatBoundary={formatValue} {valueLabel} />
@@ -0,0 +1,213 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import {
5
+ findLumenWorldMapCountry,
6
+ LUMEN_WORLD_MAP_VIEW_BOX,
7
+ type LumenWorldMapCountryGeometry,
8
+ type LumenWorldMapMarker,
9
+ type LumenWorldMapVariant,
10
+ normalizeLumenWorldMapCountries,
11
+ normalizeLumenWorldMapHighlightedCountries,
12
+ normalizeLumenWorldMapMarkers,
13
+ projectLumenWorldMapCoordinate,
14
+ resolveLumenAstroProps,
15
+ resolveLumenWorldMapCountryLabel
16
+ } from '@santi020k/lumen-core'
17
+ import { type LumenWorldMapZoomLabels, lumenWorldMapZoomLabels } from '@santi020k/lumen-core/world-map-zoom'
18
+
19
+ import Button from './Button.astro'
20
+
21
+ interface Props extends HTMLAttributes<'figure'> {
22
+ initialView?: 'world' | 'highlighted'
23
+ zoomable?: boolean
24
+ zoomLabels?: Partial<LumenWorldMapZoomLabels>
25
+ animated?: boolean
26
+ caption?: string
27
+ class?: string | null
28
+ className?: string
29
+ countries: readonly LumenWorldMapCountryGeometry[]
30
+ description?: string
31
+ heading?: string
32
+ highlightedCountries?: readonly string[]
33
+ interactive?: boolean
34
+ label: string
35
+ labels?: Readonly<Record<string, string>>
36
+ listLabel?: string
37
+ markers?: readonly LumenWorldMapMarker[]
38
+ selectedCountry?: string
39
+ variant?: LumenWorldMapVariant
40
+ }
41
+
42
+ const {
43
+ animated = true,
44
+ caption,
45
+ class: classProp = '',
46
+ className = '',
47
+ countries: inputCountries,
48
+ description,
49
+ heading,
50
+ highlightedCountries,
51
+ interactive = true,
52
+ label,
53
+ labels,
54
+ listLabel = 'Choose a country',
55
+ markers,
56
+ selectedCountry,
57
+ variant = 'dotted',
58
+ initialView = 'world',
59
+ zoomable = true,
60
+ zoomLabels,
61
+ ...rest
62
+ } = Astro.props
63
+
64
+ const countries = normalizeLumenWorldMapCountries(inputCountries)
65
+ const highlightedIds = normalizeLumenWorldMapHighlightedCountries(highlightedCountries, countries)
66
+ const highlightedSet = new Set(highlightedIds)
67
+ const normalizedMarkers = normalizeLumenWorldMapMarkers(markers)
68
+ const selected = findLumenWorldMapCountry(countries, selectedCountry)
69
+ const zoomText = { ...lumenWorldMapZoomLabels, ...zoomLabels }
70
+ const instanceId = `ui-world-map-${crypto.randomUUID()}`
71
+ const neutralPatternId = `${instanceId}-dot-neutral`
72
+ const highlightPatternId = `${instanceId}-dot-highlight`
73
+ const { height: viewBoxHeight, width: viewBoxWidth } = LUMEN_WORLD_MAP_VIEW_BOX
74
+ const { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-world-map'], classProp, className)
75
+ ---
76
+
77
+ <figure
78
+ class:list={classList}
79
+ data-animated={String(animated)}
80
+ data-interactive={String(interactive)}
81
+ data-ui-world-map
82
+ data-initial-view={initialView}
83
+ data-variant={variant}
84
+ id={instanceId}
85
+ {...passthrough}
86
+ >
87
+ {
88
+ (heading || description) && (
89
+ <header class="ui-world-map__heading">
90
+ {heading && <h3>{heading}</h3>}
91
+ {description && <p>{description}</p>}
92
+ </header>
93
+ )
94
+ }
95
+ {zoomable && (
96
+ <div class="ui-world-map__zoom-controls">
97
+ <Button aria-label={zoomText.zoomOut} data-ui-world-map-zoom="out" disabled size="icon" variant="outline">−</Button>
98
+ <output aria-label={zoomText.level} aria-live="polite" data-ui-world-map-zoom-status>100%</output>
99
+ <Button aria-label={zoomText.zoomIn} data-ui-world-map-zoom="in" disabled size="icon" variant="outline">+</Button>
100
+ <Button data-ui-world-map-zoom="fit" disabled size="sm" variant="outline">{zoomText.fit}</Button>
101
+ <Button data-ui-world-map-zoom="reset" disabled size="sm" variant="outline">{zoomText.reset}</Button>
102
+ </div>
103
+ )}
104
+ <div class="ui-world-map__frame">
105
+ <div
106
+ aria-label={`${zoomText.viewport}: ${label}`}
107
+ class="ui-world-map__viewport"
108
+ data-ui-world-map-viewport
109
+ role="region"
110
+ tabindex={zoomable ? 0 : undefined}
111
+ >
112
+ <svg
113
+ aria-label={label}
114
+ class="ui-world-map__plot"
115
+ role="img"
116
+ viewBox={`0 0 ${viewBoxWidth} ${viewBoxHeight}`}
117
+ xmlns="http://www.w3.org/2000/svg"
118
+ >
119
+ {
120
+ variant === 'dotted' && (
121
+ <defs>
122
+ <pattern height="4" id={neutralPatternId} patternUnits="userSpaceOnUse" width="4">
123
+ <circle class="ui-world-map__dot ui-world-map__dot--neutral" cx="2" cy="2" r="1.1" />
124
+ </pattern>
125
+ <pattern height="4" id={highlightPatternId} patternUnits="userSpaceOnUse" width="4">
126
+ <circle class="ui-world-map__dot ui-world-map__dot--highlight" cx="2" cy="2" r="1.1" />
127
+ </pattern>
128
+ </defs>
129
+ )
130
+ }
131
+ <g aria-hidden="true" class="ui-world-map__countries">
132
+ {countries.map(country => {
133
+ const isHighlighted = highlightedSet.has(country.id)
134
+ const isSelected = selected?.id === country.id
135
+ const countryLabel = resolveLumenWorldMapCountryLabel(country, labels)
136
+
137
+ return (
138
+ <path
139
+ class:list={[
140
+ 'ui-world-map__country',
141
+ isHighlighted && 'ui-world-map__country--highlighted',
142
+ isSelected && 'ui-world-map__country--selected'
143
+ ]}
144
+ d={country.path}
145
+ data-label={countryLabel}
146
+ data-label-x={country.labelX}
147
+ data-label-y={country.labelY}
148
+ data-ui-world-map-country={country.id}
149
+ fill={
150
+ variant === 'dotted' ?
151
+ `url(#${isHighlighted ? highlightPatternId : neutralPatternId})` :
152
+ undefined
153
+ }
154
+ fill-rule="evenodd"
155
+ >
156
+ <title>{countryLabel}</title>
157
+ </path>
158
+ )
159
+ })}
160
+ </g>
161
+ {
162
+ normalizedMarkers.length > 0 && (
163
+ <g aria-hidden="true" class="ui-world-map__markers">
164
+ {normalizedMarkers.map(marker => {
165
+ const { x, y } = projectLumenWorldMapCoordinate(marker.longitude, marker.latitude)
166
+
167
+ return (
168
+ <circle class="ui-world-map__marker" cx={x} cy={y} r="4">
169
+ <title>{marker.label}</title>
170
+ </circle>
171
+ )
172
+ })}
173
+ </g>
174
+ )
175
+ }
176
+ <path aria-hidden="true" class="ui-world-map__selection" d={selected?.path ?? ''} />
177
+ </svg>
178
+ </div>
179
+ <span class="ui-world-map__inspection" data-ui-world-map-inspection hidden />
180
+ </div>
181
+ {
182
+ interactive && (
183
+ <label class="ui-world-map__select-label">
184
+ <span>{listLabel}</span>
185
+ <select class="ui-select ui-world-map__select" data-ui-world-map-select disabled>
186
+ <option value="">{listLabel}</option>
187
+ {countries.map(country => (
188
+ <option selected={selected?.id === country.id} value={country.id}>
189
+ {resolveLumenWorldMapCountryLabel(country, labels)}
190
+ </option>
191
+ ))}
192
+ </select>
193
+ </label>
194
+ )
195
+ }
196
+ {
197
+ highlightedIds.length > 0 && (
198
+ <ul aria-label="Highlighted countries" class="ui-world-map__highlights" data-ui-world-map-highlights>
199
+ {highlightedIds.map(id => {
200
+ const country = findLumenWorldMapCountry(countries, id)
201
+
202
+ return country && <li>{resolveLumenWorldMapCountryLabel(country, labels)}</li>
203
+ })}
204
+ </ul>
205
+ )
206
+ }
207
+ {normalizedMarkers.length > 0 && (
208
+ <ul aria-label="Map markers" class="ui-sr-only">
209
+ {normalizedMarkers.map(marker => <li>{marker.label}</li>)}
210
+ </ul>
211
+ )}
212
+ {caption && <figcaption>{caption}</figcaption>}
213
+ </figure>
package/forms.ts CHANGED
@@ -10,11 +10,24 @@ export interface AstroActionErrorLike {
10
10
 
11
11
  export type LumenControlIdMap = Readonly<Record<string, string>>
12
12
 
13
- const isActionErrorLike = (error: unknown): error is AstroActionErrorLike => (
14
- typeof error === 'object' &&
15
- error !== null
13
+ const isFieldMessages = (value: unknown): boolean => (
14
+ value === null || value === undefined || typeof value === 'string' ||
15
+ (Array.isArray(value) && value.every((message: unknown) => typeof message === 'string'))
16
16
  )
17
17
 
18
+ const isActionFields = (value: unknown): value is NonNullable<AstroActionErrorLike['fields']> => (
19
+ typeof value === 'object' && value !== null && !Array.isArray(value) &&
20
+ Object.values(value).every(isFieldMessages)
21
+ )
22
+
23
+ const isActionErrorLike = (error: unknown): error is AstroActionErrorLike => {
24
+ if (typeof error !== 'object' || error === null) return false
25
+
26
+ if ('message' in error && error.message !== undefined && typeof error.message !== 'string') return false
27
+
28
+ return !('fields' in error) || error.fields === undefined || isActionFields(error.fields)
29
+ }
30
+
18
31
  export const normalizeAstroActionErrors = (
19
32
  error: unknown,
20
33
  controlIds: LumenControlIdMap = {}
@@ -35,7 +48,7 @@ export const normalizeAstroActionErrors = (
35
48
  return {
36
49
  fields: normalized.fields.map(fieldError => ({
37
50
  ...fieldError,
38
- ...(controlIds[fieldError.name] ?
51
+ ...(Object.hasOwn(controlIds, fieldError.name) && controlIds[fieldError.name] ?
39
52
  {
40
53
  controlId: controlIds[fieldError.name]
41
54
  } :