@santi020k/lumen-astro 3.0.1 → 4.0.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 (103) hide show
  1. package/README.md +403 -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/LollipopChart.astro +8 -0
  46. package/components/MediaFilmstrip.astro +19 -0
  47. package/components/MediaThumbnail.astro +29 -0
  48. package/components/MediaViewport.astro +38 -0
  49. package/components/Mentions.astro +2 -1
  50. package/components/MessageScroller.astro +6 -0
  51. package/components/MotionGroup.astro +19 -0
  52. package/components/NativeSelect.astro +2 -2
  53. package/components/Pagination.astro +2 -1
  54. package/components/PasswordField.astro +1 -1
  55. package/components/PhoneInput.astro +60 -9
  56. package/components/PhoneNumber.astro +19 -0
  57. package/components/PieChart.astro +26 -3
  58. package/components/PromptComposer.astro +36 -0
  59. package/components/RangeChart.astro +38 -8
  60. package/components/ScatterChart.astro +62 -14
  61. package/components/Segmented.astro +5 -5
  62. package/components/Select.astro +8 -8
  63. package/components/SourceCitation.astro +18 -0
  64. package/components/Sparkline.astro +9 -10
  65. package/components/Stack.astro +2 -2
  66. package/components/StreamMessage.astro +26 -0
  67. package/components/Tabs.astro +3 -0
  68. package/components/ToolActivity.astro +17 -0
  69. package/components/TreeSelect.astro +1 -0
  70. package/components/VirtualList.astro +5 -2
  71. package/components/VisualEffect.astro +21 -0
  72. package/components/WaterfallChart.astro +27 -0
  73. package/components/WorldMap.astro +213 -0
  74. package/forms.ts +17 -4
  75. package/index.ts +37 -0
  76. package/internal/ChartInspection.astro +27 -0
  77. package/internal/ComparisonChart.astro +69 -0
  78. package/internal/IntervalChart.astro +66 -0
  79. package/package.json +6 -4
  80. package/runtime/UIPrimitives.astro +670 -647
  81. package/runtime/chart-actions.ts +19 -0
  82. package/runtime/controllers/amount-field.ts +12 -0
  83. package/runtime/controllers/attachments.ts +13 -0
  84. package/runtime/controllers/chart-activation.ts +33 -0
  85. package/runtime/controllers/charts.ts +33 -0
  86. package/runtime/controllers/combobox.ts +39 -0
  87. package/runtime/controllers/comparison-reset.ts +20 -0
  88. package/runtime/controllers/data-table.ts +142 -0
  89. package/runtime/controllers/dialogs.ts +32 -32
  90. package/runtime/controllers/document-navigation.ts +54 -22
  91. package/runtime/controllers/file-upload.ts +89 -0
  92. package/runtime/controllers/image-comparison.ts +53 -0
  93. package/runtime/controllers/media-selection.ts +20 -0
  94. package/runtime/controllers/media-viewport.ts +54 -0
  95. package/runtime/controllers/media-workspace.ts +8 -0
  96. package/runtime/controllers/message-scroller.ts +13 -0
  97. package/runtime/controllers/motion.ts +15 -16
  98. package/runtime/controllers/optional-media.ts +29 -0
  99. package/runtime/controllers/phone-input.ts +128 -39
  100. package/runtime/controllers/visual-interactions.ts +75 -0
  101. package/runtime/controllers/world-map.ts +154 -0
  102. package/styles/critical.css +634 -61
  103. package/styles/lumen.css +101 -0
