@santi020k/lumen-astro 0.2.0 → 0.4.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
@@ -18,6 +18,10 @@ enhances all matching Lumen markup on the page.
18
18
  @import "@santi020k/lumen-astro/styles.css";
19
19
  ```
20
20
 
21
+ The stylesheet defaults `--ui-font` to `"Montserrat", "Avenir Next", "Segoe UI", sans-serif`.
22
+ It declares the family stack but does not bundle or load font files. Load Montserrat once through
23
+ your preferred delivery path, or override `--ui-font` in application CSS.
24
+
21
25
  With Tailwind, keep the Lumen import in the same shared CSS entry:
22
26
 
23
27
  ```css
@@ -61,10 +65,75 @@ import { Button, Icon } from '@santi020k/lumen-astro'
61
65
  <Icon name="search" label="Search" />
62
66
  ```
63
67
 
68
+ ## Long-form navigation
69
+
70
+ `Anchor` accepts an optional heading `depth` so article tables of contents can preserve their
71
+ hierarchy. `ScrollProgress` derives reading progress from the document and pins the indicator to
72
+ the top or bottom viewport edge. Both use the single root `UIPrimitives` runtime.
73
+
74
+ ```astro
75
+ ---
76
+ import { Anchor, ScrollProgress } from '@santi020k/lumen-astro'
77
+ ---
78
+
79
+ <ScrollProgress aria-label="Article reading progress" />
80
+ <Anchor items={[
81
+ { depth: 2, href: '#install', label: 'Install' },
82
+ { depth: 3, href: '#astro', label: 'Astro' }
83
+ ]} />
84
+ ```
85
+
86
+ ## Data visualization
87
+
88
+ Use `Sparkline` beside a metric, `BarChart` for categorical comparison, `LineChart` for ordered
89
+ trends, and `PieChart` for a small part-to-whole breakdown. The chart components accept
90
+ already-aggregated series, use Lumen data-color tokens, and include a revealable semantic data
91
+ table by default.
92
+
93
+ ```astro
94
+ ---
95
+ import { LineChart } from '@santi020k/lumen-astro'
96
+
97
+ const series = [{
98
+ id: 'views',
99
+ label: 'Views',
100
+ data: [{ x: 'Mon', y: 42 }, { x: 'Tue', y: 68 }]
101
+ }]
102
+ ---
103
+
104
+ <LineChart aria-label="Views by day" heading="Website traffic" {series} />
105
+ ```
106
+
107
+ `PieChart` accepts one `LumenChartSeries`; its data points become slices. It defaults to
108
+ `variant="donut"` and also supports `variant="pie"`.
109
+
64
110
  Lucide intentionally excludes brand marks. For GitHub and other project-specific icons, put the
65
111
  SVG in the default slot instead of passing an unsupported `name`; Astro warns about unknown named
66
112
  icons during development.
67
113
 
114
+ ## Code tabs
115
+
116
+ Use `CodeTabs` for related install commands, language variants, or configuration examples. Each
117
+ item supplies a stable value, visible label, code string, and optional language. A shared
118
+ `storageKey` keeps matching instances synchronized and remembers the reader's selection.
119
+
120
+ ```astro
121
+ ---
122
+ import { CodeTabs } from '@santi020k/lumen-astro'
123
+
124
+ const installCommands = [
125
+ { value: 'pnpm', label: 'pnpm', code: 'pnpm add @santi020k/lumen-astro', language: 'bash' },
126
+ { value: 'npm', label: 'npm', code: 'npm install @santi020k/lumen-astro', language: 'bash' }
127
+ ]
128
+ ---
129
+
130
+ <CodeTabs
131
+ ariaLabel="Package manager"
132
+ items={installCommands}
133
+ storageKey="preferred-package-manager"
134
+ />
135
+ ```
136
+
68
137
  ## Optimized images
69
138
 
70
139
  Lumen's Astro `Image` delegates to `astro:assets`, so imported image metadata, responsive layouts,
@@ -145,3 +214,17 @@ variants. The older `variant="glass"` form remains supported for cards. Overlay
145
214
  <DatePicker glass="subtle" />
146
215
  <Select glass="strong" options={['Astro', 'React']} />
147
216
  ```
217
+
218
+ ## Semantic stat roots
219
+
220
+ `Stat` renders a `div` by default. Use `as="article"` when the metric and its supporting content
221
+ form a standalone item, or `as="section"` when it is a labeled region in a larger view.
222
+
223
+ ```astro
224
+ <Stat as="article" label="Active workspaces" value="1,234" variant="accent">
225
+ <p>Across all production organizations.</p>
226
+ </Stat>
227
+ ```
228
+
229
+ Use `variant="default"` for the original neutral surface, `variant="accent"` for a featured metric,
230
+ or `variant="glass"` for selective translucency.
@@ -3,7 +3,13 @@ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
3
3
 
4
4
  import type { HTMLAttributes } from 'astro/types'
5
5
 
6
- type AnchorLink = { href: string; label: string }
6
+ type AnchorDepth = 1 | 2 | 3 | 4 | 5 | 6
7
+
8
+ type AnchorLink = {
9
+ depth?: AnchorDepth
10
+ href: string
11
+ label: string
12
+ }
7
13
 
