From a984752c8471f75c24a77394e5539efcde42dcf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:08:56 +0200 Subject: [PATCH] Rework the charts page into sections; add `.page-section-title` (#3101) --- .changeset/charts-page-sections.md | 5 + .changeset/legend-large.md | 7 + .changeset/page-section-title.md | 6 + core/scss/_variables.scss | 2 + core/scss/layout/_page.scss | 13 ++ core/scss/ui/_legend.scss | 44 +++++ docs/content/ui/components/legend.mdx | 53 ++++++ docs/content/ui/layout/page-headers.mdx | 32 ++++ preview/pages/charts.astro | 171 ++++++++++++++---- preview/pages/legend.astro | 142 +++++++++++++-- screenshots/pages/legend-large-list.astro | 22 +++ screenshots/pages/legend-large-vertical.astro | 38 ++++ screenshots/pages/legend-large.astro | 42 +++++ shared/components/demo/Chart.astro | 16 +- shared/components/parts/charts/Activity.astro | 6 +- shared/data/charts.json | 105 +++++++---- shared/lib/chart-script.test.ts | 6 + shared/lib/chart-script.ts | 11 +- shared/ui/Legend.astro | 17 +- shared/ui/LegendList.astro | 6 +- 20 files changed, 632 insertions(+), 112 deletions(-) create mode 100644 .changeset/charts-page-sections.md create mode 100644 .changeset/legend-large.md create mode 100644 .changeset/page-section-title.md create mode 100644 screenshots/pages/legend-large-list.astro create mode 100644 screenshots/pages/legend-large-vertical.astro create mode 100644 screenshots/pages/legend-large.astro diff --git a/.changeset/charts-page-sections.md b/.changeset/charts-page-sections.md new file mode 100644 index 000000000..28a22ddbb --- /dev/null +++ b/.changeset/charts-page-sections.md @@ -0,0 +1,5 @@ +--- +"@tabler/preview": minor +--- + +Updated the charts page with sections, card subtitles, `Legend` lists above charts and a semantic colors chart. diff --git a/.changeset/legend-large.md b/.changeset/legend-large.md new file mode 100644 index 000000000..a63e94102 --- /dev/null +++ b/.changeset/legend-large.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": patch +--- + +Added `.legend-lg`, `.legend-unit` and `.legend-list-divided` for large legend items with a big value and a unit. diff --git a/.changeset/page-section-title.md b/.changeset/page-section-title.md new file mode 100644 index 000000000..8605b7f6c --- /dev/null +++ b/.changeset/page-section-title.md @@ -0,0 +1,6 @@ +--- +"@tabler/core": minor +"@tabler/docs": patch +--- + +Added `.page-section-title` and `.page-section-description` classes for section headings inside the page body. diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 18eb610bd..5e1e07907 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -2136,6 +2136,8 @@ $legend-padding-y: 0.375rem !default; $legend-padding-x: 0.75rem !default; $legend-list-gap: 0.25rem 1rem !default; $legend-list-gap-interactive: 0.25rem !default; +$legend-lg-min-width: 9rem !default; +$legend-lg-value-font-size: $h2-font-size !default; // scss-docs-end legend-variables // Switch icon diff --git a/core/scss/layout/_page.scss b/core/scss/layout/_page.scss index dec8ff838..99e069078 100644 --- a/core/scss/layout/_page.scss +++ b/core/scss/layout/_page.scss @@ -130,6 +130,19 @@ color: var(--secondary); } +// Heading of a section inside the page body, e.g. between groups of cards in a `.row-cards` +.page-section-title { + margin: 1rem 0 0; + font-size: $h2-font-size; + font-weight: $page-title-font-weight; + line-height: $h2-line-height; +} + +.page-section-description { + margin: 0.25rem 0 0; + color: var(--secondary); +} + // // Page cover // diff --git a/core/scss/ui/_legend.scss b/core/scss/ui/_legend.scss index 65bca0f2a..db2407020 100644 --- a/core/scss/ui/_legend.scss +++ b/core/scss/ui/_legend.scss @@ -87,6 +87,30 @@ button.legend { color: var(--secondary); } +.legend-unit { + font-size: $h5-font-size; + font-weight: var(--font-weight-normal); + color: var(--secondary); +} + +// Large legend: the label on the first line, a big value (with an optional unit) below +.legend-lg { + display: inline-grid; + grid-template-columns: auto 1fr; + row-gap: 0.5rem; + min-width: $legend-lg-min-width; + @include border-radius(var(--border-radius)); + font-weight: var(--font-weight-normal); + text-align: start; + + .legend-value { + grid-column: 1 / -1; + font-size: $legend-lg-value-font-size; + font-weight: var(--font-weight-medium); + color: var(--body-color); + } +} + @each $name, $color in map.merge($theme-colors, $social-colors) { .legend-#{$name} { --legend-color: var(--#{$name}); @@ -113,3 +137,23 @@ button.legend { flex-direction: column; align-items: flex-start; } + +// A line between the items, for a row of large legends +.legend-list-divided { + &, + &:has(> a.legend, > button.legend) { + --legend-list-gap: 0.5rem 1.5rem; + } + + > .legend { + position: relative; + } + + > .legend + .legend::before { + position: absolute; + inset-block: 0; + inset-inline-start: -0.75rem; + content: ''; + border-inline-start: var(--border-width) var(--border-style) var(--border-color); + } +} diff --git a/docs/content/ui/components/legend.mdx b/docs/content/ui/components/legend.mdx index 1985863e1..ef3cb4213 100644 --- a/docs/content/ui/components/legend.mdx +++ b/docs/content/ui/components/legend.mdx @@ -4,6 +4,7 @@ summary: A legend labels the series of a chart or a progress bar with a colored description: Label chart series with colored markers. Group legend items in a list, add values, and let users toggle series on click. related: [/ui/plugins/chart, /ui/components/status, /ui/components/progress] source: core/scss/ui/_legend.scss +docs-libs: [apexcharts] classnames: component: - class: legend-dot @@ -15,9 +16,16 @@ classnames: part: - class: legend-value desc: Muted value next to the label + - class: legend-unit + desc: Small unit after the value, for example ms or GB + size: + - class: legend-lg + desc: Label on the first line and a big value below style: - class: legend-list-vertical desc: Stacks the items in a column + - class: legend-list-divided + desc: Draws a line between the items, for large legends state: - class: active desc: Keeps the hover background on a link or button @@ -29,6 +37,7 @@ classnames: --- import Example from '@components/Example.astro'; import CodeDocs from '@components/CodeDocs.astro'; +import Chart from '@shared/components/demo/Chart.astro'; ## Legend marker @@ -102,6 +111,50 @@ Add `legend-list-vertical` to stack the items, for example next to a donut chart +## Large legend + +Add `legend-lg` to put the label on the first line and a big value below it. Wrap the unit in a `legend-unit`. Add `legend-list-divided` to the list to draw a line between the items. This size works well above a chart, where the legend also shows the key numbers. + + +
+ P99 124 ms + P95 76 ms + P75 32 ms + Requests 1,234 +
+
+ +### Above a chart + +Put the large items above a chart to show the latest value of each series. Turn off the chart's own legend, because the items already name the series. + + +
+
+
+ P99 124 ms + P95 76 ms + P75 32 ms + P50 10 ms +
+ +
+
+
+ +### Large links + +Large items work as links and buttons too, for example as filters above a list. Add `active` to mark the current item and `legend-off` for a hidden one. + + +
+ Passed 128 + Failed 4 + Skipped 12 + Pending 3 +
+
+ ## Variables Use these SCSS variables to customize legends. The default values are: diff --git a/docs/content/ui/layout/page-headers.mdx b/docs/content/ui/layout/page-headers.mdx index 375522a5f..2d9bf6082 100644 --- a/docs/content/ui/layout/page-headers.mdx +++ b/docs/content/ui/layout/page-headers.mdx @@ -151,9 +151,41 @@ A [breadcrumb](/ui/components/breadcrumb) above the title shows where the page s +## Section title + +Use `.page-section-title` to split a long page body into sections, for example between groups of cards in a `.row-cards` grid. Add a `.page-section-description` below it to say in one sentence what the section holds. + + +
+
+

Line charts

+

Trends over time, where the change between points matters more than single values.

+
+
+
+
+

Revenue by plan

+

Monthly revenue of each plan.

+
+
+
+
+
+
+

CPU load

+

Load of web servers and workers in one day.

+
+
+
+
+
+ +The title sets its own size and top margin, so it looks the same on an `h2`, an `h3` or a `div`. The space below comes from the grid gutter. + ## Accessibility - The page title belongs in an `h1`, and there should be exactly one per page. `page-title` is a style, not a heading. +- A section title is a real heading. Use an `h2` under the page `h1`, and an `h3` for card titles inside that section. - The pretitle is context, not a heading. Keep it a `div` so it does not break the heading order. - Buttons in `page-header` come after the title in the markup even when they sit to its right. That is the order they should be read in. - An icon-only action needs an `aria-label`. diff --git a/preview/pages/charts.astro b/preview/pages/charts.astro index c6d373e79..7f5f137aa 100644 --- a/preview/pages/charts.astro +++ b/preview/pages/charts.astro @@ -6,6 +6,8 @@ import Chart from '@components/demo/Chart.astro' import Card from '@ui/Card.astro' import CardHeader from '@ui/CardHeader.astro' import CardBody from '@ui/CardBody.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' import chartsData from '@data/charts.json' import { capitalize } from '@shared/lib/string-format' import DocsLink from '@ui/DocsLink.astro' @@ -13,7 +15,99 @@ import CaptureScript from '@shared/components/CaptureScript.astro' import LegendList from '@ui/LegendList.astro' import Legend from '@ui/Legend.astro' -const demoCharts = Object.entries(chartsData as Record).filter(([, chart]) => chart && chart.demo) +type DemoChart = { name?: string; extend?: string; type?: string; series?: { name?: string; color?: string }[] } + +const charts = chartsData as Record + +// Legend rules: one serie needs no legend, two to four series get a list above the +// chart, and only circular charts or charts with five and more series keep the legend below. +const circular = ['pie', 'donut', 'radialBar', 'polarArea', 'radar'] + +function topLegend(chartId: string) { + const own = charts[chartId] + const chart = own?.extend ? { ...charts[own.extend], ...own } : own + const series = chart?.series ?? [] + if (circular.includes(chart?.type ?? 'bar') || series.length < 2 || series.length > 4) return undefined + return series.map((serie) => ({ text: serie.name ?? '', color: serie.color ?? 'primary' })) +} + +const third = 'col-md-6 col-lg-4' +const half = 'col-lg-6' +const wide = 'col-lg-8' + +// Every chart is placed by hand, section by section and row by row (each row adds up to +// 12 columns). Charts with many points, several series or long labels get a wider column. +const sections: { title: string; description: string; charts: [chartId: string, col: string, subtitle: string][] }[] = [ + { + title: 'Line charts', + description: 'Trends over time, where the change between points matters more than single values.', + charts: [ + ['completion-tasks-2', third, 'Tickets solved per day with a smooth curve, the default.'], + ['completion-tasks-4', third, 'The same tickets drawn with straight segments.'], + ['completion-tasks-5', third, 'A stepped line for values that change in jumps.'], + + ['completion-tasks-7', third, 'Tickets solved per day, labeled with values.'], + ['tasks', wide, 'Issues opened, closed and reopened per day over the last 17 days.'], + + ['line', third, 'Monthly revenue of each plan, January to June.'], + ['line-stroke', third, 'Monthly signups, dashed for paid and referral.'], + ['spline', third, 'CPU load of web servers and workers in one day.'], + + ['temperature', half, 'Monthly average temperature in Tokyo and London.'], + ['stepline', half, 'Number of running instances during the day.'], + ], + }, + { + title: 'Area charts', + description: 'Lines with a filled area, to stress volume or to show how parts add up to a total.', + charts: [ + ['completion-tasks-3', third, 'Tickets solved per day as a filled area.'], + ['completion-tasks-10', third, 'Solved and reopened tickets as two overlapping areas.'], + ['completion-tasks-11', third, 'Solved and reopened tickets stacked on top of each other.'], + + ['area', third, 'Monthly sessions on mobile and desktop.'], + ['area-spline', third, 'Monthly egress and ingress traffic.'], + ['area-spline-stacked', third, 'Storage used by media and backups, stacked.'], + ], + }, + { + title: 'Bar charts', + description: 'Comparison of separate categories, side by side or stacked.', + charts: [ + ['completion-tasks', third, 'Tickets solved per day, last week.'], + ['completion-tasks-6', third, 'The same tickets as horizontal bars.'], + ['completion-tasks-8', third, 'Solved and reopened tickets side by side.'], + + ['completion-tasks-9', third, 'Solved and reopened tickets in one stacked bar per day.'], + ['cache-status', third, 'Cache status of requests per day, colored by meaning.'], + ['funnel', third, 'Visitors left at each step from first visit to customer.'], + ], + }, + { + title: 'Circular charts', + description: 'Parts of a whole and progress toward a goal, best with a few values.', + charts: [ + ['demo-pie', third, 'Share of visits by source.'], + ['campaigns', third, 'Delivery, open and click rates of one campaign.'], + ['polar-area', third, 'Sales volume of each region.'], + ], + }, + { + title: 'Other charts', + description: 'Mixed series, distributions, schedules and hierarchies.', + charts: [ + ['combination', wide, 'Monthly spend as columns with leads and trials as lines.'], + ['radar', third, 'Pro and Enterprise plans rated in six areas.'], + + ['scatter', third, 'Page load time samples on desktop and mobile.'], + ['timeline', wide, 'Planned duration of each release phase.'], + + ['bubble', third, 'Search and social campaigns on three metrics.'], + ['treemap', third, 'Disk space taken by each folder.'], + ['box-plot', third, 'Spread of API response times in each month.'], + ], + }, +] --- @@ -23,16 +117,15 @@ const demoCharts = Object.entries(chartsData as Record -
+
- - - - - - - + + + + + + @@ -56,28 +149,6 @@ const demoCharts = Object.entries(chartsData as Record
-
- - - - - - - - - - - - - - - - - - - -
-
@@ -92,15 +163,35 @@ const demoCharts = Object.entries(chartsData as Record { - demoCharts.map(([chartId, chart]) => ( -
- - {chart.name && } - - - - -
+ sections.map((section) => ( + +
+

{section.title}

+

{section.description}

+
+ {section.charts.map(([chartId, col, subtitle]) => ( +
+ + +
+ {charts[chartId]?.name ?? capitalize(chartId.replace(/-/g, ' '))} + {subtitle} +
+
+ + {topLegend(chartId) && ( + + {topLegend(chartId)?.map((item) => ( + + ))} + + )} + + +
+
+ ))} +
)) }
diff --git a/preview/pages/legend.astro b/preview/pages/legend.astro index f1403c801..f5cc4fd8e 100644 --- a/preview/pages/legend.astro +++ b/preview/pages/legend.astro @@ -32,6 +32,13 @@ const requests = [ { name: 'Rate limited', color: 'teal', value: '-' }, ] +const latency = [ + { name: 'P99', color: 'primary', value: 124 }, + { name: 'P95', color: 'azure', value: 76 }, + { name: 'P75', color: 'green', value: 32 }, + { name: 'P50', color: 'yellow', value: 10 }, +] + const storage = [ { name: 'Documents', color: 'primary', value: '3.5 GB', width: 44 }, { name: 'Media', color: 'info', value: '1.5 GB', width: 19 }, @@ -119,13 +126,128 @@ const storage = [ +
+ + + Large + Set size="lg" to put a big value under the label. Add unit for the unit and divided on the list for the lines. + + + + + + + + +
+ +
+ + + Under a stat + A legend row explains the split behind a single number. + Requests +
341.0k
+ + {requests.map((item) => )} + +
+
+
+ +
+ + + Above a chart + Large items show the latest value of each series, so the chart needs no legend of its own. + + {latency.map((item) => )} + + + + +
+ +
+ + + Large vertical + Combine size="lg" with a vertical list for a side panel. + + {storage.map((item) => )} + + + +
+ +
+ + + Large links + Large items work as links and buttons too. Mark the current one with active and a hidden series with off. + + + + + + + + +
+ +
+ + + With units + Add unit to a regular item to show the unit after the value. + + {latency.slice(0, 3).map((item) => )} + + + +
+ +
+ + + Without lines + Leave out divided when the items sit in a tight space or wrap to more rows. + + {traffic.map((item) => )} + + + +
+ +
+ + + Loading + Use placeholder blocks for the label and the value while the data loads. + + + +
+
- - - + + + @@ -170,20 +292,6 @@ const storage = [
-
- - - Under a stat - A legend row explains the split behind a single number. - Requests -
341.0k
- - {requests.map((item) => )} - -
-
-
-
diff --git a/screenshots/pages/legend-large-list.astro b/screenshots/pages/legend-large-list.astro new file mode 100644 index 000000000..9415dcfa2 --- /dev/null +++ b/screenshots/pages/legend-large-list.astro @@ -0,0 +1,22 @@ +--- +// Large legend on its own, no card and no chart: a `divided` LegendList of +// `size="lg"` items — colored dot and name, a big value with a small unit +// below — with a line between them. The last item shows the `off` state of a +// hidden series. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Legend from '@ui/Legend.astro' +import LegendList from '@ui/LegendList.astro' + +const items = [ + { name: 'Requests', color: 'primary', value: '1,234', unit: 'req/s' }, + { name: 'Bandwidth', color: 'azure', value: '86.2', unit: 'MB/s' }, + { name: 'Storage', color: 'purple', value: '56', unit: 'GB' }, + { name: 'Warnings', color: 'orange', value: '128', off: true }, +] +--- + + + + {items.map((item) => )} + + diff --git a/screenshots/pages/legend-large-vertical.astro b/screenshots/pages/legend-large-vertical.astro new file mode 100644 index 000000000..6b9519d5c --- /dev/null +++ b/screenshots/pages/legend-large-vertical.astro @@ -0,0 +1,38 @@ +--- +// Large legend next to a donut: a traffic-sources card with the chart on the +// left and a `vertical` list of `size="lg"` items on the right, each showing a +// source and its share. `legend-donut` is the traffic donut from charts.json +// with the ApexCharts legend left off, since the list replaces it. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Card from '@ui/Card.astro' +import CardHeader from '@ui/CardHeader.astro' +import CardBody from '@ui/CardBody.astro' +import Legend from '@ui/Legend.astro' +import LegendList from '@ui/LegendList.astro' +import Chart from '@components/demo/Chart.astro' + +// Order and colors follow the `traffic-sources-donut` entry in charts.json. +const sources = [ + { name: 'Organic search', color: 'azure', value: 38 }, + { name: 'Direct', color: 'primary', value: 27 }, + { name: 'Social media', color: 'purple', value: 18 }, + { name: 'Referral', color: 'orange', value: 12 }, + { name: 'Email', color: 'green', value: 5 }, +] +--- + + + + + +
+
+ +
+ + {sources.map((item) => )} + +
+
+
+
diff --git a/screenshots/pages/legend-large.astro b/screenshots/pages/legend-large.astro new file mode 100644 index 000000000..6cf7ad3d3 --- /dev/null +++ b/screenshots/pages/legend-large.astro @@ -0,0 +1,42 @@ +--- +// Large legend above a chart: a read-latency card where each percentile is a +// `size="lg"` button item — colored dot and name on the first line, the latest +// value with its unit below — in a `divided` list. The fake cursor rests on the +// active item so the tinted hover background is part of the picture. Every +// series stays on, because the chart draws all four. `legend-latency` is a four-series line chart in +// charts.json with the ApexCharts legend left off, since the items replace it. +import ScreenshotLayout from '../layouts/ScreenshotLayout.astro' +import Card from '@ui/Card.astro' +import CardHeader from '@ui/CardHeader.astro' +import CardBody from '@ui/CardBody.astro' +import Legend from '@ui/Legend.astro' +import LegendList from '@ui/LegendList.astro' +import Chart from '@components/demo/Chart.astro' + +// Series order and colors follow the `legend-latency` entry in charts.json. +const series = [ + { name: 'P99', color: 'primary', value: 124 }, + { name: 'P95', color: 'azure', value: 76, active: true }, + { name: 'P75', color: 'green', value: 32 }, + { name: 'P50', color: 'yellow', value: 10 }, +] +--- + + + + + + + { + series.map((item) => ( + + {item.name} + {item.active && } + + )) + } + + + + + diff --git a/shared/components/demo/Chart.astro b/shared/components/demo/Chart.astro index 6a096d11d..ca35756cb 100644 --- a/shared/components/demo/Chart.astro +++ b/shared/components/demo/Chart.astro @@ -10,9 +10,11 @@ interface Props { class?: string; height?: number; label: string; + /** overrides the `legend` flag from charts.json, e.g. `false` when the page renders its own legend */ + legend?: boolean | undefined; } -const { chartId, id = chartId, size, class: classProp, height: heightProp, label } = Astro.props; +const { chartId, id = chartId, size, class: classProp, height: heightProp, label, legend } = Astro.props; const chartsData = charts as Record; let data = chartsData[chartId]; @@ -23,6 +25,10 @@ if (data?.extend) { data = { ...chartsData[data.extend], ...chartsData[chartId] }; } +if (data && legend !== undefined) { + data = { ...data, legend }; +} + let height = heightProp ?? data?.height ?? 10; if (size === 'sm') { @@ -36,7 +42,7 @@ if (size === 'sm') { // The :root custom-property block stays a plain string rendered via set:html — it's // inert CSS text (no injection surface), not executable code. const style = data ? chartStyle({ id, data }) : ''; -const { config, xFormatterExpr } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatterExpr: undefined }; +const { config, xFormatterExpr, yFormatterExpr } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatterExpr: undefined, yFormatterExpr: undefined }; const chartKey = `chart-${id}`; const elementId = `chart-${id}`; --- @@ -48,7 +54,7 @@ const elementId = `chart-${id}`; -