@@ -4,6 +4,7 @@ import type { HTMLAttributes } from 'astro/types'
4
4
  import {
5
5
  getLumenPhoneCountries,
6
6
  getLumenPhoneCountry,
7
+ type LumenControlVisualSize,
7
8
  type LumenPhoneCountry,
8
9
  type LumenPhoneCountryOptions,
9
10
  type LumenPhoneNumber,
@@ -11,6 +12,8 @@ import {
11
12
  resolveLumenPhoneNumber
12
13
  } from '@santi020k/lumen-core'
13
14
 
15
+ import CountryFlag from './CountryFlag.astro'
16
+
14
17
  interface CountryOption {
15
18
  disabled?: boolean
16
19
  label: string
@@ -23,7 +26,7 @@ interface NormalizedCountryOption extends CountryOption {
23
26
  regionCode?: string
24
27
  }
25
28
 
26
- interface Props extends Omit<HTMLAttributes<'div'>, 'size'> {
29
+ interface Props extends HTMLAttributes<'div'> {
27
30
  class?: string | null
28
31
  className?: string
29
32
  countries?: Country[]
@@ -31,17 +34,30 @@ interface Props extends Omit<HTMLAttributes<'div'>, 'size'> {
31
34
  countryLabel?: string
32
35
  countryName?: string
33
36
  countryValue?: string
37
+ disabled?: boolean
38
+ readOnly?: boolean
39
+ required?: boolean
40
+ errorMessage?: string
41
+ showValidationError?: boolean
42
+ inputProps?: Omit<HTMLAttributes<'input'>, 'value' | 'name' | 'type'>
34
43
  invalidNumberMessage?: string
35
44
  locale?: LumenPhoneCountryOptions['locale']
36
45
  name?: string
37
46
  placeholder?: string
38
- size?: 'default' | 'lg' | 'sm'
47
+ visualSize?: LumenControlVisualSize
39
48
  value?: string
40
49
  }
41
50
 
42
51
  const {
43
52
  class: classProp = '',
44
53
  className = '',
54
+ id,
55
+ disabled = false,
56
+ readOnly = false,
57
+ required = false,
58
+ errorMessage,
59
+ showValidationError = true,
60
+ inputProps = {},
45
61
  countries = [],
46
62
  countryOptions,
47
63
  countryLabel = 'Country code',
@@ -51,18 +67,20 @@ const {
51
67
  locale,
52
68
  name = 'phone',
53
69
  placeholder = 'Phone number',
54
- size = 'default',
70
+ visualSize = 'default',
55
71
  value,
56
72
  ...rest
57
73
  } = Astro.props
58
74
 
75
+ const isDisabled = disabled || Boolean(inputProps.disabled)
76
+ const isReadOnly = readOnly || Boolean(inputProps.readonly)
59
77
  const phoneOptions = locale === undefined ? {} : { locale }
60
78
  const metadataCountries = countryOptions ?? getLumenPhoneCountries(phoneOptions)
61
79
  const usesMetadata = countries.length === 0
62
80
 
63
81
  const normalizedCountries: NormalizedCountryOption[] = usesMetadata ?
64
82
  metadataCountries.map(country => ({
65
- label: country.pickerLabel,
83
+ label: `${country.displayName} (${country.callingCode})`,
66
84
  regionCode: country.regionCode,
67
85
  value: country.regionCode
68
86
  })) :
@@ -95,6 +113,9 @@ const normalizePhoneValue = (detectedValue: LumenPhoneNumber | null): LumenPhone
95
113
  const phoneValue = normalizePhoneValue(detectedPhoneValue)
96
114
  const selectedCountryValue = usesMetadata ? phoneValue?.country.regionCode ?? selectedCountry?.regionCode : countryValue
97
115
  const renderedValue = usesMetadata ? phoneValue?.nationalNumber ?? value : value
116
+ const inputId = id ?? inputProps.id ?? `phone-${crypto.randomUUID()}`
117
+ const errorId = `phone-error-${crypto.randomUUID()}`
118
+ const effectiveError = errorMessage || (showValidationError && phoneValue && phoneValue.nationalNumber.length > 0 && !phoneValue.isValid ? invalidNumberMessage : '')
98
119
 
99
120
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
100
121
  'ui-phone-input',
@@ -104,21 +125,39 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
104
125
 
105
126
  <div
106
127
  class:list={classList}
128
+ data-disabled={isDisabled || undefined}
129
+ data-readonly={isReadOnly || undefined}
130
+ data-size={visualSize}
131
+ data-slot="phone-input"
132
+ data-error-message={errorMessage}
133
+ data-error-id={errorId}
134
+ data-show-validation-error={String(showValidationError)}
135
+ data-invalid={effectiveError ? 'true' : undefined}
107
136
  data-e164={phoneValue?.e164}
108
137
  data-invalid-number-message={invalidNumberMessage}
109
138
  data-ui-phone-input={usesMetadata ? '' : undefined}
110
139
  data-valid={phoneValue ? String(phoneValue.isValid) : undefined}
111
140
  {...passthrough}
112
141
  >
142
+ <span class="ui-phone-input__picker" data-slot="phone-country">
143
+ {usesMetadata && (
144
+ <span aria-hidden="true" class="ui-phone-input__selection">
145
+ <CountryFlag decorative regionCode={phoneValue?.country.regionCode ?? selectedCountry?.regionCode ?? countryValue} />
146
+ <span data-ui-phone-code>{phoneValue?.country.callingCode ?? selectedCountry?.callingCode}</span>
147
+ <span class="ui-phone-input__chevron" />
148
+ </span>
149
+ )}
113
150
  <select
114
151
  aria-label={countryLabel}
115
152
  class:list={[
116
153
  'ui-select',
117
154
  'ui-phone-input__country',
118
- size === 'sm' && 'ui-select--sm',
119
- size === 'lg' && 'ui-select--lg'
155
+ visualSize === 'sm' && 'ui-select--sm',
156
+ visualSize === 'lg' && 'ui-select--lg'
120
157
  ]}
158
+ disabled={isDisabled || isReadOnly || normalizedCountries.length === 0}
121
159
  name={countryName}
160
+ form={inputProps.form}
122
161
  >
123
162
  {normalizedCountries.map(country => (
124
163
  <option
@@ -130,13 +169,22 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
130
169
  </option>
131
170
  ))}
132
171
  </select>
172
+ </span>
133
173
  <input
134
- autocomplete="tel"
174
+ {...inputProps}
175
+ id={inputId}
176
+ autocomplete={inputProps.autocomplete ?? 'tel-national'}
177
+ disabled={disabled || inputProps.disabled}
178
+ readonly={readOnly || inputProps.readonly}
179
+ required={required || inputProps.required}
180
+ aria-invalid={effectiveError ? 'true' : inputProps['aria-invalid']}
181
+ aria-errormessage={effectiveError ? errorId : inputProps['aria-errormessage']}
182
+ aria-describedby={[inputProps['aria-describedby'], effectiveError ? errorId : undefined].filter(Boolean).join(' ') || undefined}
135
183
  class:list={[
136
184
  'ui-input',
137
185
  'ui-phone-input__number',
138
- size === 'sm' && 'ui-input--sm',
139
- size === 'lg' && 'ui-input--lg'
186
+ visualSize === 'sm' && 'ui-input--sm',
187
+ visualSize === 'lg' && 'ui-input--lg'
140
188
  ]}
141
189
  inputmode="tel"
142
190
  name={name}
@@ -144,4 +192,7 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
144
192
  type="tel"
145
193
  value={renderedValue}
146
194
  />
195
+ {isReadOnly && !isDisabled && <input name={countryName} form={inputProps.form} type="hidden" value={selectedCountryValue} />}
147
196
  </div>
197
+
198
+ <span class="ui-phone-input__error" id={errorId} role="alert" hidden={!effectiveError}>{effectiveError}</span>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { formatLumenPhoneNumber, type LumenPhoneNumber } from '@santi020k/lumen-core'
5
+
6
+ import CountryFlag from './CountryFlag.astro'
7
+
8
+ interface Props extends HTMLAttributes<'span'> {
9
+ value: LumenPhoneNumber
10
+ link?: boolean
11
+ }
12
+
13
+ const { value, link = false, class: className, ...props } = Astro.props
14
+ const Tag = link && value.isValid && value.e164 ? 'a' : 'span'
15
+ ---
16
+ <Tag {...props} class:list={['ui-phone-number', className]} href={link && value.isValid && value.e164 ? `tel:${value.e164}` : undefined} title={value.country.displayName}>
17
+ <CountryFlag decorative regionCode={value.country.regionCode} />
18
+ <span>{formatLumenPhoneNumber(value) || '—'}</span>
19
+ </Tag>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import {
4
+ import { createLumenChartDatumActivation,
5
5
  createLumenPieGeometry,
6
6
  formatLumenChartSummary,
7
7
  getLumenChartToneClassName,
@@ -12,7 +12,10 @@ import {
12
12
  type LumenPieChartVariant,
13
13
  resolveLumenChartLabels } from '@santi020k/lumen-core'
14
14
 
15
+ import { createChartDatumAction } from '../runtime/chart-actions'
16
+
15
17
  import Chart from './Chart.astro'
18
+ import ChartDatumActions from './ChartDatumActions.astro'
16
19
 
17
20
  interface Props extends HTMLAttributes<'figure'> {
18
21
  caption?: string
@@ -21,6 +24,7 @@ interface Props extends HTMLAttributes<'figure'> {
21
24
  class?: string | null
22
25
  className?: string
23
26
  description?: string
27
+ drilldown?: boolean
24
28
  glass?: boolean | 'subtle' | 'strong'
25
29
  heading?: string
26
30
  labels?: Partial<LumenChartLabels>
@@ -39,6 +43,7 @@ const {
39
43
  class: classProp = '',
40
44
  className = '',
41
45
  description,
46
+ drilldown = false,
42
47
  glass = false,
43
48
  heading,
44
49
  labels,
@@ -66,6 +71,21 @@ const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
66
71
  maximumFractionDigits: percentage < 0.01 ? 1 : 0,
67
72
  style: 'percent'
68
73
  }).format(percentage)
74
+
75
+ const datumActions = drilldown ?
76
+ geometry.slices.map((slice, index) => {
77
+ const datum = renderedSeries.data[index]
78
+
79
+ return datum ?
80
+ createChartDatumAction(
81
+ createLumenChartDatumActivation(series.id, datum),
82
+ `${slice.label} · ${series.label}: ${valueFormatter(slice.value)}`,
83
+ resolvedLabels
84
+ ) :
85
+ null
86
+ }) :
87
+ []
88
+
69
89
  ---
70
90
 
71
91
  <Chart
@@ -76,6 +96,7 @@ const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
76
96
  {heading}
77
97
  {summary}
78
98
  {...rest}
99
+ data-ui-chart-activation={drilldown || undefined}
79
100
  >
80
101
  {
81
102
  showLegend && hasData && (
@@ -98,9 +119,10 @@ const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
98
119
  >
99
120
  <g class="ui-pie-chart__slices">
100
121
  {
101
- geometry.slices.map(slice => (
122
+ geometry.slices.map((slice, index) => (
102
123
  <path
103
124
  class={getLumenChartToneClassName(slice.tone)}
125
+ data-ui-chart-datum={datumActions[index]?.serialized}
104
126
  d={slice.path}
105
127
  fill-rule="evenodd"
106
128
  >
@@ -130,7 +152,7 @@ const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
130
152
  showTable && hasData && (
131
153
  <details class="ui-chart__data">
132
154
  <summary>{resolvedLabels.viewData}</summary>
133
- <div>
155
+ <div role="group" tabindex="0" aria-label={resolvedLabels.chartData}>
134
156
  <table>
135
157
  <thead>
136
158
  <tr>
@@ -153,4 +175,5 @@ const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
153
175
  </details>
154
176
  )
155
177
  }
178
+ {drilldown && <ChartDatumActions actions={datumActions} label={resolvedLabels.exploreData} />}
156
179
  </Chart>
@@ -0,0 +1,36 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { canSubmitLumenPrompt, normalizeLumenPromptLimit } from '@santi020k/lumen-core'
5
+
6
+ import Button from './Button.astro'
7
+ import Form from './Form.astro'
8
+ import Label from './Label.astro'
9
+ import Stack from './Stack.astro'
10
+ import Textarea from './Textarea.astro'
11
+
12
+ interface Props extends HTMLAttributes<'form'> {
13
+ disabled?: boolean
14
+ label?: string
15
+ maxLength?: number
16
+ pending?: boolean
17
+ sendLabel?: string
18
+ stopLabel?: string
19
+ submitOnEnter?: boolean
20
+ value?: string
21
+ }
22
+
23
+ const { class: className, disabled = false, id = `prompt-${crypto.randomUUID()}`, label = 'Message', maxLength = 4000, pending = false, sendLabel = 'Send', stopLabel = 'Stop', submitOnEnter = false, value = '', ...rest } = Astro.props
24
+ const limit = normalizeLumenPromptLimit(maxLength)
25
+ ---
26
+
27
+ <Form {...rest} class:list={['ui-prompt-composer', className]} data-ui-prompt-composer data-disabled={String(disabled)} data-pending={String(pending)} data-submit-on-enter={String(submitOnEnter)} enhance={false} method="post" status={pending ? 'submitting' : 'idle'}>
28
+ <Label for={id}>{label}</Label>
29
+ <Textarea id={id} data-ui-prompt-input disabled={disabled} maxlength={limit} name="prompt" required rows={3}>{value}</Textarea>
30
+ <Stack direction="horizontal" gap="related" wrap>
31
+ <slot name="actions" />
32
+ <Button data-ui-prompt-send disabled={disabled || pending || (!rest.action && !canSubmitLumenPrompt(value, limit))} type="submit">{sendLabel}</Button>
33
+ <Button data-ui-prompt-stop disabled={disabled} hidden={!pending} type="button" variant="secondary">{stopLabel}</Button>
34
+ </Stack>
35
+ <slot />
36
+ </Form>
@@ -1,9 +1,12 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { createLumenRangeGeometry, getLumenChartTicks, getLumenChartToneClassName, type LumenChartLabels, type LumenChartTone, type LumenRangeDatum, resolveLumenChartLabels, scaleLumenChartValue } from '@santi020k/lumen-core'
4
+ import { createLumenRangeDatumActivation, createLumenRangeGeometry, getLumenChartCategoryTicks, getLumenChartTicks, getLumenChartToneClassName, type LumenChartLabels, type LumenChartTone, type LumenRangeDatum, resolveLumenChartLabels, scaleLumenChartValue } from '@santi020k/lumen-core'
5
+
6
+ import { createChartDatumAction } from '../runtime/chart-actions'
5
7
 
6
8
  import Chart from './Chart.astro'
9
+ import ChartDatumActions from './ChartDatumActions.astro'
7
10
 
8
11
  interface Props extends HTMLAttributes<'figure'> {
9
12
  caption?: string
@@ -11,6 +14,7 @@ interface Props extends HTMLAttributes<'figure'> {
11
14
  className?: string
12
15
  data: readonly LumenRangeDatum[]
13
16
  description?: string
17
+ drilldown?: boolean
14
18
  formatValue?: (value: number) => string
15
19
  heading?: string
16
20
  labels?: Partial<LumenChartLabels>
@@ -19,7 +23,7 @@ interface Props extends HTMLAttributes<'figure'> {
19
23
  tone?: LumenChartTone
20
24
  }
21
25
 
22
- const { caption, class: classProp = '', className = '', data, description, formatValue = String, heading, labels, showTable = true, summary: summaryProp, tone = 'series-1', ...rest } = Astro.props
26
+ const { caption, class: classProp = '', className = '', data, description, drilldown = false, formatValue = String, heading, labels, showTable = true, summary: summaryProp, tone = 'series-1', ...rest } = Astro.props
23
27
  const width = 640
24
28
  const height = 320
25
29
  const padding = 44
@@ -32,29 +36,55 @@ const summary = summaryProp ?? (
32
36
  resolvedLabels.formatRangeSummary(geometry.points.length)
33
37
  )
34
38
 
39
+ const categoryTicks = getLumenChartCategoryTicks(geometry.points.map(point => String(point.xLabel ?? point.x)), {
40
+ start: padding,
41
+ end: width - padding,
42
+ positions: geometry.points.map(point => point.xCoordinate)
43
+ })
44
+
35
45
  const ticks = getLumenChartTicks(geometry.domain)
46
+
47
+ const datumActions = drilldown ?
48
+ geometry.points.map(point => createChartDatumAction(
49
+ createLumenRangeDatumActivation(point),
50
+ `${point.xLabel ?? point.x}: ${point.label ?? `${formatValue(point.low ?? 0)}–${formatValue(point.high ?? 0)}`}`,
51
+ resolvedLabels
52
+ )) :
53
+ []
54
+
36
55
  ---
37
56
 
38
- <Chart class:list={['ui-range-chart', getLumenChartToneClassName(tone), classProp, className]} {caption} {description} {heading} {summary} {...rest}>
57
+ <Chart class:list={['ui-range-chart', getLumenChartToneClassName(tone), classProp, className]} {caption} {description} {heading} {summary} {...rest} data-ui-chart-activation={drilldown || undefined}>
39
58
  {geometry.points.length === 0 && <p class="ui-chart__empty" role="status">{resolvedLabels.empty}</p>}
40
- <div class="ui-chart__plot" hidden={geometry.points.length === 0}><svg aria-hidden="true" viewBox={`0 0 ${width} ${height}`}><g class="
59
+ <div aria-label={resolvedLabels.chartData} class="ui-chart__plot" role="region" tabindex="0" hidden={geometry.points.length === 0}><svg aria-hidden="true" viewBox={`0 0 ${width} ${height}`}><g class="
41
60
  ui-chart__grid
42
61
  "
43
- >{ticks.map(tick => {
62
+ >{ticks.map(tick => {
44
63
  const y = scaleLumenChartValue(tick, geometry.domain, height - padding, padding)
45
64
 
46
65
  return <><line x1={padding} x2={width - padding} y1={y} y2={y}/><text x={padding - 8} y={y}>{formatValue(tick)}</text></>
47
- })}</g><path
66
+ })}</g><g class="ui-chart__axis-labels">{categoryTicks.map(tick => <text x={tick.position} y={height - 12} text-anchor={tick.textAnchor}>{tick.label}</text>)}</g><path
48
67
  class="ui-range-chart__area"
49
68
  d={geometry.areaPath}
50
- />{geometry.points.map(point => (
69
+ />{geometry.points.map((point, index) => (
51
70
  <line
71
+ data-ui-chart-datum={datumActions[index]?.serialized}
52
72
  class="ui-range-chart__interval"
53
73
  x1={point.xCoordinate}
54
74
  x2={point.xCoordinate}
55
75
  y1={point.highCoordinate}
56
76
  y2={point.lowCoordinate}
57
77
  ><title>{`${point.xLabel ?? point.x}: ${point.label ?? `${formatValue(point.low ?? 0)}–${formatValue(point.high ?? 0)}`}`}</title></line>
78
+ ))}{drilldown && geometry.points.map((point, index) => (
79
+ <rect
80
+ class="ui-chart__datum-hit"
81
+ x={point.xCoordinate - 10}
82
+ y={Math.min(point.highCoordinate, point.lowCoordinate) - Math.max(0, 20 - Math.abs(point.highCoordinate - point.lowCoordinate)) / 2}
83
+ width="20"
84
+ height={Math.max(20, Math.abs(point.highCoordinate - point.lowCoordinate))}
85
+ data-ui-chart-datum={datumActions[index]?.serialized}
86
+ />
58
87
  ))}</svg></div>
59
- {showTable && <details class="ui-chart__data"><summary>{resolvedLabels.viewData}</summary><div><table><thead><tr><th scope="col">{resolvedLabels.category}</th><th scope="col">{resolvedLabels.low}</th><th scope="col">{resolvedLabels.high}</th></tr></thead><tbody>{data.map(item => <tr><th scope="row">{item.xLabel ?? item.x}</th><td>{item.low === null || !Number.isFinite(item.low) ? resolvedLabels.notAvailable : formatValue(item.low)}</td><td>{item.high === null || !Number.isFinite(item.high) ? resolvedLabels.notAvailable : formatValue(item.high)}</td></tr>)}</tbody></table></div></details>}
88
+ {showTable && <details class="ui-chart__data"><summary>{resolvedLabels.viewData}</summary><div role="group" tabindex="0" aria-label={resolvedLabels.chartData}><table><thead><tr><th scope="col">{resolvedLabels.category}</th><th scope="col">{resolvedLabels.low}</th><th scope="col">{resolvedLabels.high}</th></tr></thead><tbody>{data.map(item => <tr><th scope="row">{item.xLabel ?? item.x}</th><td>{item.low === null || !Number.isFinite(item.low) ? resolvedLabels.notAvailable : formatValue(item.low)}</td><td>{item.high === null || !Number.isFinite(item.high) ? resolvedLabels.notAvailable : formatValue(item.high)}</td></tr>)}</tbody></table></div></details>}
89
+ {drilldown && <ChartDatumActions actions={datumActions} label={resolvedLabels.exploreData} />}
60
90
  </Chart>
@@ -1,33 +1,46 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import {
4
+ import { createLumenChartDatumActivation,
5
5
  createLumenScatterGeometry,
6
+ createLumenScatterReferences,
6
7
  formatLumenChartSummary,
7
8
  getLumenChartTicks,
8
9
  getLumenChartToneClassName,
10
+ getLumenScatterXTicks,
11
+ type LumenChartDomain,
9
12
  type LumenChartLabels,
10
- type LumenChartScaleType,
11
13
  type LumenChartSeries,
14
+ type LumenScatterReference,
15
+ type LumenScatterScaleType,
12
16
  resolveLumenChartLabels,
13
- scaleLumenChartValue } from '@santi020k/lumen-core'
17
+ scaleLumenChartValue, scaleLumenScatterX } from '@santi020k/lumen-core'
18
+
19
+ import { createChartDatumAction } from '../runtime/chart-actions'
14
20
 
15
21
  import Chart from './Chart.astro'
22
+ import ChartDatumActions from './ChartDatumActions.astro'
16
23
 
17
24
  interface Props extends HTMLAttributes<'figure'> {
18
25
  caption?: string
19
26
  class?: string | null
20
27
  className?: string
21
28
  description?: string
29
+ drilldown?: boolean
22
30
  emptyLabel?: string
23
31
  formatValue?: (value: number) => string
32
+ formatX?: (value: number | string) => string
33
+ formatY?: (value: number) => string
34
+ xDomain?: Partial<LumenChartDomain>
35
+ domain?: Partial<LumenChartDomain>
36
+ references?: readonly LumenScatterReference[]
24
37
  heading?: string
25
38
  labels?: Partial<LumenChartLabels>
26
39
  series: readonly LumenChartSeries[]
27
40
  showLegend?: boolean
28
41
  showTable?: boolean
29
42
  summary?: string
30
- xScale?: Exclude<LumenChartScaleType, 'categorical'>
43
+ xScale?: LumenScatterScaleType
31
44
  }
32
45
 
33
46
  const {
@@ -35,8 +48,14 @@ const {
35
48
  class: classProp = '',
36
49
  className = '',
37
50
  description,
51
+ drilldown = false,
38
52
  emptyLabel,
39
53
  formatValue = String,
54
+ formatX = String,
55
+ formatY = formatValue,
56
+ xDomain,
57
+ domain,
58
+ references = [],
40
59
  heading,
41
60
  labels,
42
61
  series,
@@ -47,7 +66,9 @@ const {
47
66
  ...rest
48
67
  } = Astro.props
49
68
 
50
- const geometry = createLumenScatterGeometry(series, { xScale })
69
+ const plotId = `ui-scatter-${crypto.randomUUID()}`
70
+ const geometry = createLumenScatterGeometry(series, { xScale, ...(xDomain ? { xDomain } : {}), ...(domain ? { domain } : {}) })
71
+ const referenceGeometry = createLumenScatterReferences(references, geometry, xScale)
51
72
  const resolvedLabels = resolveLumenChartLabels(labels)
52
73
 
53
74
  const projectedSeries = series.map(item => ({
@@ -56,28 +77,55 @@ const projectedSeries = series.map(item => ({
56
77
  })).filter(item => item.data.length > 0)
57
78
 
58
79
  const hasData = geometry.points.length > 0
59
- const summary = summaryProp ?? formatLumenChartSummary(projectedSeries, formatValue, resolvedLabels)
60
- const xTicks = getLumenChartTicks(geometry.xDomain)
80
+ const summary = summaryProp ?? formatLumenChartSummary(projectedSeries, formatY, resolvedLabels)
81
+ const xTicks = getLumenScatterXTicks(geometry.xDomain, xScale)
61
82
  const yTicks = getLumenChartTicks(geometry.domain)
83
+
84
+ const datumActions = drilldown ?
85
+ geometry.points.map(point => createChartDatumAction(
86
+ createLumenChartDatumActivation(point.seriesId, point),
87
+ `${point.xLabel ?? formatX(point.x)} · ${point.seriesLabel}: ${point.label ?? formatY(point.y ?? 0)}`,
88
+ resolvedLabels
89
+ )) :
90
+ []
91
+
62
92
  ---
63
93
 
64
- <Chart class:list={['ui-scatter-chart', classProp, className]} {caption} {description} {heading} {summary} {...rest}>
94
+ <Chart class:list={['ui-scatter-chart', classProp, className]} {caption} {description} {heading} {summary} {...rest} data-ui-chart-activation={drilldown || undefined}>
65
95
  {showLegend && hasData && <ul class="ui-chart__legend" aria-label={resolvedLabels.chartLegend}>{series.map((item, index) => <li class={getLumenChartToneClassName(item.tone, index)}><span aria-hidden="true" />{item.label}</li>)}</ul>}
66
96
  {!hasData && <p class="ui-chart__empty" role="status">{emptyLabel ?? resolvedLabels.empty}</p>}
67
- <div class="ui-chart__plot" hidden={!hasData}>
97
+ <div aria-label={resolvedLabels.chartData} class="ui-chart__plot" role="region" tabindex="0" hidden={!hasData}>
68
98
  <svg aria-hidden="true" viewBox={`0 0 ${geometry.width} ${geometry.height}`}>
99
+ <defs><clipPath id={plotId}><rect x="44" y="44" width={geometry.width - 88} height={geometry.height - 88} /></clipPath></defs>
69
100
  <g class="ui-chart__grid">{yTicks.map(tick => {
70
101
  const y = scaleLumenChartValue(tick, geometry.domain, geometry.height - 44, 44)
71
102
 
72
- return <><line x1="44" x2={geometry.width - 44} y1={y} y2={y}/><text x="36" y={y}>{formatValue(tick)}</text></>
103
+ return <><line x1="44" x2={geometry.width - 44} y1={y} y2={y}/><text x="36" y={y}>{formatY(tick)}</text></>
73
104
  })}</g>
74
105
  <g class="ui-chart__axis-labels">{xTicks.map(tick => {
75
- const x = scaleLumenChartValue(tick, geometry.xDomain, 44, geometry.width - 44)
106
+ const x = scaleLumenScatterX(tick, geometry.xDomain, 44, geometry.width - 44, xScale)
76
107
 
77
- return <text text-anchor="middle" x={x} y={geometry.height - 14}>{formatValue(tick)}</text>
108
+ return <text text-anchor="middle" x={x} y={geometry.height - 14}>{formatX(tick)}</text>
78
109
  })}</g>
79
- <g class="ui-scatter-chart__marks">{geometry.points.map(point => <circle class={getLumenChartToneClassName(point.tone)} cx={point.xCoordinate} cy={point.yCoordinate} r={point.radius}><title>{`${point.xLabel ?? point.x} · ${point.seriesLabel}: ${point.label ?? formatValue(point.y ?? 0)}`}</title></circle>)}</g>
110
+ <g class="ui-scatter-chart__references" clip-path={`url(#${plotId})`}>{referenceGeometry.map(reference => reference.region ?
111
+ <rect x={Math.min(reference.x1, reference.x2)} y={Math.min(reference.y1, reference.y2)} width={Math.abs(reference.x2 - reference.x1)} height={Math.abs(reference.y2 - reference.y1)}><title>{reference.label}</title></rect> :
112
+ <line x1={reference.x1} x2={reference.x2} y1={reference.y1} y2={reference.y2}><title>{reference.label}</title></line>)}</g>
113
+ <g class="ui-scatter-chart__marks" clip-path={`url(#${plotId})`}>{geometry.points.map((point, index) => <circle data-ui-chart-datum={datumActions[index]?.serialized} class={getLumenChartToneClassName(point.tone)} cx={point.xCoordinate} cy={point.yCoordinate} r={point.radius}><title>{`${point.xLabel ?? formatX(point.x)} · ${point.seriesLabel}: ${point.label ?? formatY(point.y ?? 0)}`}</title></circle>)}</g>
114
+ {drilldown && geometry.points.map((point, index) => point.xCoordinate >= 44 && point.xCoordinate <= geometry.width - 44 && point.yCoordinate >= 44 && point.yCoordinate <= geometry.height - 44 && (
115
+ <circle
116
+ class="ui-chart__datum-hit"
117
+ cx={point.xCoordinate}
118
+ cy={point.yCoordinate}
119
+ r={Math.max(10, point.radius)}
120
+ data-ui-chart-datum={datumActions[index]?.serialized}
121
+ />
122
+ ))}
123
+
80
124
  </svg>
81
125
  </div>
82
- {showTable && hasData && <details class="ui-chart__data"><summary>{resolvedLabels.viewData}</summary><div><table><thead><tr><th scope="col">{resolvedLabels.x}</th><th scope="col">{resolvedLabels.series}</th><th scope="col">{resolvedLabels.value}</th><th scope="col">{resolvedLabels.size}</th></tr></thead><tbody>{geometry.points.map(point => <tr><th scope="row">{point.xLabel ?? point.x}</th><td>{point.seriesLabel}</td><td>{point.label ?? formatValue(point.y ?? 0)}</td><td>{point.size === undefined || point.size === null || !Number.isFinite(point.size) ? resolvedLabels.notAvailable : formatValue(point.size)}</td></tr>)}</tbody></table></div></details>}
126
+ {referenceGeometry.length > 0 && (
127
+ <ul class="ui-scatter-chart__reference-labels">{referenceGeometry.map(reference => <li>{reference.label}</li>)}</ul>
128
+ )}
129
+ {showTable && hasData && <details class="ui-chart__data"><summary>{resolvedLabels.viewData}</summary><div role="group" tabindex="0" aria-label={resolvedLabels.chartData}><table><thead><tr><th scope="col">{resolvedLabels.x}</th><th scope="col">{resolvedLabels.series}</th><th scope="col">{resolvedLabels.value}</th><th scope="col">{resolvedLabels.size}</th></tr></thead><tbody>{geometry.points.map(point => <tr><th scope="row">{point.xLabel ?? formatX(point.x)}</th><td>{point.seriesLabel}</td><td>{point.label ?? formatY(point.y ?? 0)}</td><td>{point.size === undefined || point.size === null || !Number.isFinite(point.size) ? resolvedLabels.notAvailable : formatValue(point.size)}</td></tr>)}</tbody></table></div></details>}
130
+ {drilldown && <ChartDatumActions actions={datumActions} label={resolvedLabels.exploreData} />}
83
131
  </Chart>
@@ -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 { type LumenControlVisualSize, resolveLumenAstroProps } from '@santi020k/lumen-core'
5
5
 
6
6
  type SegmentedOption = string | { disabled?: boolean, label: string, value: string }
7
7
 
@@ -11,7 +11,7 @@ interface Props extends HTMLAttributes<'div'> {
11
11
  defaultValue?: string
12
12
  name?: string
13
13
  options?: SegmentedOption[]
14
- size?: 'default' | 'sm' | 'lg'
14
+ visualSize?: LumenControlVisualSize
15
15
  value?: string
16
16
  }
17
17
 
@@ -22,7 +22,7 @@ const {
22
22
  defaultValue,
23
23
  name = 'segmented',
24
24
  options = [],
25
- size = 'default',
25
+ visualSize = 'default',
26
26
  value = defaultValue,
27
27
  ...rest
28
28
  } = Astro.props
@@ -31,8 +31,8 @@ const normalized = options.map(option => typeof option === 'string' ? { disabled
31
31
 
32
32
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
33
33
  'ui-segmented',
34
- size === 'sm' && 'ui-segmented--sm',
35
- size === 'lg' && 'ui-segmented--lg'
34
+ visualSize === 'sm' && 'ui-segmented--sm',
35
+ visualSize === 'lg' && 'ui-segmented--lg'
36
36
  ], classProp, className)
37
37
  ---
38
38
 
@@ -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 { type LumenControlVisualSize, resolveLumenAstroProps } from '@santi020k/lumen-core'
5
5
 
6
6
  interface Option {
7
7
  disabled?: boolean
@@ -11,13 +11,13 @@ interface Option {
11
11
 
12
12
  type SelectOption = Option | string
13
13
 
14
- interface Props extends Omit<HTMLAttributes<'select'>, 'size'> {
14
+ interface Props extends HTMLAttributes<'select'> {
15
15
  class?: string | null
16
16
  className?: string
17
17
  glass?: boolean | 'subtle' | 'strong'
18
18
  options?: SelectOption[]
19
19
  placeholder?: string
20
- size?: 'default' | 'lg' | 'md' | 'sm'
20
+ visualSize?: LumenControlVisualSize
21
21
  }
22
22
 
23
23
  const {
@@ -30,7 +30,7 @@ const {
30
30
  options = [],
31
31
  placeholder,
32
32
  required = false,
33
- size = 'default',
33
+ visualSize = 'default',
34
34
  value,
35
35
  ...rest
36
36
  } = Astro.props
@@ -63,8 +63,8 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
63
63
  class:list={[
64
64
  'ui-select',
65
65
  'ui-select__native',
66
- size === 'sm' && 'ui-select--sm',
67
- size === 'lg' && 'ui-select--lg'
66
+ visualSize === 'sm' && 'ui-select--sm',
67
+ visualSize === 'lg' && 'ui-select--lg'
68
68
  ]}
69
69
  data-ui-select-native
70
70
  disabled={disabled}
@@ -98,8 +98,8 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
98
98
  class:list={[
99
99
  'ui-select',
100
100
  'ui-select__trigger',
101
- size === 'sm' && 'ui-select--sm',
102
- size === 'lg' && 'ui-select--lg'
101
+ visualSize === 'sm' && 'ui-select--sm',
102
+ visualSize === 'lg' && 'ui-select--lg'
103
103
  ]}
104
104
  data-ui-select-trigger
105
105
  disabled={disabled}
@@ -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
+ )}