From be30919bb29b940589fa36b144aab51f97e2e9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:59:56 +0200 Subject: [PATCH] Add legend component with interactive items (#3047) --- .changeset/legend-component.md | 7 + core/scss/_variables.scss | 11 +- core/scss/ui/_legend.scss | 105 ++++++++- docs/content/ui/components/legend.mdx | 109 +++++++++ docs/content/ui/plugins/chart.mdx | 14 +- preview/pages/charts.astro | 58 +++++ preview/pages/legend.astro | 219 ++++++++++++++++++ screenshots/layouts/ScreenshotLayout.astro | 8 +- screenshots/pages/legend-list.astro | 35 +++ screenshots/pages/legend.astro | 45 ++++ shared/components/HomepageContent.astro | 30 ++- shared/components/cards/StorageUsage.astro | 31 +-- shared/components/cards/TasksDue.astro | 2 +- .../components/cards/charts/MrrOverview.astro | 16 +- .../cards/charts/SalesOverview.astro | 9 +- shared/components/demo/ScaleLegend.astro | 2 +- shared/data/charts.json | 37 ++- shared/data/docs.json | 4 + shared/data/menu.json | 4 + shared/ui/Legend.astro | 37 ++- shared/ui/LegendList.astro | 17 ++ shared/ui/Tag.astro | 2 +- 22 files changed, 727 insertions(+), 75 deletions(-) create mode 100644 .changeset/legend-component.md create mode 100644 docs/content/ui/components/legend.mdx create mode 100644 preview/pages/legend.astro create mode 100644 screenshots/pages/legend-list.astro create mode 100644 screenshots/pages/legend.astro create mode 100644 shared/ui/LegendList.astro diff --git a/.changeset/legend-component.md b/.changeset/legend-component.md new file mode 100644 index 000000000..4f26803ab --- /dev/null +++ b/.changeset/legend-component.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added `.legend` component with `.legend-dot`, `.legend-value`, `.legend-off` and `.legend-list`, plus `Legend` Astro component. diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 445f4e0e4..4b7caea3f 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -2120,12 +2120,19 @@ $form-invalid-color: $form-feedback-invalid-color !default; $form-invalid-border-color: $form-feedback-invalid-color !default; // Legend +// scss-docs-start legend-variables $legend-margin-bottom: 0.5rem !default; $legend-font-size: 1.5rem !default; $legend-font-weight: null !default; $legend-bg: var(--border-color) !default; -$legend-size: 0.75em !default; -$legend-border-radius: var(--border-radius-sm) !default; +$legend-size: 0.5rem !default; +$legend-border-radius: var(--border-radius-pill) !default; +$legend-gap: 0.5rem !default; +$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; +// scss-docs-end legend-variables // Offcanvas // scss-docs-start offcanvas-variables diff --git a/core/scss/ui/_legend.scss b/core/scss/ui/_legend.scss index fc93318ec..65bca0f2a 100644 --- a/core/scss/ui/_legend.scss +++ b/core/scss/ui/_legend.scss @@ -1,14 +1,115 @@ /** Legend */ +@use 'sass:map'; + @use '../config' as *; -.legend { +// +// Legend dot +// +.legend-dot { --legend-size: #{$legend-size}; + --legend-color: #{$legend-bg}; display: inline-block; + flex-shrink: 0; width: var(--legend-size); height: var(--legend-size); - background: $legend-bg; + background: var(--legend-color); border: 1px solid var(--border-color-translucent); @include border-radius($legend-border-radius); } + +// +// Legend +// +.legend { + --legend-color: #{$legend-bg}; + --legend-gap: #{$legend-gap}; + --legend-padding-y: #{$legend-padding-y}; + --legend-padding-x: #{$legend-padding-x}; + --legend-hover-bg: #{color-transparent(var(--legend-color), 0.1)}; + display: inline-flex; + gap: var(--legend-gap); + align-items: center; + padding: var(--legend-padding-y) 0; + margin: 0; + font: inherit; + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--body-color); + text-decoration: none; + white-space: nowrap; + background: transparent; + border: 0; + transition: + background-color $transition-time, + opacity $transition-time; + @include border-radius(var(--border-radius-pill)); + + .legend-dot { + --legend-color: inherit; + } + + &:focus:not(:focus-visible) { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; + } +} + +a.legend, +button.legend { + padding-inline: var(--legend-padding-x); + cursor: pointer; + + &:hover, + &.active { + color: var(--body-color); + text-decoration: none; + background: var(--legend-hover-bg); + } +} + +.legend-off { + opacity: 0.5; + + .legend-dot { + --legend-color: #{$legend-bg}; + } +} + +.legend-value { + font-variant-numeric: tabular-nums; + color: var(--secondary); +} + +@each $name, $color in map.merge($theme-colors, $social-colors) { + .legend-#{$name} { + --legend-color: var(--#{$name}); + } +} + +// +// Legend list +// +.legend-list { + --legend-list-gap: #{$legend-list-gap}; + display: flex; + flex-wrap: wrap; + gap: var(--legend-list-gap); + align-items: center; + + // Links and buttons carry their own horizontal padding, so the list can be tighter + &:has(> a.legend, > button.legend) { + --legend-list-gap: #{$legend-list-gap-interactive}; + } +} + +.legend-list-vertical { + flex-direction: column; + align-items: flex-start; +} diff --git a/docs/content/ui/components/legend.mdx b/docs/content/ui/components/legend.mdx new file mode 100644 index 000000000..1985863e1 --- /dev/null +++ b/docs/content/ui/components/legend.mdx @@ -0,0 +1,109 @@ +--- +title: Legend +summary: A legend labels the series of a chart or a progress bar with a colored marker, a name and an optional value. +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 +classnames: + component: + - class: legend-dot + desc: Small square marker in the series color + - class: legend + desc: A marker with a label, on a span, link or button + - class: legend-list + desc: Wraps legend items in a row + part: + - class: legend-value + desc: Muted value next to the label + style: + - class: legend-list-vertical + desc: Stacks the items in a column + state: + - class: active + desc: Keeps the hover background on a link or button + - class: legend-off + desc: Dims the item and greys the marker, for a hidden series + color: + - class: legend-{color} + desc: Any base color, for example legend-blue or legend-primary, on an item or on a marker +--- +import Example from '@components/Example.astro'; +import CodeDocs from '@components/CodeDocs.astro'; + +## Legend marker + +A `legend-dot` is a small square in the color of a series. Use it on its own next to a label. Set the color with a `legend-{color}` class. Full list of available colors can be found in the [Colors](/ui/base/colors) section. + + + + + + + + + + + + + + + + +## Legend item + +Wrap the marker and the label in a `legend`. Put the color class on the item, so the marker and the hover background share it. Add a `legend-value` for a number next to the label. + + +
+ Allowed 339.0k + Denied 1.2k + Challenged 815 +
+
+ +## Interactive legend + +Use a `button` or a link when clicking an item should toggle a series. The item gets a tinted background on hover, mixed from the series color. Add `active` to keep it, and `legend-off` to mark a hidden series. + + +
+ + + +
+
+ +With ApexCharts, hide the built-in legend and call `toggleSeries` with the series name: + +```html + +``` + +```js +document.querySelectorAll('.legend').forEach((item) => { + item.addEventListener('click', () => { + chart.toggleSeries(item.dataset.series) + item.classList.toggle('legend-off') + }) +}) +``` + +## Vertical list + +Add `legend-list-vertical` to stack the items, for example next to a donut chart. + + +
+ This year $92,400 + Last year $84,300 + Forecast $98,000 +
+
+ +## Variables + +Use these SCSS variables to customize legends. The default values are: + + diff --git a/docs/content/ui/plugins/chart.mdx b/docs/content/ui/plugins/chart.mdx index 7988687f7..7f6d6027b 100644 --- a/docs/content/ui/plugins/chart.mdx +++ b/docs/content/ui/plugins/chart.mdx @@ -377,18 +377,12 @@ Charts that share a `chart.group` name move together. Hover one and the others s ## Legend key -ApexCharts draws its own legend, but you can also build a plain-HTML legend next to a chart's title - for example to label the series above the chart instead of below it. Add a `legend` span before each label and color it with a `bg-*` utility class. +ApexCharts draws its own legend, but you can also build a plain-HTML legend next to a chart's title - for example to label the series above the chart instead of below it. Use the [Legend](/ui/components/legend) component: a `legend-list` of `legend` elements, colored with `legend-{color}`. -
-
- - This year -
-
- - Last year -
+
+ This year + Last year
diff --git a/preview/pages/charts.astro b/preview/pages/charts.astro index 6a90dcebf..c6d373e79 100644 --- a/preview/pages/charts.astro +++ b/preview/pages/charts.astro @@ -9,6 +9,9 @@ import CardBody from '@ui/CardBody.astro' import chartsData from '@data/charts.json' import { capitalize } from '@shared/lib/string-format' import DocsLink from '@ui/DocsLink.astro' +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) --- @@ -20,6 +23,61 @@ const demoCharts = Object.entries(chartsData as Record
+
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
diff --git a/preview/pages/legend.astro b/preview/pages/legend.astro new file mode 100644 index 000000000..f1403c801 --- /dev/null +++ b/preview/pages/legend.astro @@ -0,0 +1,219 @@ +--- +import DefaultLayout from '@shared/layouts/DefaultLayout.astro' +import CaptureScript from '@shared/components/CaptureScript.astro' +import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' +import CardHeader from '@ui/CardHeader.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' +import Legend from '@ui/Legend.astro' +import LegendList from '@ui/LegendList.astro' +import Subheader from '@ui/Subheader.astro' +import DocsLink from '@ui/DocsLink.astro' +import Chart from '@components/demo/Chart.astro' +import site from '@data/site.json' +import { ucFirst } from '@shared/lib/string-format' + +const colors = Object.keys(site.colors) + +const traffic = [ + { 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%' }, +] + +const requests = [ + { name: 'Allowed', color: 'blue', value: '339.0k' }, + { name: 'Denied', color: 'red', value: '1.2k' }, + { name: 'Challenged', color: 'orange', value: '815' }, + { name: 'Logged', color: 'purple', value: '-' }, + { name: 'Rate limited', color: 'teal', value: '-' }, +] + +const storage = [ + { name: 'Documents', color: 'primary', value: '3.5 GB', width: 44 }, + { name: 'Media', color: 'info', value: '1.5 GB', width: 19 }, + { name: 'Shared', color: 'success', value: '0.7 GB', width: 9 }, +] +--- + + + + +
+
+ + + Basic + A Legend is a marker and a label. Set the series color with color. + + + + + + + +
+ +
+ + + With values + Add value to show a number next to the label. + + {requests.slice(0, 3).map((item) => )} + + + +
+ +
+ + + States + Links and buttons get a tinted background on hover. Use active to keep it and off for a hidden series. + + + + + + + +
+ +
+ + + Colors + Every palette color works with legend-{'{color}'}. + + {colors.map((color) => )} + + + +
+ +
+ + + Vertical + Add vertical to the list to stack the items. + + {traffic.map((item) => )} + + + +
+ +
+ + + Marker only + Use a bare legend-dot when the label lives elsewhere. +
+ {colors.map((color) => )} +
+
+
+
+ +
+ + + + + + + + + + Button items call toggleSeries() on the chart and toggle legend-off. + + + + + + + + +
+ +
+ + + Next to a donut + A vertical list beside a chart with its own legend hidden. +
+
+ +
+ + {traffic.map((item) => )} + +
+
+
+
+ +
+ + + Under a stat + A legend row explains the split behind a single number. + Requests +
341.0k
+ + {requests.map((item) => )} + +
+
+
+ +
+ + + With a progress bar + Label the segments of a progress-separated bar. +

