@santi020k/lumen-astro 1.6.0 → 1.7.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,15 @@ incremental migrations, but new code should keep native behavior and visual styl
60
60
  <NativeSelect size={8} visualSize="lg" />
61
61
  ```
62
62
 
63
+ `PhoneInput` generates localized country names, supplementary flags, and calling codes when custom
64
+ `countries` are not provided. Mount `UIPrimitives` once to enable as-you-type formatting,
65
+ metadata-backed validation, automatic country detection for pasted international numbers, and the
66
+ `ui:phone-change` event with a `LumenPhoneNumber` detail.
67
+
68
+ ```astro
69
+ <PhoneInput countryValue="CO" locale="en-US" name="hospitalPhone" />
70
+ ```
71
+
63
72
  ## Compound interactive components
64
73
 
65
74
  Popover, dropdown menu, tabs, and tooltip parts expose the DOM and ARIA contracts consumed by the
@@ -216,7 +225,9 @@ non-code content `Code` semantics.
216
225
  ## Data visualization
217
226
 
218
227
  Use `Sparkline` beside a metric, `BarChart` for categorical comparison, `LineChart` for ordered
219
- trends, and `PieChart` for a small part-to-whole breakdown. The chart components accept
228
+ trends, `PieChart` for a small part-to-whole breakdown, `ScatterChart` for numeric relationships,
229
+ `Heatmap` for a matrix, `RangeChart` for intervals, and `ComboChart` for a shared-domain mix of bars,
230
+ lines, and areas. The chart components accept
220
231
  already-aggregated series, use Lumen data-color tokens, and include a revealable semantic data
221
232
  table by default.
222
233
 
@@ -237,6 +248,10 @@ const series = [{
237
248
  `PieChart` accepts one `LumenChartSeries`; its data points become slices. It defaults to
238
249
  `variant="donut"` and also supports `variant="pie"`.
239
250
 
251
+ Every chart includes a factual screen-reader summary by default and accepts `summary` for more
252
+ useful domain context. See [data visualization](../../docs/data-visualization.md) for selection,
253
+ missing-data, live-data, and accessibility guidance.
254
+
240
255
  Lucide intentionally excludes brand marks. For GitHub and other project-specific icons, put the
241
256
  SVG in the default slot instead of passing an unsupported `name`; Astro warns about unknown named
242
257
  icons during development.
@@ -3,6 +3,7 @@ import type { HTMLAttributes } from 'astro/types'
3
3
 
4
4
  import {
5
5
  createLumenBarGeometry,
6
+ formatLumenChartSummary,
6
7
  getLumenChartTicks,
7
8
  getLumenChartToneClassName,
8
9
  hasLumenChartData,
@@ -31,6 +32,7 @@ interface Props extends HTMLAttributes<'figure'> {
31
32
  series: readonly LumenChartSeries[]
32
33
  showLegend?: boolean
33
34
  showTable?: boolean
35
+ summary?: string
34
36
  }
35
37
 
36
38
  const {
@@ -49,6 +51,7 @@ const {
49
51
  series,
50
52
  showLegend = series.length > 1,
51
53
  showTable = true,
54
+ summary = formatLumenChartSummary(series, formatValue),
52
55
  ...rest
53
56
  } = Astro.props
54
57
 
@@ -72,7 +75,7 @@ const labelFor = (item: LumenChartSeries, category: number | string) => (
72
75
  const formattedValueFor = (item: LumenChartSeries, category: number | string) => {
73
76
  const value = valueFor(item, category)
74
77
 
75
- return value === null ? 'Not available' : formatValue(value)
78
+ return value === null || !Number.isFinite(value) ? 'Not available' : formatValue(value)
76
79
  }
77
80
 
78
81
  const categoryLabelFor = (category: number | string) => {
@@ -92,6 +95,7 @@ const margin = orientation === 'horizontal' ?
92
95
  {description}
93
96
  {glass}
94
97
  {heading}
98
+ {summary}
95
99
  {...rest}
96
100
  >
97
101
  {
@@ -11,6 +11,7 @@ interface Props extends HTMLAttributes<'figure'> {
11
11
  glass?: boolean | 'subtle' | 'strong'
12
12
  heading?: string
13
13
  presentation?: 'bare' | 'default'
14
+ summary?: string
14
15
  value?: string
15
16
  }
16
17
 
@@ -22,6 +23,7 @@ const {
22
23
  glass = false,
23
24
  heading,
24
25
  presentation = 'default',
26
+ summary,
25
27
  value,
26
28
  ...rest
27
29
  } = Astro.props
@@ -53,6 +55,7 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
53
55
  </header>
54
56
  )
55
57
  }
58
+ {summary && <p class="ui-sr-only" data-ui-chart-summary>{summary}</p>}
56
59
  <slot />
57
60
  {caption && <figcaption>{caption}</figcaption>}
58
61
  </figure>
@@ -0,0 +1,118 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import {
5
+ alignLumenChartSeries,
6
+ createLumenBarGeometry,
7
+ createLumenLineGeometry,
8
+ formatLumenChartSummary,
9
+ getLumenChartCategories,
10
+ getLumenChartDomain,
11
+ getLumenChartToneClassName,
12
+ hasLumenChartData,
13
+ type LumenChartDatum,
14
+ type LumenComboSeries,
15
+ resolveLumenChartTone } from '@santi020k/lumen-core'
16
+
17
+ import Chart from './Chart.astro'
18
+
19
+ interface Props extends HTMLAttributes<'figure'> {
20
+ caption?: string
21
+ class?: string | null
22
+ className?: string
23
+ description?: string
24
+ formatValue?: (value: number) => string
25
+ heading?: string
26
+ series: readonly LumenComboSeries[]
27
+ showLegend?: boolean
28
+ showTable?: boolean
29
+ summary?: string
30
+ }
31
+
32
+ const { caption, class: classProp = '', className = '', description, formatValue = String, heading, series, showLegend = true, showTable = true, summary = formatLumenChartSummary(series, formatValue), ...rest } = Astro.props
33
+ const width = 640
34
+ const height = 320
35
+ const padding = 44
36
+ const categories = getLumenChartCategories(series)
37
+
38
+ const alignedSeries = series.map(item => ({
39
+ ...item,
40
+ data: alignLumenChartSeries(item, categories).data
41
+ }))
42
+
43
+ const domain = getLumenChartDomain(alignedSeries.flatMap(item => item.data.map(datum => datum.y)))
44
+ const barSeries = alignedSeries.filter(item => item.mark === 'bar')
45
+ const lineSeries = alignedSeries.filter(item => item.mark !== 'bar')
46
+ const bars = createLumenBarGeometry(barSeries, { domain, height, width })
47
+
48
+ const categoryPositions = new Map(
49
+ bars.categories.map(category => [`${typeof category.category}:${String(category.category)}`, category.x])
50
+ )
51
+
52
+ const drawableWidth = width - padding * 2
53
+
54
+ const alignComboLineDatum = (datum: LumenChartDatum): LumenChartDatum => {
55
+ if (barSeries.length === 0) return datum
56
+
57
+ return {
58
+ ...datum,
59
+ x: ((categoryPositions.get(`${typeof datum.x}:${String(datum.x)}`) ?? padding) - padding) / drawableWidth
60
+ }
61
+ }
62
+
63
+ const lineGeometryOptions = {
64
+ domain,
65
+ height,
66
+ padding,
67
+ width,
68
+ ...(barSeries.length === 0 ?
69
+ {} :
70
+ {
71
+ xDomain: { max: 1, min: 0 },
72
+ xScale: 'linear' as const
73
+ })
74
+ }
75
+
76
+ const lines = lineSeries.map(item => createLumenLineGeometry(
77
+ item.data.map(alignComboLineDatum),
78
+ lineGeometryOptions
79
+ ))
80
+
81
+ const hasData = hasLumenChartData(series)
82
+ ---
83
+
84
+ <Chart class:list={['ui-combo-chart', classProp, className]} {caption} {description} {heading} {summary} {...rest}>
85
+ {showLegend && hasData && <ul class="ui-chart__legend" aria-label="Chart legend">{series.map((item, index) => <li class={getLumenChartToneClassName(item.tone, index)}><span aria-hidden="true" />{item.label}</li>)}</ul>}
86
+ {!hasData && <p class="ui-chart__empty" role="status">No chart data available.</p>}
87
+ <div class="ui-chart__plot" hidden={!hasData}><svg aria-hidden="true" viewBox={`0 0 ${width} ${height}`}><g class="
88
+ ui-bar-chart__marks
89
+ "
90
+ >{bars.marks.map(mark => <rect class={getLumenChartToneClassName(mark.tone)} height={mark.height} rx="4" width={mark.width} x={mark.x} y={mark.y}><title>{`${mark.seriesLabel}: ${formatValue(mark.value)}`}</title></rect>)}</g>{lines.map((geometry, index) => {
91
+ const item = lineSeries[index]
92
+
93
+ if (!item) return null
94
+
95
+ const tone = resolveLumenChartTone(item.tone, index + barSeries.length)
96
+
97
+ return (
98
+ <g class:list={['ui-line-chart__series', getLumenChartToneClassName(tone)]}>{item.mark === 'area' && geometry.areaPaths.map(path => (
99
+ <path
100
+ class="ui-line-chart__area"
101
+ d={path}
102
+ />
103
+ )) }<path
104
+ class="ui-line-chart__line"
105
+ d={geometry.path}
106
+ /></g>
107
+ )
108
+ })}</svg></div>
109
+ {showTable && hasData && (
110
+ <details class="ui-chart__data"><summary>View chart data</summary><div><table><thead><tr><th scope="col">Category</th>{series.map(item => <th scope="col">{item.label}</th>)}</tr></thead><tbody>{categories.map(category => (
111
+ <tr><th scope="row">{String(category)}</th>{series.map(item => {
112
+ const datum = item.data.find(point => point.x === category)
113
+
114
+ return <td>{datum?.label ?? (datum?.y === undefined || datum.y === null || !Number.isFinite(datum.y) ? 'Not available' : formatValue(datum.y))}</td>
115
+ })}</tr>
116
+ ))}</tbody></table></div></details>
117
+ )}
118
+ </Chart>
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { createLumenHeatmapGeometry, type LumenHeatmapDatum } from '@santi020k/lumen-core'
5
+
6
+ import Chart from './Chart.astro'
7
+
8
+ interface Props extends HTMLAttributes<'figure'> {
9
+ caption?: string
10
+ class?: string | null
11
+ className?: string
12
+ data: readonly LumenHeatmapDatum[]
13
+ description?: string
14
+ formatValue?: (value: number) => string
15
+ heading?: string
16
+ showTable?: boolean
17
+ summary?: string
18
+ }
19
+
20
+ const { caption, class: classProp = '', className = '', data, description, formatValue = String, heading, showTable = true, summary = `${data.filter(item => item.value !== null && Number.isFinite(item.value)).length} available heatmap cells.`, ...rest } = Astro.props
21
+ const geometry = createLumenHeatmapGeometry(data)
22
+ const hasData = geometry.cells.some(cell => cell.value !== null && Number.isFinite(cell.value))
23
+ ---
24
+
25
+ <Chart class:list={['ui-heatmap', classProp, className]} {caption} {description} {heading} {summary} {...rest}>
26
+ {!hasData && <p class="ui-chart__empty" role="status">No chart data available.</p>}
27
+ <div class="ui-chart__plot" hidden={!hasData}><svg aria-hidden="true" viewBox={`0 0 ${geometry.width} ${geometry.height}`}><g class="
28
+ ui-heatmap__cells
29
+ "
30
+ >{geometry.cells.filter(cell => cell.value !== null && Number.isFinite(cell.value)).map(cell => <rect height={Math.max(0, cell.height - 2)} opacity={Math.max(0.12, cell.ratio)} width={Math.max(0, cell.width - 2)} x={cell.xCoordinate + 1} y={cell.yCoordinate + 1}><title>{`${cell.xLabel ?? cell.x} · ${cell.yLabel ?? cell.y}: ${cell.label ?? formatValue(cell.value ?? 0)}`}</title></rect>)}</g></svg></div>
31
+ {showTable && <details class="ui-chart__data"><summary>View chart data</summary><div><table><thead><tr><th scope="col">Column</th><th scope="col">Row</th><th scope="col">Value</th></tr></thead><tbody>{data.map(cell => <tr><th scope="row">{cell.xLabel ?? cell.x}</th><td>{cell.yLabel ?? cell.y}</td><td>{cell.label ?? (cell.value === null || !Number.isFinite(cell.value) ? 'Not available' : formatValue(cell.value))}</td></tr>)}</tbody></table></div></details>}
32
+ </Chart>
@@ -1,7 +1,10 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import {
5
+ renderLumenIllustrationSvg,
6
+ resolveLumenAstroProps
7
+ } from '@santi020k/lumen-core'
5
8
 
6
9
  interface Props extends HTMLAttributes<'span'> {
7
10
  class?: string | null
@@ -28,6 +31,8 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
28
31
  `ui-illustration--${tone}`,
29
32
  `ui-illustration--${size}`
30
33
  ], classProp, className)
34
+
35
+ const artwork = renderLumenIllustrationSvg(variant)
31
36
  ---
32
37
 
33
38
  <span
@@ -36,32 +41,5 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
36
41
  class:list={classList}
37
42
  role={label ? 'img' : undefined}
38
43
  {...passthrough}
39
- >
40
- <svg aria-hidden="true" fill="none" viewBox="0 0 120 120">
41
- <circle class="ui-illustration__wash" cx="60" cy="60" r="48" />
42
- {variant === 'empty' && (
43
- <g>
44
- <path d="M32 48h56l-7 35H39l-7-35Z" />
45
- <path d="M43 48 49 36h22l6 12M47 66h26" />
46
- </g>
47
- )}
48
- {variant === 'success' && (
49
- <g>
50
- <circle cx="60" cy="60" r="28" />
51
- <path d="m45 60 10 10 21-23" />
52
- </g>
53
- )}
54
- {variant === 'error' && (
55
- <g>
56
- <circle cx="60" cy="60" r="28" />
57
- <path d="m49 49 22 22m0-22L49 71" />
58
- </g>
59
- )}
60
- {variant === 'offline' && (
61
- <g>
62
- <path d="M37 74h44a14 14 0 0 0 1-28 23 23 0 0 0-42-4 16 16 0 0 0-3 32Z" />
63
- <path d="m38 34 44 52" />
64
- </g>
65
- )}
66
- </svg>
67
- </span>
44
+ set:html={artwork}
45
+ />
@@ -10,25 +10,32 @@ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
10
10
  type Props = (LocalImageProps | RemoteImageProps) & {
11
11
  class?: string | null
12
12
  className?: string
13
+ fit?: 'contain' | 'cover'
13
14
  invertOnDark?: boolean
15
+ radius?: 'full' | 'lg' | 'md' | 'none' | 'sm'
14
16
  }
15
17
 
16
18
  const {
17
19
  class: classProp = '',
18
20
  className = '',
21
+ fit = 'cover',
19
22
  invertOnDark = false,
20
23
  loading = 'lazy',
24
+ radius = 'lg',
21
25
  ...rest
22
26
  } = Astro.props
23
27
 
24
28
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
25
29
  'ui-image',
26
- invertOnDark && 'ui-image--invert-dark'
30
+ invertOnDark && 'ui-image--invert-dark',
31
+ `ui-image--fit-${fit}`,
32
+ `ui-image--radius-${radius}`
27
33
  ], classProp, className)
28
34
  ---
29
35
 
30
36
  <AstroImage
31
37
  loading={loading}
32
38
  class:list={classList}
39
+ fit={fit}
33
40
  {...passthrough}
34
41
  />
@@ -4,6 +4,7 @@ import type { HTMLAttributes } from 'astro/types'
4
4
  import {
5
5
  alignLumenChartSeries,
6
6
  createLumenLineGeometry,
7
+ formatLumenChartSummary,
7
8
  getLumenChartCategories,
8
9
  getLumenChartDomain,
9
10
  getLumenChartTicks,
@@ -33,6 +34,7 @@ interface Props extends HTMLAttributes<'figure'> {
33
34
  series: readonly LumenChartSeries[]
34
35
  showLegend?: boolean
35
36
  showTable?: boolean
37
+ summary?: string
36
38
  }
37
39
 
38
40
  const {
@@ -51,6 +53,7 @@ const {
51
53
  series,
52
54
  showLegend = series.length > 1,
53
55
  showTable = true,
56
+ summary = formatLumenChartSummary(series, formatValue),
54
57
  ...rest
55
58
  } = Astro.props
56
59
 
@@ -99,6 +102,7 @@ const referenceY =
99
102
  {description}
100
103
  {glass}
101
104
  {heading}
105
+ {summary}
102
106
  {...rest}
103
107
  >
104
108
  {
@@ -227,7 +231,7 @@ const referenceY =
227
231
  return (
228
232
  <td>
229
233
  {datum?.label ??
230
- (datum?.y === null || datum === undefined ?
234
+ (datum?.y === undefined || datum.y === null || !Number.isFinite(datum.y) ?
231
235
  'Not available' :
232
236
  formatValue(datum.y))}
233
237
  </td>
@@ -1,7 +1,15 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types'
3
3
 
4
- import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
+ import {
5
+ getLumenPhoneCountries,
6
+ getLumenPhoneCountry,
7
+ type LumenPhoneCountry,
8
+ type LumenPhoneCountryOptions,
9
+ type LumenPhoneNumber,
10
+ resolveLumenAstroProps,
11
+ resolveLumenPhoneNumber
12
+ } from '@santi020k/lumen-core'
5
13
 
6
14
  interface CountryOption {
7
15
  disabled?: boolean
@@ -11,13 +19,20 @@ interface CountryOption {
11
19
 
12
20
  type Country = CountryOption | string
13
21
 
22
+ interface NormalizedCountryOption extends CountryOption {
23
+ regionCode?: string
24
+ }
25
+
14
26
  interface Props extends Omit<HTMLAttributes<'div'>, 'size'> {
15
27
  class?: string | null
16
28
  className?: string
17
29
  countries?: Country[]
30
+ countryOptions?: readonly LumenPhoneCountry[]
18
31
  countryLabel?: string
19
32
  countryName?: string
20
33
  countryValue?: string
34
+ invalidNumberMessage?: string
35
+ locale?: LumenPhoneCountryOptions['locale']
21
36
  name?: string
22
37
  placeholder?: string
23
38
  size?: 'default' | 'lg' | 'sm'
@@ -28,9 +43,12 @@ const {
28
43
  class: classProp = '',
29
44
  className = '',
30
45
  countries = [],
46
+ countryOptions,
31
47
  countryLabel = 'Country code',
32
48
  countryName = 'country',
33
- countryValue,
49
+ countryValue = 'US',
50
+ invalidNumberMessage = 'Enter a complete phone number.',
51
+ locale,
34
52
  name = 'phone',
35
53
  placeholder = 'Phone number',
36
54
  size = 'default',
@@ -38,8 +56,45 @@ const {
38
56
  ...rest
39
57
  } = Astro.props
40
58
 
41
- const normalizedCountries = countries.map(country => typeof country === 'string' ? { label: country, value: country } : country)
42
- const hasCountryValue = countryValue !== undefined
59
+ const phoneOptions = locale === undefined ? {} : { locale }
60
+ const metadataCountries = countryOptions ?? getLumenPhoneCountries(phoneOptions)
61
+ const usesMetadata = countries.length === 0
62
+
63
+ const normalizedCountries: NormalizedCountryOption[] = usesMetadata ?
64
+ metadataCountries.map(country => ({
65
+ label: country.pickerLabel,
66
+ regionCode: country.regionCode,
67
+ value: country.regionCode
68
+ })) :
69
+ countries.map(country => typeof country === 'string' ? { label: country, value: country } : country)
70
+
71
+ const selectedCountry = metadataCountries.find(country => (
72
+ country.regionCode === countryValue || country.callingCode === countryValue
73
+ )) ?? metadataCountries[0] ?? getLumenPhoneCountry('US', phoneOptions)
74
+
75
+ const detectedPhoneValue = selectedCountry && value ?
76
+ resolveLumenPhoneNumber(selectedCountry, value, phoneOptions) :
77
+ null
78
+
79
+ const normalizePhoneValue = (detectedValue: LumenPhoneNumber | null): LumenPhoneNumber | null => {
80
+ if (!detectedValue || !selectedCountry) return detectedValue
81
+
82
+ const detectedCountryIsAllowed = metadataCountries.some(country => (
83
+ country.regionCode === detectedValue.country.regionCode
84
+ ))
85
+
86
+ if (detectedCountryIsAllowed) return detectedValue
87
+
88
+ const allowedCountryValue = detectedValue.nationalNumber.startsWith('+') ?
89
+ detectedValue.nationalNumber.slice(1) :
90
+ detectedValue.nationalNumber
91
+
92
+ return resolveLumenPhoneNumber(selectedCountry, allowedCountryValue, phoneOptions)
93
+ }
94
+
95
+ const phoneValue = normalizePhoneValue(detectedPhoneValue)
96
+ const selectedCountryValue = usesMetadata ? phoneValue?.country.regionCode ?? selectedCountry?.regionCode : countryValue
97
+ const renderedValue = usesMetadata ? phoneValue?.nationalNumber ?? value : value
43
98
 
44
99
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
45
100
  'ui-phone-input',
@@ -47,7 +102,14 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
47
102
  ], classProp, className)
48
103
  ---
49
104
 
50
- <div class:list={classList} {...passthrough}>
105
+ <div
106
+ class:list={classList}
107
+ data-e164={phoneValue?.e164}
108
+ data-invalid-number-message={invalidNumberMessage}
109
+ data-ui-phone-input={usesMetadata ? '' : undefined}
110
+ data-valid={phoneValue ? String(phoneValue.isValid) : undefined}
111
+ {...passthrough}
112
+ >
51
113
  <select
52
114
  aria-label={countryLabel}
53
115
  class:list={[
@@ -61,7 +123,8 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
61
123
  {normalizedCountries.map(country => (
62
124
  <option
63
125
  disabled={country.disabled}
64
- selected={hasCountryValue && country.value === countryValue}
126
+ data-region={country.regionCode}
127
+ selected={country.value === selectedCountryValue}
65
128
  value={country.value}
66
129
  >{country.label}
67
130
  </option>
@@ -79,6 +142,6 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
79
142
  name={name}
80
143
  placeholder={placeholder}
81
144
  type="tel"
82
- value={value}
145
+ value={renderedValue}
83
146
  />
84
147
  </div>
@@ -3,6 +3,7 @@ import type { HTMLAttributes } from 'astro/types'
3
3
 
4
4
  import {
5
5
  createLumenPieGeometry,
6
+ formatLumenChartSummary,
6
7
  getLumenChartToneClassName,
7
8
  getLumenPieChartVariantClassName,
8
9
  hasLumenPieData,
@@ -24,6 +25,7 @@ interface Props extends HTMLAttributes<'figure'> {
24
25
  series: LumenChartSeries
25
26
  showLegend?: boolean
26
27
  showTable?: boolean
28
+ summary?: string
27
29
  valueFormatter?: (value: number) => string
28
30
  variant?: LumenPieChartVariant
29
31
  }
@@ -41,6 +43,7 @@ const {
41
43
  showLegend = true,
42
44
  showTable = true,
43
45
  valueFormatter = String,
46
+ summary: summaryProp,
44
47
  variant = 'donut',
45
48
  ...rest
46
49
  } = Astro.props
@@ -48,6 +51,13 @@ const {
48
51
  const geometry = createLumenPieGeometry(series.data, { variant })
49
52
  const hasData = hasLumenPieData(series.data)
50
53
 
54
+ const renderedSeries = {
55
+ ...series,
56
+ data: series.data.filter(datum => datum.y !== null && Number.isFinite(datum.y) && datum.y > 0)
57
+ }
58
+
59
+ const summary = summaryProp ?? formatLumenChartSummary([renderedSeries], valueFormatter)
60
+
51
61
  const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
52
62
  maximumFractionDigits: percentage < 0.01 ? 1 : 0,
53
63
  style: 'percent'
@@ -60,6 +70,7 @@ const percentageFor = (percentage: number) => new Intl.NumberFormat(undefined, {
60
70
  {description}
61
71
  {glass}
62
72
  {heading}
73
+ {summary}
63
74
  {...rest}
64
75
  >
65
76
  {
@@ -0,0 +1,58 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { createLumenRangeGeometry, getLumenChartTicks, getLumenChartToneClassName, type LumenChartTone, type LumenRangeDatum, scaleLumenChartValue } from '@santi020k/lumen-core'
5
+
6
+ import Chart from './Chart.astro'
7
+
8
+ interface Props extends HTMLAttributes<'figure'> {
9
+ caption?: string
10
+ class?: string | null
11
+ className?: string
12
+ data: readonly LumenRangeDatum[]
13
+ description?: string
14
+ formatValue?: (value: number) => string
15
+ heading?: string
16
+ showTable?: boolean
17
+ summary?: string
18
+ tone?: LumenChartTone
19
+ }
20
+
21
+ const { caption, class: classProp = '', className = '', data, description, formatValue = String, heading, showTable = true, summary: summaryProp, tone = 'series-1', ...rest } = Astro.props
22
+ const width = 640
23
+ const height = 320
24
+ const padding = 44
25
+ const geometry = createLumenRangeGeometry(data, { height, padding, width })
26
+
27
+ const summary = summaryProp ?? (
28
+ geometry.points.length === 0 ?
29
+ 'No chart data available.' :
30
+ `${geometry.points.length} available ranges.`
31
+ )
32
+
33
+ const ticks = getLumenChartTicks(geometry.domain)
34
+ ---
35
+
36
+ <Chart class:list={['ui-range-chart', getLumenChartToneClassName(tone), classProp, className]} {caption} {description} {heading} {summary} {...rest}>
37
+ {geometry.points.length === 0 && <p class="ui-chart__empty" role="status">No chart data available.</p>}
38
+ <div class="ui-chart__plot" hidden={geometry.points.length === 0}><svg aria-hidden="true" viewBox={`0 0 ${width} ${height}`}><g class="
39
+ ui-chart__grid
40
+ "
41
+ >{ticks.map(tick => {
42
+ const y = scaleLumenChartValue(tick, geometry.domain, height - padding, padding)
43
+
44
+ return <><line x1={padding} x2={width - padding} y1={y} y2={y}/><text x={padding - 8} y={y}>{formatValue(tick)}</text></>
45
+ })}</g><path
46
+ class="ui-range-chart__area"
47
+ d={geometry.areaPath}
48
+ />{geometry.points.map(point => (
49
+ <line
50
+ class="ui-range-chart__interval"
51
+ x1={point.xCoordinate}
52
+ x2={point.xCoordinate}
53
+ y1={point.highCoordinate}
54
+ y2={point.lowCoordinate}
55
+ ><title>{`${point.xLabel ?? point.x}: ${point.label ?? `${formatValue(point.low ?? 0)}–${formatValue(point.high ?? 0)}`}`}</title></line>
56
+ ))}</svg></div>
57
+ {showTable && <details class="ui-chart__data"><summary>View chart data</summary><div><table><thead><tr><th scope="col">Category</th><th scope="col">Low</th><th scope="col">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) ? 'Not available' : formatValue(item.low)}</td><td>{item.high === null || !Number.isFinite(item.high) ? 'Not available' : formatValue(item.high)}</td></tr>)}</tbody></table></div></details>}
58
+ </Chart>
@@ -0,0 +1,78 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import {
5
+ createLumenScatterGeometry,
6
+ formatLumenChartSummary,
7
+ getLumenChartTicks,
8
+ getLumenChartToneClassName,
9
+ type LumenChartScaleType,
10
+ type LumenChartSeries,
11
+ scaleLumenChartValue } from '@santi020k/lumen-core'
12
+
13
+ import Chart from './Chart.astro'
14
+
15
+ interface Props extends HTMLAttributes<'figure'> {
16
+ caption?: string
17
+ class?: string | null
18
+ className?: string
19
+ description?: string
20
+ emptyLabel?: string
21
+ formatValue?: (value: number) => string
22
+ heading?: string
23
+ series: readonly LumenChartSeries[]
24
+ showLegend?: boolean
25
+ showTable?: boolean
26
+ summary?: string
27
+ xScale?: Exclude<LumenChartScaleType, 'categorical'>
28
+ }
29
+
30
+ const {
31
+ caption,
32
+ class: classProp = '',
33
+ className = '',
34
+ description,
35
+ emptyLabel = 'No chart data available.',
36
+ formatValue = String,
37
+ heading,
38
+ series,
39
+ showLegend = series.length > 1,
40
+ showTable = true,
41
+ summary: summaryProp,
42
+ xScale = 'linear',
43
+ ...rest
44
+ } = Astro.props
45
+
46
+ const geometry = createLumenScatterGeometry(series, { xScale })
47
+
48
+ const projectedSeries = series.map(item => ({
49
+ ...item,
50
+ data: geometry.points.filter(point => point.seriesId === item.id)
51
+ })).filter(item => item.data.length > 0)
52
+
53
+ const hasData = geometry.points.length > 0
54
+ const summary = summaryProp ?? formatLumenChartSummary(projectedSeries, formatValue)
55
+ const xTicks = getLumenChartTicks(geometry.xDomain)
56
+ const yTicks = getLumenChartTicks(geometry.domain)
57
+ ---
58
+
59
+ <Chart class:list={['ui-scatter-chart', classProp, className]} {caption} {description} {heading} {summary} {...rest}>
60
+ {showLegend && hasData && <ul class="ui-chart__legend" aria-label="Chart legend">{series.map((item, index) => <li class={getLumenChartToneClassName(item.tone, index)}><span aria-hidden="true" />{item.label}</li>)}</ul>}
61
+ {!hasData && <p class="ui-chart__empty" role="status">{emptyLabel}</p>}
62
+ <div class="ui-chart__plot" hidden={!hasData}>
63
+ <svg aria-hidden="true" viewBox={`0 0 ${geometry.width} ${geometry.height}`}>
64
+ <g class="ui-chart__grid">{yTicks.map(tick => {
65
+ const y = scaleLumenChartValue(tick, geometry.domain, geometry.height - 44, 44)
66
+
67
+ return <><line x1="44" x2={geometry.width - 44} y1={y} y2={y}/><text x="36" y={y}>{formatValue(tick)}</text></>
68
+ })}</g>
69
+ <g class="ui-chart__axis-labels">{xTicks.map(tick => {
70
+ const x = scaleLumenChartValue(tick, geometry.xDomain, 44, geometry.width - 44)
71
+
72
+ return <text text-anchor="middle" x={x} y={geometry.height - 14}>{formatValue(tick)}</text>
73
+ })}</g>
74
+ <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>
75
+ </svg>
76
+ </div>
77
+ {showTable && hasData && <details class="ui-chart__data"><summary>View chart data</summary><div><table><thead><tr><th scope="col">X</th><th scope="col">Series</th><th scope="col">Value</th><th scope="col">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) ? 'Not available' : formatValue(point.size)}</td></tr>)}</tbody></table></div></details>}
78
+ </Chart>
package/index.ts CHANGED
@@ -38,6 +38,7 @@ export { default as CodeTabs } from './components/CodeTabs.astro'
38
38
  export { default as Collapsible } from './components/Collapsible.astro'
39
39
  export { default as ColorPicker } from './components/ColorPicker.astro'
40
40
  export { default as Combobox } from './components/Combobox.astro'
41
+ export { default as ComboChart } from './components/ComboChart.astro'
41
42
  export { default as Command } from './components/Command.astro'
42
43
  export { default as Container } from './components/Container.astro'
43
44
  export { default as ContextMenu } from './components/ContextMenu.astro'
@@ -68,6 +69,7 @@ export { default as FormattedDate } from './components/FormattedDate.astro'
68
69
  export { default as GradientDivider } from './components/GradientDivider.astro'
69
70
  export { default as Graphic } from './components/Graphic.astro'
70
71
  export { default as Grid } from './components/Grid.astro'
72
+ export { default as Heatmap } from './components/Heatmap.astro'
71
73
  export { default as HoverCard } from './components/HoverCard.astro'
72
74
  export { default as Icon } from './components/Icon.astro'
73
75
  export { default as Illustration } from './components/Illustration.astro'
@@ -108,10 +110,12 @@ export { default as Progress } from './components/Progress.astro'
108
110
  export { default as Prose } from './components/Prose.astro'
109
111
  export { default as QRCode } from './components/QRCode.astro'
110
112
  export { default as RadioGroup } from './components/RadioGroup.astro'
113
+ export { default as RangeChart } from './components/RangeChart.astro'
111
114
  export { default as Rating } from './components/Rating.astro'
112
115
  export { default as Resizable } from './components/Resizable.astro'
113
116
  export { default as RevealGroup } from './components/RevealGroup.astro'
114
117
  export { default as RichTextEditor } from './components/RichTextEditor.astro'
118
+ export { default as ScatterChart } from './components/ScatterChart.astro'
115
119
  export { default as Schedule } from './components/Schedule.astro'
116
120
  export { default as ScrollArea } from './components/ScrollArea.astro'
117
121
  export { default as ScrollProgress } from './components/ScrollProgress.astro'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@santi020k/lumen-astro",
3
- "version": "1.6.0",
3
+ "version": "1.7.0-rc.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -37,8 +37,8 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "uqr": "^0.1.3",
40
- "@santi020k/lumen-core": "^1.4.0",
41
- "@santi020k/lumen": "^1.6.0"
40
+ "@santi020k/lumen-core": "^1.7.0-rc.0",
41
+ "@santi020k/lumen": "^1.7.0-rc.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@astrojs/ts-plugin": "^1.10.11",
@@ -17,6 +17,7 @@ import {
17
17
  exportThemeFigmaVariables,
18
18
  formatLumenLanguageLabel,
19
19
  getLumenLocalePair,
20
+ getLumenPhoneCountry,
20
21
  getLumenRichTextShortcut,
21
22
  isLumenRichTextToggleCommand,
22
23
  type LumenLocaleOption,
@@ -24,7 +25,8 @@ import {
24
25
  type LumenRichTextCommandDetail,
25
26
  type LumenThemeTokens,
26
27
  lumenTokenNames,
27
- normalizeLumenLocales } from '@santi020k/lumen-core'
28
+ normalizeLumenLocales,
29
+ resolveLumenPhoneNumber } from '@santi020k/lumen-core'
28
30
 
29
31
  /* eslint-disable complexity */
30
32
  /* Stateful DOM handlers stay local so Astro consumers do not need a framework runtime. */
@@ -71,6 +73,74 @@ const formControlSelector = [
71
73
  'textarea'
72
74
  ].join(',')
73
75
 
76
+ const phoneInputSelector = '[data-ui-phone-input]'
77
+
78
+ const initPhoneInputs = (scope: ParentNode): void => {
79
+ for (const root of scope.querySelectorAll<HTMLElement>(phoneInputSelector)) {
80
+ if (root.dataset.uiPhoneBound === 'true') continue
81
+
82
+ const countrySelect = root.querySelector<HTMLSelectElement>('.ui-phone-input__country')
83
+ const numberInput = root.querySelector<HTMLInputElement>('.ui-phone-input__number')
84
+
85
+ if (!countrySelect || !numberInput) continue
86
+
87
+ root.dataset.uiPhoneBound = 'true'
88
+
89
+ const locale = root.lang || document.documentElement.lang || undefined
90
+ const phoneOptions = locale ? { locale } : {}
91
+
92
+ const commit = (): void => {
93
+ const selectedOption = countrySelect.selectedOptions[0]
94
+ const regionCode = selectedOption?.dataset.region ?? countrySelect.value
95
+ const country = getLumenPhoneCountry(regionCode, phoneOptions)
96
+
97
+ if (!country) return
98
+
99
+ const detectedPhoneNumber = resolveLumenPhoneNumber(country, numberInput.value, phoneOptions)
100
+
101
+ const detectedCountryIsAllowed = Array.from(countrySelect.options).some(option => (
102
+ option.value === detectedPhoneNumber.country.regionCode
103
+ ))
104
+
105
+ const phoneNumber = detectedCountryIsAllowed ?
106
+ detectedPhoneNumber :
107
+ resolveLumenPhoneNumber(
108
+ country,
109
+ detectedPhoneNumber.nationalNumber.startsWith('+') ?
110
+ detectedPhoneNumber.nationalNumber.slice(1) :
111
+ detectedPhoneNumber.nationalNumber,
112
+ phoneOptions
113
+ )
114
+
115
+ const hasInput = phoneNumber.nationalNumber.length > 0
116
+ const invalidMessage = root.dataset.invalidNumberMessage ?? 'Enter a complete phone number.'
117
+
118
+ numberInput.value = phoneNumber.nationalNumber
119
+
120
+ countrySelect.value = phoneNumber.country.regionCode
121
+
122
+ numberInput.setCustomValidity(hasInput && !phoneNumber.isValid ? invalidMessage : '')
123
+
124
+ numberInput.setAttribute('aria-invalid', String(hasInput && !phoneNumber.isValid))
125
+
126
+ root.dataset.e164 = phoneNumber.e164 ?? ''
127
+
128
+ root.dataset.valid = String(phoneNumber.isValid)
129
+
130
+ root.dispatchEvent(new CustomEvent('ui:phone-change', {
131
+ bubbles: true,
132
+ detail: phoneNumber
133
+ }))
134
+ }
135
+
136
+ countrySelect.addEventListener('change', commit)
137
+
138
+ numberInput.addEventListener('input', commit)
139
+
140
+ if (numberInput.value) commit()
141
+ }
142
+ }
143
+
74
144
  const validityMessageAttributes = [
75
145
  ['valueMissing', 'data-error-required'],
76
146
  ['typeMismatch', 'data-error-type'],
@@ -5583,6 +5653,8 @@ const initUiPrimitives = (scope: ParentNode = document): void => {
5583
5653
 
5584
5654
  initFields(scope)
5585
5655
 
5656
+ initPhoneInputs(scope)
5657
+
5586
5658
  initForms(scope)
5587
5659
 
5588
5660
  initPasswordFields(scope)