diff --git a/CHANGELOG.md b/CHANGELOG.md index 8526076c5..1c65e3505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Added a `toast` component with plain-text or Markdown content, icons, colors, six screen placements, configurable auto-dismiss timing, optional manual dismissal, URL-fragment triggers, and automatic stacking of queued notifications. - `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined. - Form `options_source` URLs now preserve existing query parameters when adding the dynamic `search` parameter. + - `column` charts now display vertical bars instead of nothing at all. + - `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart. + - Map coordinates that are not a pair of numbers, like a latitude with no longitude, are now ignored instead of breaking the whole map. + - Screen readers now announce the title of the modal component instead of an unnamed dialog. ## v0.45 diff --git a/biome.json b/biome.json index 874cb3e21..3ffd84277 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,8 @@ "!**/*.svg", "!examples/official-site/pgconf", "!tests/end-to-end/test-results", - "!.zed/*.json" + "!.zed/*.json", + "!.claude/*.json" ], "ignoreUnknown": true }, diff --git a/examples/CRUD - Authentication/www/css/style.css b/examples/CRUD - Authentication/www/css/style.css index a6bace37d..e34f5f082 100644 --- a/examples/CRUD - Authentication/www/css/style.css +++ b/examples/CRUD - Authentication/www/css/style.css @@ -1,9 +1,9 @@ .menu_options_slim { - min-width: inherit !important; + min-width: inherit; } .menu_language_slim { - min-width: inherit !important; + min-width: inherit; } .menu_language { @@ -11,13 +11,13 @@ } div.dropdown-menu- { - min-width: inherit !important; + min-width: inherit; } a.dropdown-item- { - min-width: inherit !important; + min-width: inherit; } .slim_item { - min-width: inherit !important; + min-width: inherit; } diff --git a/examples/official-site/assets/highlightjs-and-tabler-theme.css b/examples/official-site/assets/highlightjs-and-tabler-theme.css index e7d72eb0f..24064eacf 100644 --- a/examples/official-site/assets/highlightjs-and-tabler-theme.css +++ b/examples/official-site/assets/highlightjs-and-tabler-theme.css @@ -60,8 +60,8 @@ --tblr-red: var(--tblr-danger); /* Luminous links */ - --tblr-link-color: hsl(212, 70%, 75%) !important; /* Star glow */ - --tblr-link-hover-color: hsl(212, 70%, 85%) !important; /* Supernova */ + --tblr-link-color: hsl(212, 70%, 75%); /* Star glow */ + --tblr-link-hover-color: hsl(212, 70%, 85%); /* Supernova */ --tblr-carousel-caption-color: var(--tblr-muted-color); /* Ethereal shadows */ @@ -241,7 +241,8 @@ pre:has(code) { @media print { pre:has(code) { + /* biome-ignore lint/complexity/noImportantStyles: .markdown pre is more specific and caps the height at 20rem */ max-height: none !important; - box-shadow: none !important; + box-shadow: none; } } diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index 5f1ee7ca1..93f032195 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -650,7 +650,7 @@ INSERT INTO component(name, icon, description) VALUES INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'chart', * FROM (VALUES -- top level ('title', 'The name of the chart.', 'TEXT', TRUE, TRUE), - ('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar"', 'TEXT', TRUE, FALSE), + ('type', 'The type of chart. One of: "line", "area", "bar", "column", "pie", "scatter", "bubble", "heatmap", "rangeBar". "column" is a synonym of "bar".', 'TEXT', TRUE, FALSE), ('time', 'Whether the x-axis represents time. If set to true, the x values will be parsed and formatted as dates for the user.', 'BOOLEAN', TRUE, TRUE), ('xmin', 'The minimal value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), ('xmax', 'The maximum value for the x-axis. When time is true, this can be a date or timestamp.', 'TEXT', TRUE, TRUE), @@ -664,7 +664,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('marker', 'Marker size', 'REAL', TRUE, TRUE), ('labels', 'Whether to show the data labels on the chart or not.', 'BOOLEAN', TRUE, TRUE), ('color', 'The name of a color in which to display the chart. If there are multiple series in the chart, this parameter can be repeated multiple times.', 'COLOR', TRUE, TRUE), - ('stacked', 'Whether to cumulate values from different series.', 'BOOLEAN', TRUE, TRUE), + ('stacked', 'Whether to cumulate values from different series. Supported by the "line", "area" and "bar" chart types, and ignored by the others.', 'BOOLEAN', TRUE, TRUE), ('toolbar', 'Whether to display a toolbar at the top right of the chart, that offers downloading the data as CSV.', 'BOOLEAN', TRUE, TRUE), ('show_legend', 'Whether to display the legend listing all chart series. Defaults to true.', 'BOOLEAN', TRUE, TRUE), ('logarithmic', 'Display the y-axis in logarithmic scale.', 'BOOLEAN', TRUE, TRUE), diff --git a/examples/rich-text-editor/rich_text_editor.js b/examples/rich-text-editor/rich_text_editor.js index 335933430..3a12aa4bd 100644 --- a/examples/rich-text-editor/rich_text_editor.js +++ b/examples/rich-text-editor/rich_text_editor.js @@ -141,7 +141,7 @@ function markdownToDelta(markdown) { */ function mdastToDelta(tree) { const delta = { ops: [] }; - if (!tree || !tree.children) return delta; + if (!tree?.children) return delta; for (const node of tree.children) { traverseMdastNode(node, delta); @@ -230,7 +230,6 @@ function traverseMdastNode(node, delta, attributes = {}) { break; case "listItem": { - // biome-ignore lint/correctness/noUnusedVariables: object destructuring with a spread const { list, ...listItemChildrenAttributes } = attributes; for (const child of node.children || []) { diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index f4fc1bcd3..e5ee4efe5 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -36,6 +36,9 @@ sqlpage_chart = (() => { ); const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; + const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; + const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; + /** @typedef { { [name:string]: {data:{x:number|string|Date,y:number}[], name:string} } } Series */ /** @@ -98,6 +101,10 @@ sqlpage_chart = (() => { const chartContainer = c.querySelector(".chart"); chartContainer.innerHTML = ""; const is_timeseries = !!data.time; + const chart_type = + APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line"; + const is_stacked = + !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); /** @type { Series } */ const series_map = {}; for (const [name, old_x, old_y, z] of data.points) { @@ -106,7 +113,7 @@ sqlpage_chart = (() => { let y = old_y; if (is_timeseries) { if (typeof x === "number") x = new Date(x * 1000); - else if (data.type === "rangeBar" && Array.isArray(y)) + else if (chart_type === "rangeBar" && Array.isArray(y)) y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } @@ -128,13 +135,12 @@ sqlpage_chart = (() => { let labels; const categories = series.length > 0 && typeof series[0].data[0].x === "string"; - if (data.type === "pie") { + if (chart_type === "pie") { labels = data.points.map(([name, x, _y]) => x || name); series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); - } else if (categories && data.type === "bar" && series.length > 1) + } else if (categories && chart_type === "bar" && series.length > 1) series = align_categories(series); - const chart_type = data.type || "line"; const options = { chart: { type: chart_type, @@ -142,7 +148,7 @@ sqlpage_chart = (() => { background: "transparent", parentHeightOffset: 0, height: chartContainer.style.height, - stacked: !!data.stacked, + stacked: is_stacked, toolbar: { show: !!data.toolbar, }, @@ -167,15 +173,15 @@ sqlpage_chart = (() => { color: "var(--tblr-primary-bg-subtle)", }, formatter: - data.type === "rangeBar" + chart_type === "rangeBar" ? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name - : data.type === "pie" + : chart_type === "pie" ? (value, { seriesIndex, w }) => `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` : (value) => value?.toLocaleString?.() || value, }, fill: { - type: data.type === "area" ? "gradient" : "solid", + type: chart_type === "area" ? "gradient" : "solid", }, stroke: { width: @@ -225,13 +231,13 @@ sqlpage_chart = (() => { tooltip: { fillSeriesColor: false, custom: - data.type === "bubble" || data.type === "scatter" + chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, y: { formatter: (value) => { if (value == null) return ""; - if (is_timeseries && data.type === "rangeBar") { + if (is_timeseries && chart_type === "rangeBar") { const d = new Date(value); if (d.getHours() === 0 && d.getMinutes() === 0) return d.toLocaleDateString(); @@ -246,7 +252,7 @@ sqlpage_chart = (() => { }, plotOptions: { bar: { - horizontal: !!data.horizontal || data.type === "rangeBar", + horizontal: !!data.horizontal || chart_type === "rangeBar", borderRadius: 5, }, bubble: { minBubbleRadius: 5 }, @@ -257,7 +263,6 @@ sqlpage_chart = (() => { if (labels) options.labels = labels; // tickamount is the number of intervals, not the number of ticks if (data.xticks) options.xaxis.tickAmount = data.xticks; - console.log("Rendering chart", options); const chart = new ApexCharts(chartContainer, options); chart.render(); if (window.charts) window.charts.push(chart); diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css index f1c32cdd3..a6e0b769a 100644 --- a/sqlpage/sqlpage.css +++ b/sqlpage/sqlpage.css @@ -19,6 +19,7 @@ td > p { /** Removes the margin-bottom from the last element */ .remove-bottom-margin > :last-child { + /* biome-ignore lint/complexity/noImportantStyles: the last child is often a tabler spacing utility such as .my-2, which is itself !important */ margin-bottom: 0 !important; } @@ -29,7 +30,7 @@ td > p { /* orchidjs/tom-select#712 */ .ts-wrapper.multi .ts-control > div.active { - border: 1px solid transparent !important; + border: 1px solid transparent; } /* remove the ugly text highlight in the default tom-select */ @@ -55,7 +56,7 @@ code { .apexcharts-text, .apexcharts-datalabel { - fill: var(--tblr-body-color) !important; + fill: var(--tblr-body-color); font-weight: var(--tblr-body-font-weight); } @@ -117,6 +118,7 @@ li p { } .leaflet-container { + /* biome-ignore lint/complexity/noImportantStyles: leaflet's stylesheet is injected after this one, at the same specificity */ background: var(--tblr-active-bg) !important; } @@ -189,7 +191,7 @@ See https://github.com/tabler/tabler/issues/2404 } .text-black-fg { - color: var(--tblr-dark-fg) !important; + color: var(--tblr-dark-fg); } .toast-colored .toast-description a { diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index 82e26f649..d029ef237 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -182,12 +182,14 @@ function sqlpage_map() { onLeafletLoad(); } /** - * * @param {string|undefined} coords * @returns {[number, number] | undefined} */ function parseCoords(coords) { - return coords?.split(",", 2).map((c) => Number.parseFloat(c)); + const parsed = coords?.split(",", 2).map((c) => Number.parseFloat(c)); + if (parsed?.length !== 2 || !parsed.every(Number.isFinite)) + return undefined; + return [parsed[0], parsed[1]]; } function onLeafletLoad() { is_leaflet_loaded = true; @@ -230,6 +232,7 @@ function sqlpage_map() { const marker = dataset.coords ? createMarker(marker_elem, options) : createGeoJSONMarker(marker_elem, options); + if (!marker) return; marker.addTo(map); map._sqlpage_markers.push(marker); if (marker_elem.textContent.trim()) marker.bindPopup(marker_elem); @@ -241,6 +244,7 @@ function sqlpage_map() { } function createMarker(marker_elem, options) { const coords = parseCoords(marker_elem.dataset.coords); + if (!coords) return undefined; const icon_obj = marker_elem.getElementsByClassName("mapicon")[0]; if (icon_obj) { const size = @@ -441,7 +445,7 @@ function open_modal_for_hash() { const hash = window.location.hash.substring(1); if (!hash) return; const modal = document.getElementById(hash); - if (!modal || !modal.classList.contains("modal")) return; + if (!modal?.classList.contains("modal")) return; const bootstrap_modal = window.tabler.bootstrap.Modal.getOrCreateInstance(modal); bootstrap_modal.show(); diff --git a/sqlpage/templates/modal.handlebars b/sqlpage/templates/modal.handlebars index 8b13ba2ee..c2ce2a763 100644 --- a/sqlpage/templates/modal.handlebars +++ b/sqlpage/templates/modal.handlebars @@ -2,11 +2,11 @@ id="{{id}}" tabindex="-1" aria-hidden="false" - aria-labelledby="{{title}}"> + aria-labelledby="{{id}}-title">