Using 5.7 GB of 8 GB

+
+ {storage.map((item) =>
)} +
+ + {storage.map((item) => )} + + + + +
+ +
+ + + Filter links + Link items with href work as filters. Mark the current one with active. + + + + + + + + +
+
+ diff --git a/screenshots/layouts/ScreenshotLayout.astro b/screenshots/layouts/ScreenshotLayout.astro index 039848534..a8353ad30 100644 --- a/screenshots/layouts/ScreenshotLayout.astro +++ b/screenshots/layouts/ScreenshotLayout.astro @@ -16,7 +16,7 @@ interface Props { title: string /** dir="rtl" + the .rtl.css variant of tabler.css */ rtl?: boolean - /** the Tabler logo above the card (+ balancing spacer below it) */ + /** the Tabler logo at the bottom of the frame */ showLogo?: boolean /** caps the [data-screenshot-target] width at columns * 320px */ columns?: number @@ -111,11 +111,13 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file) } .screenshot-logo { + position: absolute; + bottom: 0; display: flex; align-items: center; justify-content: center; + height: 8rem; padding: 0 5rem; - flex: 1; } /* fake mouse pointer, to make the static screenshot read as a live interaction */ @@ -135,8 +137,6 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
- {showLogo &&
diff --git a/shared/components/cards/TasksDue.astro b/shared/components/cards/TasksDue.astro index 6fff3b172..64ac0c7ec 100644 --- a/shared/components/cards/TasksDue.astro +++ b/shared/components/cards/TasksDue.astro @@ -35,7 +35,7 @@ const tasks = crmDashboard.tasks_due
{item.text}
- + {item.due}
diff --git a/shared/components/cards/charts/MrrOverview.astro b/shared/components/cards/charts/MrrOverview.astro index d0d03622e..d9da7bb83 100644 --- a/shared/components/cards/charts/MrrOverview.astro +++ b/shared/components/cards/charts/MrrOverview.astro @@ -1,6 +1,8 @@ --- import Subheader from '@ui/Subheader.astro' import Chart from '@shared/components/demo/Chart.astro' +import LegendList from '@ui/LegendList.astro' +import Legend from '@ui/Legend.astro' ---
@@ -14,16 +16,10 @@ import Chart from '@shared/components/demo/Chart.astro'
-
-
- - This year -
-
- - Last year -
-
+ + + + diff --git a/shared/components/cards/charts/SalesOverview.astro b/shared/components/cards/charts/SalesOverview.astro index 0f3cc7cf4..6bff43494 100644 --- a/shared/components/cards/charts/SalesOverview.astro +++ b/shared/components/cards/charts/SalesOverview.astro @@ -1,6 +1,7 @@ --- import Subheader from '@ui/Subheader.astro' import Chart from '@shared/components/demo/Chart.astro' +import LegendList from '@ui/LegendList.astro' import Legend from '@ui/Legend.astro' --- @@ -15,10 +16,10 @@ import Legend from '@ui/Legend.astro' -
- This year - Last year -
+ + + + diff --git a/shared/components/demo/ScaleLegend.astro b/shared/components/demo/ScaleLegend.astro index 76d89844e..8a5394574 100644 --- a/shared/components/demo/ScaleLegend.astro +++ b/shared/components/demo/ScaleLegend.astro @@ -18,7 +18,7 @@ const items = range(1, steps)
{minLabel}
- {items.map((step) => )} + {items.map((step) => )}
{maxLabel}
diff --git a/shared/data/charts.json b/shared/data/charts.json index 5c04ff1d9..10421c76b 100644 --- a/shared/data/charts.json +++ b/shared/data/charts.json @@ -117,6 +117,25 @@ } ] }, + "legend-demo": { + "extend": "demo-line", + "legend": false + }, + "legend-requests": { + "type": "bar", + "stacked": true, + "datetime": true, + "series": [ + { "name": "Allowed", "color": "blue", "data": [42, 48, 45, 52, 56, 50, 58, 62, 57, 64, 68, 72] }, + { "name": "Challenged", "color": "azure", "data": [14, 18, 16, 22, 19, 25, 21, 28, 24, 31, 27, 34] }, + { "name": "Logged", "color": "green", "data": [20, 24, 22, 28, 31, 27, 34, 37, 33, 41, 44, 48] } + ] + }, + "legend-donut": { + "extend": "traffic-sources-donut", + "legend": false, + "sparkline": true + }, "demo-line": { "type": "line", "datetime": true, @@ -959,12 +978,8 @@ "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], "stroke-curve": "straight", "show-markers": true, - "y-axes": [ - { "title": "Uptime (%)", "min": 99, "max": 100 } - ], - "annotations-y": [ - { "value": 99.9, "label": "SLA 99.9%", "color": "red" } - ], + "y-axes": [{ "title": "Uptime (%)", "min": 99, "max": 100 }], + "annotations-y": [{ "value": 99.9, "label": "SLA 99.9%", "color": "red" }], "series": [ { "name": "Uptime", @@ -997,7 +1012,10 @@ { "name": "Sessions", "color": "primary", - "data": [1847, 1768, 1792, 2003, 2081, 2099, 2261, 2441, 2268, 2483, 2367, 2245, 2458, 2278, 2236, 2044, 1871, 1950, 2134, 2270, 2377, 2200, 2255, 2260, 2141, 2312, 2137, 2270, 2128, 1935, 2032, 2138, 2297, 2436, 2583, 2691, 2502, 2441, 2300, 2373, 2329, 2251, 2379, 2307, 2470, 2500, 2380, 2370, 2408, 2378, 2299, 2448, 2496, 2441, 2538, 2581, 2544, 2581, 2477, 2309, 2354, 2370, 2357, 2277, 2263, 2092, 2212, 2093, 1940, 2093, 2143, 2198, 2385, 2286, 2284, 2245, 2306, 2388, 2281, 2158, 2213, 2009, 1924, 2047, 2164, 2142, 2092, 2102, 2005, 1923] + "data": [ + 1847, 1768, 1792, 2003, 2081, 2099, 2261, 2441, 2268, 2483, 2367, 2245, 2458, 2278, 2236, 2044, 1871, 1950, 2134, 2270, 2377, 2200, 2255, 2260, 2141, 2312, 2137, 2270, 2128, 1935, 2032, 2138, 2297, 2436, 2583, 2691, 2502, 2441, 2300, 2373, 2329, 2251, 2379, 2307, 2470, 2500, 2380, 2370, 2408, 2378, 2299, + 2448, 2496, 2441, 2538, 2581, 2544, 2581, 2477, 2309, 2354, 2370, 2357, 2277, 2263, 2092, 2212, 2093, 1940, 2093, 2143, 2198, 2385, 2286, 2284, 2245, 2306, 2388, 2281, 2158, 2213, 2009, 1924, 2047, 2164, 2142, 2092, 2102, 2005, 1923 + ] } ] }, @@ -1012,7 +1030,10 @@ { "name": "Sessions", "color": "primary", - "data": [1847, 1768, 1792, 2003, 2081, 2099, 2261, 2441, 2268, 2483, 2367, 2245, 2458, 2278, 2236, 2044, 1871, 1950, 2134, 2270, 2377, 2200, 2255, 2260, 2141, 2312, 2137, 2270, 2128, 1935, 2032, 2138, 2297, 2436, 2583, 2691, 2502, 2441, 2300, 2373, 2329, 2251, 2379, 2307, 2470, 2500, 2380, 2370, 2408, 2378, 2299, 2448, 2496, 2441, 2538, 2581, 2544, 2581, 2477, 2309, 2354, 2370, 2357, 2277, 2263, 2092, 2212, 2093, 1940, 2093, 2143, 2198, 2385, 2286, 2284, 2245, 2306, 2388, 2281, 2158, 2213, 2009, 1924, 2047, 2164, 2142, 2092, 2102, 2005, 1923] + "data": [ + 1847, 1768, 1792, 2003, 2081, 2099, 2261, 2441, 2268, 2483, 2367, 2245, 2458, 2278, 2236, 2044, 1871, 1950, 2134, 2270, 2377, 2200, 2255, 2260, 2141, 2312, 2137, 2270, 2128, 1935, 2032, 2138, 2297, 2436, 2583, 2691, 2502, 2441, 2300, 2373, 2329, 2251, 2379, 2307, 2470, 2500, 2380, 2370, 2408, 2378, 2299, + 2448, 2496, 2441, 2538, 2581, 2544, 2581, 2477, 2309, 2354, 2370, 2357, 2277, 2263, 2092, 2212, 2093, 1940, 2093, 2143, 2198, 2385, 2286, 2284, 2245, 2306, 2388, 2281, 2158, 2213, 2009, 1924, 2047, 2164, 2142, 2092, 2102, 2005, 1923 + ] } ] }, diff --git a/shared/data/docs.json b/shared/data/docs.json index 72184740e..e23270d0a 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -212,6 +212,10 @@ "title": "Icon", "url": "/ui/components/icon" }, + { + "title": "Legend", + "url": "/ui/components/legend" + }, { "title": "List group", "url": "/ui/components/list-group" diff --git a/shared/data/menu.json b/shared/data/menu.json index b1c9bca0f..a5607f604 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -160,6 +160,10 @@ } } }, + "legend": { + "url": "legend.html", + "title": "Legend" + }, "lists": { "title": "Lists", "url": "lists.html" diff --git a/shared/ui/Legend.astro b/shared/ui/Legend.astro index fe565081d..92940951d 100644 --- a/shared/ui/Legend.astro +++ b/shared/ui/Legend.astro @@ -1,15 +1,36 @@ --- -// A single legend item: a colored .legend dot + label, e.g. the "This year" / -// "Last year" chart legend in SalesOverview.astro. +// Legend (.legend): marker + label, with an optional value. +// Renders a link when `href` is given, a button when `button` is set, a span otherwise. + interface Props { - color?: string + /** label text; the default slot is used instead when empty */ + text?: string + /** legend-value next to the label */ + value?: string | number | undefined + /** legend-{color} on the item, shared by the marker and the hover background */ + color?: string | undefined + /** render a