8
14
  export interface Props extends HTMLAttributes<'nav'> {
9
15
  class?: string | null
@@ -34,7 +40,13 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
34
40
  <ol>
35
41
  {items.map((item, index) => (
36
42
  <li>
37
- <a data-active={index === 0 ? 'true' : 'false'} href={item.href}>{item.label}</a>
43
+ <a
44
+ data-active={index === 0 ? 'true' : 'false'}
45
+ data-depth={item.depth}
46
+ href={item.href}
47
+ >
48
+ {item.label}
49
+ </a>
38
50
  </li>
39
51
  ))}
40
52
  </ol>
@@ -0,0 +1,185 @@
1
+ ---
2
+ import {
3
+ createLumenBarGeometry,
4
+ getLumenChartTicks,
5
+ hasLumenChartData,
6
+ type LumenBarChartLayout,
7
+ type LumenChartOrientation,
8
+ type LumenChartSeries,
9
+ resolveLumenChartTone,
10
+ scaleLumenChartValue
11
+ } from '@santi020k/lumen-core'
12
+
13
+ import type { HTMLAttributes } from 'astro/types'
14
+
15
+ import Chart from './Chart.astro'
16
+
17
+ interface Props extends HTMLAttributes<'figure'> {
18
+ caption?: string
19
+ class?: string | null
20
+ className?: string
21
+ description?: string
22
+ glass?: boolean | 'subtle' | 'strong'
23
+ heading?: string
24
+ layout?: LumenBarChartLayout
25
+ orientation?: LumenChartOrientation
26
+ series: readonly LumenChartSeries[]
27
+ showLegend?: boolean
28
+ showTable?: boolean
29
+ }
30
+
31
+ const {
32
+ caption,
33
+ class: classProp = '',
34
+ className = '',
35
+ description,
36
+ glass = false,
37
+ heading,
38
+ layout = 'grouped',
39
+ orientation = 'vertical',
40
+ series,
41
+ showLegend = series.length > 1,
42
+ showTable = true,
43
+ ...rest
44
+ } = Astro.props
45
+ const geometry = createLumenBarGeometry(series, { layout, orientation })
46
+ const hasData = hasLumenChartData(series)
47
+ const ticks = getLumenChartTicks(geometry.domain)
48
+ const categories = geometry.categories.map(category => category.label)
49
+ const valueFor = (item: LumenChartSeries, category: number | string) =>
50
+ item.data.find(datum => datum.x === category)?.y ?? null
51
+ const labelFor = (item: LumenChartSeries, category: number | string) =>
52
+ item.data.find(datum => datum.x === category)?.label
53
+ const margin = orientation === 'horizontal'
54
+ ? { bottom: 24, left: 112, right: 20, top: 16 }
55
+ : { bottom: 52, left: 52, right: 16, top: 16 }
56
+ ---
57
+
58
+ <Chart
59
+ class:list={['ui-bar-chart', classProp, className]}
60
+ {caption}
61
+ {description}
62
+ {glass}
63
+ {heading}
64
+ {...rest}
65
+ >
66
+ {
67
+ showLegend && hasData && (
68
+ <ul class="ui-chart__legend" aria-label="Chart legend">
69
+ {series.map((item, index) => (
70
+ <li class={`ui-chart-tone--${resolveLumenChartTone(item.tone, index)}`}>
71
+ <span aria-hidden="true" />
72
+ {item.label}
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ )
77
+ }
78
+ {!hasData && <p class="ui-chart__empty" role="status">No chart data available.</p>}
79
+ <div class="ui-chart__plot" hidden={!hasData}>
80
+ <svg
81
+ aria-hidden="true"
82
+ preserveAspectRatio="xMidYMid meet"
83
+ viewBox={`0 0 ${geometry.width} ${geometry.height}`}
84
+ >
85
+ <g class="ui-chart__grid">
86
+ {
87
+ ticks.map((tick) => {
88
+ const coordinate =
89
+ orientation === 'horizontal'
90
+ ? scaleLumenChartValue(
91
+ tick,
92
+ geometry.domain,
93
+ margin.left,
94
+ geometry.width - margin.right
95
+ )
96
+ : scaleLumenChartValue(
97
+ tick,
98
+ geometry.domain,
99
+ geometry.height - margin.bottom,
100
+ margin.top
101
+ )
102
+
103
+ return orientation === 'horizontal' ? (
104
+ <line
105
+ x1={coordinate}
106
+ x2={coordinate}
107
+ y1={margin.top}
108
+ y2={geometry.height - margin.bottom}
109
+ />
110
+ ) : (
111
+ <line
112
+ x1={margin.left}
113
+ x2={geometry.width - margin.right}
114
+ y1={coordinate}
115
+ y2={coordinate}
116
+ />
117
+ )
118
+ })
119
+ }
120
+ </g>
121
+ <g class="ui-chart__axis-labels">
122
+ {
123
+ geometry.categories.map((category) => (
124
+ <text
125
+ dominant-baseline={orientation === 'horizontal' ? 'middle' : undefined}
126
+ text-anchor={orientation === 'horizontal' ? 'end' : 'middle'}
127
+ x={category.x}
128
+ y={category.y}
129
+ >
130
+ {String(category.label)}
131
+ </text>
132
+ ))
133
+ }
134
+ </g>
135
+ <g class="ui-bar-chart__marks">
136
+ {
137
+ geometry.marks.map((mark) => (
138
+ <rect
139
+ class={`ui-chart-tone--${mark.tone}`}
140
+ height={mark.height}
141
+ rx="4"
142
+ width={mark.width}
143
+ x={mark.x}
144
+ y={mark.y}
145
+ >
146
+ <title>
147
+ {`${String(mark.category)} · ${mark.seriesLabel}: ${mark.value}`}
148
+ </title>
149
+ </rect>
150
+ ))
151
+ }
152
+ </g>
153
+ </svg>
154
+ </div>
155
+ {
156
+ showTable && hasData && (
157
+ <details class="ui-chart__data">
158
+ <summary>View chart data</summary>
159
+ <div>
160
+ <table>
161
+ <thead>
162
+ <tr>
163
+ <th scope="col">Category</th>
164
+ {series.map(item => <th scope="col">{item.label}</th>)}
165
+ </tr>
166
+ </thead>
167
+ <tbody>
168
+ {categories.map(category => (
169
+ <tr>
170
+ <th scope="row">{String(category)}</th>
171
+ {series.map(item => (
172
+ <td>
173
+ {labelFor(item, category) ??
174
+ String(valueFor(item, category) ?? 'Not available')}
175
+ </td>
176
+ ))}
177
+ </tr>
178
+ ))}
179
+ </tbody>
180
+ </table>
181
+ </div>
182
+ </details>
183
+ )
184
+ }
185
+ </Chart>
@@ -4,15 +4,23 @@ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
4
  import type { HTMLAttributes } from 'astro/types'
5
5
 
6
6
  interface Props extends HTMLAttributes<'figure'> {
7
+ caption?: string
7
8
  class?: string | null
8
9
  className?: string
10
+ description?: string
9
11
  glass?: boolean | 'subtle' | 'strong'
12
+ heading?: string
13
+ value?: string
10
14
  }
11
15
 
12
16
  const {
17
+ caption,
13
18
  class: classProp = '',
14
19
  className = '',
20
+ description,
15
21
  glass = false,
22
+ heading,
23
+ value,
16
24
  ...rest
17
25
  } = Astro.props
18
26
 
@@ -28,5 +36,20 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
28
36
  class:list={classList}
29
37
  {...passthrough}
30
38
  >
39
+ {
40
+ (heading || description || value || Astro.slots.has('header')) && (
41
+ <header>
42
+ <slot name="header">
43
+ <div class="ui-chart__heading">
44
+ {heading && <h3>{heading}</h3>}
45
+ {description && <p>{description}</p>}
46
+ </div>
47
+ {value && <strong data-ui-chart-value>{value}</strong>}
48
+ </slot>
49
+ <slot name="actions" />
50
+ </header>
51
+ )
52
+ }
31
53
  <slot />
54
+ {caption && <figcaption>{caption}</figcaption>}
32
55
  </figure>
@@ -0,0 +1,92 @@
1
+ ---
2
+ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
3
+
4
+ import type { HTMLAttributes } from 'astro/types'
5
+
6
+ import Code from './Code.astro'
7
+ import Tabs from './Tabs.astro'
8
+
9
+ type CodeTheme = 'auto' | 'lumen' | 'santi020k'
10
+
11
+ export interface CodeTabItem {
12
+ code: string
13
+ label: string
14
+ language?: string
15
+ value: string
16
+ }
17
+
18
+ interface Props extends HTMLAttributes<'div'> {
19
+ ariaLabel?: string
20
+ class?: string | null
21
+ className?: string
22
+ copy?: boolean
23
+ initialValue?: string
24
+ items: readonly CodeTabItem[]
25
+ storageKey?: string
26
+ theme?: CodeTheme
27
+ wrap?: boolean
28
+ }
29
+
30
+ const {
31
+ ariaLabel = 'Code examples',
32
+ class: classProp = '',
33
+ className = '',
34
+ copy = true,
35
+ initialValue,
36
+ items,
37
+ storageKey,
38
+ theme = 'auto',
39
+ wrap = true,
40
+ ...rest
41
+ } = Astro.props
42
+
43
+ const selectedValue = initialValue ?? items[0]?.value
44
+ const tabsStateProps = {
45
+ ...(selectedValue === undefined ? {} : { initialValue: selectedValue }),
46
+ ...(storageKey === undefined ? {} : { storageKey })
47
+ }
48
+ const { classList, passthrough } = resolveLumenAstroProps(
49
+ rest,
50
+ ['ui-code-tabs'],
51
+ classProp ?? '',
52
+ className
53
+ )
54
+ ---
55
+
56
+ <Tabs
57
+ class:list={classList}
58
+ {...tabsStateProps}
59
+ {...passthrough}
60
+ >
61
+ <div class="ui-code-tabs__list" role="tablist" aria-label={ariaLabel}>
62
+ {items.map(item => (
63
+ <button
64
+ aria-selected={item.value === selectedValue ? 'true' : 'false'}
65
+ class="ui-code-tabs__tab"
66
+ data-value={item.value}
67
+ role="tab"
68
+ type="button"
69
+ >
70
+ {item.label}
71
+ </button>
72
+ ))}
73
+ </div>
74
+
75
+ {items.map(item => (
76
+ <div
77
+ class="ui-code-tabs__panel"
78
+ data-value={item.value}
79
+ hidden={item.value !== selectedValue}
80
+ role="tabpanel"
81
+ >
82
+ <Code
83
+ code={item.code}
84
+ copy={copy}
85
+ {...(item.language === undefined ? {} : { language: item.language })}
86
+ theme={theme}
87
+ variant="block"
88
+ wrap={wrap}
89
+ />
90
+ </div>
91
+ ))}
92
+ </Tabs>
@@ -7,6 +7,7 @@ interface Props extends HTMLAttributes<'dialog'> {
7
7
  class?: string | null
8
8
  className?: string
9
9
  glass?: boolean | 'subtle' | 'strong'
10
+ layout?: 'centered' | 'fullscreen'
10
11
  /**
11
12
  * @deprecated Use `glass` instead. `surface="glass"` maps to `glass={true}`.
12
13
  */
@@ -17,6 +18,7 @@ const {
17
18
  class: classProp = '',
18
19
  className = '',
19
20
  glass = false,
21
+ layout = 'centered',
20
22
  surface = 'default',
21
23
  ...rest
22
24
  } = Astro.props
@@ -24,6 +26,7 @@ const {
24
26
  const resolvedGlass = resolveLumenGlass(glass, surface)
25
27
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
26
28
  'ui-dialog',
29
+ layout === 'fullscreen' && 'ui-dialog--fullscreen',
27
30
  resolvedGlass && 'ui-dialog--glass',
28
31
  glass === 'subtle' && 'ui-glass-subtle',
29
32
  glass === 'strong' && 'ui-glass-strong'
@@ -32,6 +35,7 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
32
35
 
33
36
  <dialog
34
37
  data-surface={resolvedGlass ? 'glass' : 'default'}
38
+ data-layout={layout}
35
39
  data-ui-dialog
36
40
  class:list={classList}
37
41
  {...passthrough}
@@ -32,7 +32,7 @@ const isDecorative = decorative ?? !label
32
32
  const iconSvg = name ? renderLumenIconSvg(name) : ''
33
33
 
34
34
  if (name && !iconSvg && import.meta.env.DEV) {
35
- console.warn(`[Lumen Icon] Unknown Lucide icon "${name}". Use the default slot for brand or project-specific SVGs.`)
35
+ console.warn(`[Lumen Icon] Unknown icon "${name}". Register an icon pack or use the default slot for project-specific SVGs.`)
36
36
  }
37
37
 
38
38
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
@@ -0,0 +1,227 @@
1
+ ---
2
+ import {
3
+ alignLumenChartSeries,
4
+ createLumenLineGeometry,
5
+ getLumenChartCategories,
6
+ getLumenChartDomain,
7
+ getLumenChartTicks,
8
+ hasLumenChartData,
9
+ type LumenChartSeries,
10
+ resolveLumenChartTone,
11
+ scaleLumenChartValue
12
+ } from '@santi020k/lumen-core'
13
+
14
+ import type { HTMLAttributes } from 'astro/types'
15
+
16
+ import Chart from './Chart.astro'
17
+
18
+ interface Props extends HTMLAttributes<'figure'> {
19
+ area?: boolean
20
+ caption?: string
21
+ class?: string | null
22
+ className?: string
23
+ description?: string
24
+ glass?: boolean | 'subtle' | 'strong'
25
+ heading?: string
26
+ markers?: boolean
27
+ referenceValue?: number
28
+ series: readonly LumenChartSeries[]
29
+ showLegend?: boolean
30
+ showTable?: boolean
31
+ }
32
+
33
+ const {
34
+ area = false,
35
+ caption,
36
+ class: classProp = '',
37
+ className = '',
38
+ description,
39
+ glass = false,
40
+ heading,
41
+ markers = true,
42
+ referenceValue,
43
+ series,
44
+ showLegend = series.length > 1,
45
+ showTable = true,
46
+ ...rest
47
+ } = Astro.props
48
+ const width = 640
49
+ const height = 320
50
+ const padding = 44
51
+ const categories = getLumenChartCategories(series)
52
+ const hasData = hasLumenChartData(series)
53
+ const alignedSeries = series.map(item => alignLumenChartSeries(item, categories))
54
+ const domain = getLumenChartDomain([
55
+ ...alignedSeries.flatMap(item => item.data.map(datum => datum.y)),
56
+ referenceValue ?? null
57
+ ], false)
58
+ const geometries = alignedSeries.map(item =>
59
+ createLumenLineGeometry(item.data, {
60
+ domain,
61
+ height,
62
+ includeZero: false,
63
+ padding,
64
+ width
65
+ })
66
+ )
67
+ const ticks = getLumenChartTicks(domain)
68
+ const labelStep = Math.max(1, Math.ceil(categories.length / 8))
69
+ const valueFor = (item: LumenChartSeries, category: number | string) =>
70
+ item.data.find(datum => datum.x === category)
71
+ const referenceY =
72
+ referenceValue === undefined
73
+ ? undefined
74
+ : scaleLumenChartValue(referenceValue, domain, height - padding, padding)
75
+ ---
76
+
77
+ <Chart
78
+ class:list={['ui-line-chart', classProp, className]}
79
+ {caption}
80
+ {description}
81
+ {glass}
82
+ {heading}
83
+ {...rest}
84
+ >
85
+ {
86
+ showLegend && hasData && (
87
+ <ul class="ui-chart__legend" aria-label="Chart legend">
88
+ {series.map((item, index) => (
89
+ <li class={`ui-chart-tone--${resolveLumenChartTone(item.tone, index)}`}>
90
+ <span aria-hidden="true" />
91
+ {item.label}
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ )
96
+ }
97
+ {!hasData && <p class="ui-chart__empty" role="status">No chart data available.</p>}
98
+ <div class="ui-chart__plot" hidden={!hasData}>
99
+ <svg
100
+ aria-hidden="true"
101
+ preserveAspectRatio="xMidYMid meet"
102
+ viewBox={`0 0 ${width} ${height}`}
103
+ >
104
+ <g class="ui-chart__grid">
105
+ {
106
+ ticks.map((tick) => {
107
+ const y = scaleLumenChartValue(
108
+ tick,
109
+ domain,
110
+ height - padding,
111
+ padding
112
+ )
113
+
114
+ return (
115
+ <>
116
+ <line x1={padding} x2={width - padding} y1={y} y2={y} />
117
+ <text x={padding - 8} y={y}>
118
+ {Number(tick.toPrecision(4))}
119
+ </text>
120
+ </>
121
+ )
122
+ })
123
+ }
124
+ </g>
125
+ <g class="ui-chart__axis-labels">
126
+ {
127
+ categories.map((category, index) => {
128
+ if (
129
+ index % labelStep !== 0 &&
130
+ index !== categories.length - 1
131
+ )
132
+ return null
133
+
134
+ const denominator = Math.max(1, categories.length - 1)
135
+ const x =
136
+ padding + (index / denominator) * (width - padding * 2)
137
+
138
+ return (
139
+ <text text-anchor="middle" x={x} y={height - 14}>
140
+ {String(category)}
141
+ </text>
142
+ )
143
+ })
144
+ }
145
+ </g>
146
+ {
147
+ referenceY !== undefined && (
148
+ <line
149
+ class="ui-chart__reference"
150
+ x1={padding}
151
+ x2={width - padding}
152
+ y1={referenceY}
153
+ y2={referenceY}
154
+ />
155
+ )
156
+ }
157
+ {
158
+ geometries.map((geometry, index) => {
159
+ const item = series[index]
160
+
161
+ if (!item) return null
162
+
163
+ const tone = resolveLumenChartTone(item.tone, index)
164
+
165
+ return (
166
+ <g class={`ui-line-chart__series ui-chart-tone--${tone}`}>
167
+ {area &&
168
+ geometry.areaPaths.map(path => (
169
+ <path class="ui-line-chart__area" d={path} />
170
+ ))}
171
+ <path class="ui-line-chart__line" d={geometry.path} />
172
+ {markers &&
173
+ geometry.points.map(point => (
174
+ <circle
175
+ class="ui-line-chart__point"
176
+ cx={point.xCoordinate}
177
+ cy={point.yCoordinate}
178
+ r="3"
179
+ >
180
+ <title>
181
+ {`${point.label ?? String(point.x)} · ${item.label}: ${point.y}`}
182
+ </title>
183
+ </circle>
184
+ ))}
185
+ </g>
186
+ )
187
+ })
188
+ }
189
+ </svg>
190
+ </div>
191
+ {
192
+ showTable && hasData && (
193
+ <details class="ui-chart__data">
194
+ <summary>View chart data</summary>
195
+ <div>
196
+ <table>
197
+ <thead>
198
+ <tr>
199
+ <th scope="col">Category</th>
200
+ {series.map(item => <th scope="col">{item.label}</th>)}
201
+ </tr>
202
+ </thead>
203
+ <tbody>
204
+ {categories.map(category => (
205
+ <tr>
206
+ <th scope="row">{String(category)}</th>
207
+ {series.map(item => {
208
+ const datum = valueFor(item, category)
209
+
210
+ return (
211
+ <td>
212
+ {datum?.label ??
213
+ (datum?.y === null || datum === undefined
214
+ ? 'Not available'
215
+ : String(datum.y))}
216
+ </td>
217
+ )
218
+ })}
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ </div>
224
+ </details>
225
+ )
226
+ }
227
+ </Chart>
@@ -29,3 +29,49 @@ const { classList, passthrough } = resolveLumenAstroProps(rest, [
29
29
  >
30
30
  <slot />
31
31
  </div>
32
+
33
+ <script>
34
+ type ParticleDensity = 'low' | 'medium' | 'high'
35
+
36
+ const densityConfig: Record<ParticleDensity, number> = {
37
+ low: 15,
38
+ medium: 25,
39
+ high: 40
40
+ }
41
+
42
+ const particleTones = [
43
+ 'var(--brand)',
44
+ 'var(--accent)',
45
+ 'var(--glow, var(--brand))'
46
+ ]
47
+
48
+ const initParticles = (scope: ParentNode = document) => {
49
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
50
+
51
+ for (const container of scope.querySelectorAll<HTMLElement>('[data-ui-particles]')) {
52
+ if (container.hasAttribute('data-ui-particles-initialized')) continue
53
+
54
+ container.setAttribute('data-ui-particles-initialized', '')
55
+
56
+ const density = (container.dataset.uiParticles || 'medium') as ParticleDensity
57
+ const count = densityConfig[density]
58
+
59
+ for (let index = 0; index < count; index++) {
60
+ const particle = document.createElement('span')
61
+ const size = Math.random() * 12 + 8
62
+
63
+ particle.className = 'ui-particles__particle'
64
+ particle.style.setProperty('--ui-particle-delay', `${Math.random() * 8}s`)
65
+ particle.style.setProperty('--ui-particle-duration', `${20 + Math.random() * 15}s`)
66
+ particle.style.setProperty('--ui-particle-left', `${Math.random() * 100}%`)
67
+ particle.style.setProperty('--ui-particle-size', `${size}px`)
68
+ particle.style.setProperty('--ui-particle-tone', particleTones[Math.floor(Math.random() * particleTones.length)])
69
+ particle.style.setProperty('--ui-particle-top', `${Math.random() * 100}%`)
70
+ container.appendChild(particle)
71
+ }
72
+ }
73
+ }
74
+
75
+ initParticles()
76
+ document.addEventListener('astro:after-swap', () => initParticles())
77
+ </script>
@@ -0,0 +1,138 @@
1
+ ---
2
+ import {
3
+ createLumenPieGeometry,
4
+ hasLumenPieData,
5
+ type LumenChartSeries,
6
+ type LumenPieChartVariant
7
+ } from '@santi020k/lumen-core'
8
+
9
+ import type { HTMLAttributes } from 'astro/types'
10
+
11
+ import Chart from './Chart.astro'
12
+
13
+ interface Props extends HTMLAttributes<'figure'> {
14
+ caption?: string
15
+ centerLabel?: string
16
+ centerValue?: string
17
+ class?: string | null
18
+ className?: string
19
+ description?: string
20
+ glass?: boolean | 'subtle' | 'strong'
21
+ heading?: string
22
+ series: LumenChartSeries
23
+ showLegend?: boolean
24
+ showTable?: boolean
25
+ valueFormatter?: (value: number) => string
26
+ variant?: LumenPieChartVariant
27
+ }
28
+
29
+ const {
30
+ caption,
31
+ centerLabel,
32
+ centerValue,
33
+ class: classProp = '',
34
+ className = '',
35
+ description,
36
+ glass = false,
37
+ heading,
38
+ series,
39
+ showLegend = true,
40
+ showTable = true,
41
+ valueFormatter = String,
42
+ variant = 'donut',
43
+ ...rest
44
+ } = Astro.props
45
+ const geometry = createLumenPieGeometry(series.data, { variant })
46
+ const hasData = hasLumenPieData(series.data)
47
+ const percentageFor = (percentage: number) =>
48
+ new Intl.NumberFormat(undefined, {
49
+ maximumFractionDigits: percentage < 0.01 ? 1 : 0,
50
+ style: 'percent'
51
+ }).format(percentage)
52
+ ---
53
+
54
+ <Chart
55
+ class:list={['ui-pie-chart', `ui-pie-chart--${variant}`, classProp, className]}
56
+ {caption}
57
+ {description}
58
+ {glass}
59
+ {heading}
60
+ {...rest}
61
+ >
62
+ {
63
+ showLegend && hasData && (
64
+ <ul class="ui-chart__legend" aria-label="Chart legend">
65
+ {geometry.slices.map(slice => (
66
+ <li class={`ui-chart-tone--${slice.tone}`}>
67
+ <span aria-hidden="true" />
68
+ {slice.label}
69
+ </li>
70
+ ))}
71
+ </ul>
72
+ )
73
+ }
74
+ {!hasData && <p class="ui-chart__empty" role="status">No chart data available.</p>}
75
+ <div class="ui-chart__plot ui-pie-chart__plot" hidden={!hasData}>
76
+ <svg
77
+ aria-hidden="true"
78
+ preserveAspectRatio="xMidYMid meet"
79
+ viewBox={`0 0 ${geometry.size} ${geometry.size}`}
80
+ >
81
+ <g class="ui-pie-chart__slices">
82
+ {
83
+ geometry.slices.map(slice => (
84
+ <path
85
+ class={`ui-chart-tone--${slice.tone}`}
86
+ d={slice.path}
87
+ fill-rule="evenodd"
88
+ >
89
+ <title>
90
+ {`${slice.label}: ${valueFormatter(slice.value)} (${percentageFor(slice.percentage)})`}
91
+ </title>
92
+ </path>
93
+ ))
94
+ }
95
+ </g>
96
+ </svg>
97
+ {
98
+ variant === 'donut' && (centerLabel != null || centerValue != null) && (
99
+ <>
100
+ <div class="ui-pie-chart__center" aria-hidden="true">
101
+ {centerValue != null && <strong>{centerValue}</strong>}
102
+ {centerLabel != null && <span>{centerLabel}</span>}
103
+ </div>
104
+ <div class="ui-sr-only">
105
+ {centerValue} {centerLabel}
106
+ </div>
107
+ </>
108
+ )
109
+ }
110
+ </div>
111
+ {
112
+ showTable && hasData && (
113
+ <details class="ui-chart__data">
114
+ <summary>View chart data</summary>
115
+ <div>
116
+ <table>
117
+ <thead>
118
+ <tr>
119
+ <th scope="col">Category</th>
120
+ <th scope="col">Value</th>
121
+ <th scope="col">Share</th>
122
+ </tr>
123
+ </thead>
124
+ <tbody>
125
+ {geometry.slices.map(slice => (
126
+ <tr>
127
+ <th scope="row">{slice.label}</th>
128
+ <td>{valueFormatter(slice.value)}</td>
129
+ <td>{percentageFor(slice.percentage)}</td>
130
+ </tr>
131
+ ))}
132
+ </tbody>
133
+ </table>
134
+ </div>
135
+ </details>
136
+ )
137
+ }
138
+ </Chart>
@@ -8,6 +8,7 @@ interface Props extends HTMLAttributes<'span'> {
8
8
  className?: string
9
9
  count?: number | string
10
10
  href?: string
11
+ variant?: 'brand' | 'neutral' | 'outline'
11
12
  }
12
13
 
13
14
  const {
@@ -15,20 +16,22 @@ const {
15
16
  className = '',
16
17
  count,
17
18
  href,
19
+ variant = 'neutral',
18
20
  ...rest
19
21
  } = Astro.props
20
22
 
21
23
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
22
- 'ui-pill'
24
+ 'ui-pill',
25
+ variant !== 'neutral' && `ui-pill--${variant}`
23
26
  ], classProp, className)
24
27
  ---
25
28
 
26
29
  {href
27
- ? <a class:list={classList} href={href} {...passthrough}>
30
+ ? <a class:list={classList} data-variant={variant} href={href} {...passthrough}>
28
31
  <slot />
29
32
  {count != null && <span class="ui-pill__count">{count}</span>}
30
33
  </a>
31
- : <span class:list={classList} {...passthrough}>
34
+ : <span class:list={classList} data-variant={variant} {...passthrough}>
32
35
  <slot />
33
36
  {count != null && <span class="ui-pill__count">{count}</span>}
34
37
  </span>
@@ -0,0 +1,38 @@
1
+ ---
2
+ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
3
+
4
+ import type { HTMLAttributes } from 'astro/types'
5
+
6
+ interface Props extends HTMLAttributes<'div'> {
7
+ class?: string | null
8
+ className?: string
9
+ position?: 'bottom' | 'top'
10
+ }
11
+
12
+ const {
13
+ 'aria-label': ariaLabel = 'Reading progress',
14
+ class: classProp = '',
15
+ className = '',
16
+ position = 'top',
17
+ ...rest
18
+ } = Astro.props
19
+
20
+ const { classList, passthrough } = resolveLumenAstroProps(rest, [
21
+ 'ui-scroll-progress',
22
+ position === 'bottom' && 'ui-scroll-progress--bottom'
23
+ ], classProp, className)
24
+ ---
25
+
26
+ <div
27
+ aria-label={ariaLabel}
28
+ aria-valuemax="100"
29
+ aria-valuemin="0"
30
+ aria-valuenow="0"
31
+ class:list={classList}
32
+ data-position={position}
33
+ data-ui-scroll-progress
34
+ role="progressbar"
35
+ {...passthrough}
36
+ >
37
+ <span class="ui-scroll-progress__bar"></span>
38
+ </div>
@@ -0,0 +1,72 @@
1
+ ---
2
+ import {
3
+ composeClassName,
4
+ createLumenLineGeometry,
5
+ type LumenChartTone,
6
+ resolveLumenChartTone
7
+ } from '@santi020k/lumen-core'
8
+
9
+ import type { HTMLAttributes } from 'astro/types'
10
+
11
+ interface Props extends HTMLAttributes<'span'> {
12
+ area?: boolean
13
+ class?: string | null
14
+ className?: string
15
+ label: string
16
+ showEndpoint?: boolean
17
+ tone?: LumenChartTone
18
+ values: readonly number[]
19
+ }
20
+
21
+ const {
22
+ area = false,
23
+ class: classProp = '',
24
+ className = '',
25
+ label,
26
+ showEndpoint = true,
27
+ tone,
28
+ values,
29
+ ...rest
30
+ } = Astro.props
31
+ const data = values.map((value, index) => ({ x: index, y: value }))
32
+ const geometry = createLumenLineGeometry(data, {
33
+ height: 40,
34
+ padding: 3,
35
+ width: 120
36
+ })
37
+ const resolvedTone = resolveLumenChartTone(tone)
38
+ const endpoint = geometry.points[geometry.points.length - 1]
39
+ ---
40
+
41
+ <span
42
+ aria-label={label}
43
+ class={composeClassName(
44
+ 'ui-sparkline',
45
+ `ui-chart-tone--${resolvedTone}`,
46
+ classProp,
47
+ className
48
+ )}
49
+ role="img"
50
+ {...rest}
51
+ >
52
+ <svg aria-hidden="true" preserveAspectRatio="none" viewBox="0 0 120 40">
53
+ {
54
+ area &&
55
+ geometry.areaPaths.map((path) => (
56
+ <path class="ui-sparkline__area" d={path} />
57
+ ))
58
+ }
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
+ </svg>
71
+ <span class="ui-sr-only">{label}</span>
72
+ </span>
@@ -4,31 +4,36 @@ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
4
4
  import type { HTMLAttributes } from 'astro/types'
5
5
 
6
6
  interface Props extends HTMLAttributes<'div'> {
7
+ as?: 'article' | 'div' | 'section'
7
8
  class?: string | null
8
9
  className?: string
9
10
  label?: string
10
11
  value?: string
12
+ variant?: 'accent' | 'default' | 'glass'
11
13
  }
12
14
 
13
15
  const {
16
+ as: Tag = 'div',
14
17
  class: classProp = '',
15
18
  className = '',
16
19
  label,
17
20
  value,
21
+ variant = 'default',
18
22
  ...rest
19
23
  } = Astro.props
20
24
 
21
25
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
22
- 'ui-stat'
26
+ 'ui-stat',
27
+ variant !== 'default' && `ui-stat--${variant}`
23
28
  ], classProp, className)
24
29
  ---
25
30
 
26
- <div
31
+ <Tag
27
32
  class:list={classList}
33
+ data-variant={variant}
28
34
  {...passthrough}
29
35
  >
30
36
  {label && <div class="ui-stat-label">{label}</div>}
31
37
  {value && <div class="ui-stat-value">{value}</div>}
32
38
  <slot />
33
- </div>
34
-
39
+ </Tag>
@@ -39,7 +39,7 @@ const watermarkSvg = [
39
39
  `<svg xmlns="http://www.w3.org/2000/svg" width="${tileSize}" height="${tileSize}" viewBox="0 0 ${tileSize} ${tileSize}">`,
40
40
  `<text x="${tileSize / 2}" y="${tileSize / 2}" dominant-baseline="middle" text-anchor="middle"`,
41
41
  ` transform="rotate(${rotate} ${tileSize / 2} ${tileSize / 2})"`,
42
- ' fill="black" font-family="system-ui, sans-serif" font-size="16" font-weight="500" letter-spacing="1.6">',
42
+ ' fill="black" font-family="Montserrat, Avenir Next, Segoe UI, sans-serif" font-size="16" font-weight="500" letter-spacing="1.6">',
43
43
  `${escapedWatermarkText}</text></svg>`
44
44
  ].join('')
45
45
  const watermarkImage = `url("data:image/svg+xml,${encodeURIComponent(watermarkSvg)}")`
package/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { default as Autocomplete } from './components/Autocomplete.astro'
13
13
  export { default as Avatar } from './components/Avatar.astro'
14
14
  export { default as BackToTop } from './components/BackToTop.astro'
15
15
  export { default as Badge } from './components/Badge.astro'
16
+ export { default as BarChart } from './components/BarChart.astro'
16
17
  export { default as Breadcrumb } from './components/Breadcrumb.astro'
17
18
  export { default as Bubble } from './components/Bubble.astro'
18
19
  export { default as Button } from './components/Button.astro'
@@ -26,6 +27,7 @@ export { default as Cascader } from './components/Cascader.astro'
26
27
  export { default as Chart } from './components/Chart.astro'
27
28
  export { default as Checkbox } from './components/Checkbox.astro'
28
29
  export { default as Code } from './components/Code.astro'
30
+ export { default as CodeTabs } from './components/CodeTabs.astro'
29
31
  export { default as Collapsible } from './components/Collapsible.astro'
30
32
  export { default as ColorPicker } from './components/ColorPicker.astro'
31
33
  export { default as Combobox } from './components/Combobox.astro'
@@ -57,6 +59,7 @@ export { default as Item } from './components/Item.astro'
57
59
  export { default as Kbd } from './components/Kbd.astro'
58
60
  export { default as Label } from './components/Label.astro'
59
61
  export { default as LanguageToggle } from './components/LanguageToggle.astro'
62
+ export { default as LineChart } from './components/LineChart.astro'
60
63
  export { default as Link } from './components/Link.astro'
61
64
  export { default as Marker } from './components/Marker.astro'
62
65
  export { default as Mentions } from './components/Mentions.astro'
@@ -71,6 +74,7 @@ export { default as NumberField } from './components/NumberField.astro'
71
74
  export { default as Pagination } from './components/Pagination.astro'
72
75
  export { default as Particles } from './components/Particles.astro'
73
76
  export { default as PhoneInput } from './components/PhoneInput.astro'
77
+ export { default as PieChart } from './components/PieChart.astro'
74
78
  export { default as Pill } from './components/Pill.astro'
75
79
  export { default as Popconfirm } from './components/Popconfirm.astro'
76
80
  export { default as Popover } from './components/Popover.astro'
@@ -84,6 +88,7 @@ export { default as RevealGroup } from './components/RevealGroup.astro'
84
88
  export { default as RichTextEditor } from './components/RichTextEditor.astro'
85
89
  export { default as Schedule } from './components/Schedule.astro'
86
90
  export { default as ScrollArea } from './components/ScrollArea.astro'
91
+ export { default as ScrollProgress } from './components/ScrollProgress.astro'
87
92
  export { default as ScrollReveal } from './components/ScrollReveal.astro'
88
93
  export { default as SearchField } from './components/SearchField.astro'
89
94
  export { default as Segmented } from './components/Segmented.astro'
@@ -95,6 +100,7 @@ export { default as Skeleton } from './components/Skeleton.astro'
95
100
  export { default as SkipLink } from './components/SkipLink.astro'
96
101
  export { default as Slider } from './components/Slider.astro'
97
102
  export { default as Sonner } from './components/Sonner.astro'
103
+ export { default as Sparkline } from './components/Sparkline.astro'
98
104
  export { default as SpeedDial } from './components/SpeedDial.astro'
99
105
  export { default as Spinner } from './components/Spinner.astro'
100
106
  export { default as Stat } from './components/Stat.astro'
package/index.ts CHANGED
@@ -13,6 +13,7 @@ export { default as Autocomplete } from './components/Autocomplete.astro'
13
13
  export { default as Avatar } from './components/Avatar.astro'
14
14
  export { default as BackToTop } from './components/BackToTop.astro'
15
15
  export { default as Badge } from './components/Badge.astro'
16
+ export { default as BarChart } from './components/BarChart.astro'
16
17
  export { default as Breadcrumb } from './components/Breadcrumb.astro'
17
18
  export { default as Bubble } from './components/Bubble.astro'
18
19
  export { default as Button } from './components/Button.astro'
@@ -26,6 +27,7 @@ export { default as Cascader } from './components/Cascader.astro'
26
27
  export { default as Chart } from './components/Chart.astro'
27
28
  export { default as Checkbox } from './components/Checkbox.astro'
28
29
  export { default as Code } from './components/Code.astro'
30
+ export { default as CodeTabs } from './components/CodeTabs.astro'
29
31
  export { default as Collapsible } from './components/Collapsible.astro'
30
32
  export { default as ColorPicker } from './components/ColorPicker.astro'
31
33
  export { default as Combobox } from './components/Combobox.astro'
@@ -57,6 +59,7 @@ export { default as Item } from './components/Item.astro'
57
59
  export { default as Kbd } from './components/Kbd.astro'
58
60
  export { default as Label } from './components/Label.astro'
59
61
  export { default as LanguageToggle } from './components/LanguageToggle.astro'
62
+ export { default as LineChart } from './components/LineChart.astro'
60
63
  export { default as Link } from './components/Link.astro'
61
64
  export { default as Marker } from './components/Marker.astro'
62
65
  export { default as Mentions } from './components/Mentions.astro'
@@ -71,6 +74,7 @@ export { default as NumberField } from './components/NumberField.astro'
71
74
  export { default as Pagination } from './components/Pagination.astro'
72
75
  export { default as Particles } from './components/Particles.astro'
73
76
  export { default as PhoneInput } from './components/PhoneInput.astro'
77
+ export { default as PieChart } from './components/PieChart.astro'
74
78
  export { default as Pill } from './components/Pill.astro'
75
79
  export { default as Popconfirm } from './components/Popconfirm.astro'
76
80
  export { default as Popover } from './components/Popover.astro'
@@ -84,6 +88,7 @@ export { default as RevealGroup } from './components/RevealGroup.astro'
84
88
  export { default as RichTextEditor } from './components/RichTextEditor.astro'
85
89
  export { default as Schedule } from './components/Schedule.astro'
86
90
  export { default as ScrollArea } from './components/ScrollArea.astro'
91
+ export { default as ScrollProgress } from './components/ScrollProgress.astro'
87
92
  export { default as ScrollReveal } from './components/ScrollReveal.astro'
88
93
  export { default as SearchField } from './components/SearchField.astro'
89
94
  export { default as Segmented } from './components/Segmented.astro'
@@ -95,6 +100,7 @@ export { default as Skeleton } from './components/Skeleton.astro'
95
100
  export { default as SkipLink } from './components/SkipLink.astro'
96
101
  export { default as Slider } from './components/Slider.astro'
97
102
  export { default as Sonner } from './components/Sonner.astro'
103
+ export { default as Sparkline } from './components/Sparkline.astro'
98
104
  export { default as SpeedDial } from './components/SpeedDial.astro'
99
105
  export { default as Spinner } from './components/Spinner.astro'
100
106
  export { default as Stat } from './components/Stat.astro'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@santi020k/lumen-astro",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -33,8 +33,8 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "uqr": "^0.1.3",
36
- "@santi020k/lumen": "^0.2.0",
37
- "@santi020k/lumen-core": "^0.2.0"
36
+ "@santi020k/lumen": "^0.4.0",
37
+ "@santi020k/lumen-core": "^0.4.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@astrojs/ts-plugin": "^1.10.10",
@@ -4112,6 +4112,47 @@
4112
4112
  }
4113
4113
  }
4114
4114
 
4115
+ let scrollProgressFrame = 0
4116
+
4117
+ const updateScrollProgress = (): void => {
4118
+ scrollProgressFrame = 0
4119
+
4120
+ const scrollingElement = document.scrollingElement ?? document.documentElement
4121
+ const maximum = scrollingElement.scrollHeight - scrollingElement.clientHeight
4122
+
4123
+ const percentage = maximum > 0
4124
+ ? Math.min(100, Math.max(0, (scrollingElement.scrollTop / maximum) * 100))
4125
+ : 0
4126
+
4127
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-scroll-progress]')) {
4128
+ const bar = root.querySelector<HTMLElement>('.ui-scroll-progress__bar')
4129
+
4130
+ root.setAttribute('aria-valuenow', `${Math.round(percentage)}`)
4131
+
4132
+ if (bar) bar.style.transform = `scaleX(${percentage / 100})`
4133
+ }
4134
+ }
4135
+
4136
+ const requestScrollProgressUpdate = (): void => {
4137
+ if (scrollProgressFrame) return
4138
+
4139
+ scrollProgressFrame = requestAnimationFrame(updateScrollProgress)
4140
+ }
4141
+
4142
+ const initScrollProgress = (scope: ParentNode): void => {
4143
+ if (!scope.querySelector('[data-ui-scroll-progress]')) return
4144
+
4145
+ requestScrollProgressUpdate()
4146
+
4147
+ if (document.documentElement.dataset.uiScrollProgressBound === 'true') return
4148
+
4149
+ document.documentElement.dataset.uiScrollProgressBound = 'true'
4150
+
4151
+ window.addEventListener('resize', requestScrollProgressUpdate, { passive: true })
4152
+
4153
+ window.addEventListener('scroll', requestScrollProgressUpdate, { passive: true })
4154
+ }
4155
+
4115
4156
  const initTours = (scope: ParentNode): void => {
4116
4157
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-tour]')) {
4117
4158
  if (root.dataset.uiBound === 'true') continue
@@ -4654,6 +4695,8 @@
4654
4695
 
4655
4696
  initAnchors(scope)
4656
4697
 
4698
+ initScrollProgress(scope)
4699
+
4657
4700
  initTours(scope)
4658
4701
 
4659
4702
  initTransfers(scope)