diff --git a/packages/chart/index.html b/packages/chart/index.html index 4464af916e..f5e3be2c41 100644 --- a/packages/chart/index.html +++ b/packages/chart/index.html @@ -16,7 +16,42 @@ margin-top: 50px; } - #placeholder { + .controls { + max-width: 800px; + margin: 20px auto; + padding: 15px; + background-color: #fff; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .control-group { + margin: 10px 0; + } + + .control-group label { + display: flex; + align-items: center; + cursor: pointer; + font-size: 14px; + } + + .control-group input[type="checkbox"] { + margin-right: 10px; + cursor: pointer; + width: 18px; + height: 18px; + } + + .control-group .description { + margin-left: 28px; + font-size: 12px; + color: #666; + font-style: italic; + } + + #placeholder, + #placeholder2 { width: 100%; height: 500px; background-color: #fff; @@ -29,9 +64,31 @@

ESM Quick Test

-
+ +
+
+ +
+ When enabled, you can use Tab to navigate through columns and Space/Enter to select them +
+
+
+ +
+ When enabled, you can use Tab to navigate through slices and Space/Enter to select them +
+
+
+ +
+ +
+ diff --git a/packages/chart/src/Column.css b/packages/chart/src/Column.css index d731d289e1..587bb73322 100644 --- a/packages/chart/src/Column.css +++ b/packages/chart/src/Column.css @@ -9,9 +9,48 @@ .chart_Column .columnRect { stroke: transparent; - border-width: 1.5px; + border-width: 2px; } -.chart_Column .columnRect.selected { - stroke: red; +.chart_Column .dataCell.selected .columnRect { + stroke: #dc3545 !important; + stroke-width: 3px !important; + paint-order: fill stroke !important; + filter: drop-shadow(0 0 3px rgba(220, 53, 69, 0.4)); + transition: all 0.2s ease; +} + +.chart_Column .dataCell:hover .columnRect { + stroke: rgba(108, 117, 125, 0.6); + stroke-width: 2px; + filter: brightness(1.05); +} + +.chart_Column .dataCell:focus .columnRect { + stroke: #007bff !important; + stroke-width: 3px !important; + paint-order: fill stroke !important; + filter: drop-shadow(0 0 3px rgba(0, 123, 255, 0.4)); + transition: all 0.2s ease; +} + +.chart_Column .dataCell.selected:focus .columnRect { + stroke: #6f42c1 !important; + filter: drop-shadow(0 0 3px rgba(111, 66, 193, 0.5)); +} + +.chart_Column .dataCell:focus-visible { + outline: none; +} + +.chart_Column .dataCell[data-tabster-focusable] { + outline: none !important; +} + +.chart_Column .dataCell:focus { + outline: none !important; +} + +.chart_Column .dataCell:active { + outline: none !important; } diff --git a/packages/chart/src/Column.ts b/packages/chart/src/Column.ts index db8d6cf35c..9f5ac11050 100644 --- a/packages/chart/src/Column.ts +++ b/packages/chart/src/Column.ts @@ -1,8 +1,9 @@ import { INDChart, ITooltip } from "@hpcc-js/api"; -import { InputField, Text } from "@hpcc-js/common"; +import { d3Event, InputField, Text } from "@hpcc-js/common"; import { format as d3Format } from "d3-format"; import { scaleBand as d3ScaleBand } from "d3-scale"; import { local as d3Local, select as d3Select } from "d3-selection"; +// import { createTabster, getGroupper, getTabster, type Types as TabsterTypes } from "tabster"; import { XYAxis } from "./XYAxis.ts"; import "../src/Column.css"; @@ -21,6 +22,7 @@ export class Column extends XYAxis { private textLocal = d3Local(); private stackedTextLocal = d3Local(); private isHorizontal: boolean; + // protected _tabster: TabsterTypes.TabsterCore | null = null; constructor() { super(); @@ -34,6 +36,12 @@ export class Column extends XYAxis { layerEnter(host: XYAxis, element, duration: number = 250) { super.layerEnter(host, element, duration); + + // this._tabster = getTabster(element.node()?.ownerDocument?.defaultView) || createTabster(element.node()?.ownerDocument?.defaultView); + // if (this._tabster) { + // getGroupper(this._tabster); + // } + const context = this; this .tooltipHTML(function (d) { @@ -84,6 +92,27 @@ export class Column extends XYAxis { this.isHorizontal = isHorizontal; const context = this; + // Configure Tabster Groupper on the host's parent HTML div (not SVG element) + // Tabster works with HTML DOM, not SVG, so we apply to host's _parentRelativeDiv + const hostWidget = host as any; // Cast to access protected _parentRelativeDiv + if (this.tabNavigation() && hostWidget._parentRelativeDiv) { + hostWidget._parentRelativeDiv + .attr("tabindex", "0") + .attr("data-tabster", JSON.stringify({ + groupper: { + tabbability: 2 + } + })) + .attr("role", "group") + .attr("aria-label", `${this.columns()[0] || "Chart"} data`); + } else if (hostWidget._parentRelativeDiv) { + hostWidget._parentRelativeDiv + .attr("tabindex", null) + .attr("data-tabster", null) + .attr("role", null) + .attr("aria-label", null); + } + this._palette = this._palette.switch(this.paletteID()); if (this.useClonedPalette()) { this._palette = this._palette.cloneNotExists(this.paletteID() + "_" + this.id()); @@ -110,7 +139,7 @@ export class Column extends XYAxis { const columnScale = d3ScaleBand() .domain(context.layerColumns(host).filter(function (_d, idx) { return idx > 0; })) .rangeRound(isHorizontal ? [0, dataLen] : [dataLen, 0]) - .paddingInner(this.xAxisSeriesPaddingInner()) + .paddingInner(Math.max(this.xAxisSeriesPaddingInner(), 0.05)) .paddingOuter(0) ; let domainSums = []; @@ -177,6 +206,15 @@ export class Column extends XYAxis { .on("dblclick", function (d: any) { context.dblclick(host.rowToObj(d.origRow), d.column, host._selection.selected(this)); }) + .on("keydown", function (evt, d: any) { + if (context.tabNavigation()) { + const event = d3Event(); + if (event.code === "Space" || event.key === "Enter") { + event.preventDefault(); + host._selection.click(this); + } + } + }) .style("opacity", 0) .each(function (this: SVGElement, d: any) { const element = d3Select(this); @@ -193,266 +231,270 @@ export class Column extends XYAxis { .style("opacity", 1) ; const domainLength = host.yAxisStacked() ? dataLen : columnScale.bandwidth(); - columnGEnter.merge(columnGRect as any).each(function (this: SVGElement, d: any) { - const element = d3Select(this); - const domainPos = host.dataPos(dataRow[0]) + (host.yAxisStacked() ? 0 : columnScale(d.column)) + offset; - const upperValue = d.value instanceof Array ? d.value[1] : d.value; - let valueText = d.origRow[d.idx]; - if (context.showValue()) { - const dm = context.dataMeta(); - switch (context.showValueAsPercent()) { - case "series": - const seriesSum = typeof dm.sum !== "undefined" ? dm.sum : seriesSums[d.idx]; - valueText = formatPct(valueText / seriesSum); - break; - case "domain": - const domainSum = typeof dm.sum !== "undefined" ? dm.sum : domainSums[dataRowIdx]; - valueText = formatPct(valueText / domainSum); - break; - case null: - default: - valueText = d3Format(context.showValueFormat())(valueText); - break; - } - } - const upperValuePos = host.valuePos(upperValue); - const lowerValuePos = host.valuePos(d.value instanceof Array ? d.value[0] : 0); - const valuePos = Math.min(lowerValuePos, upperValuePos); - const valueLength = Math.abs(upperValuePos - lowerValuePos); - - const innerTextHeight = context.innerTextFontSize(); - const innerTextPadding = context.innerTextPadding_exists() ? context.innerTextPadding() : innerTextHeight / 2.5; - - const dataRect = context.intersectRectRect( - { - x: isHorizontal ? domainPos : valuePos, - y: isHorizontal ? valuePos : domainPos, - width: isHorizontal ? domainLength : valueLength, - height: isHorizontal ? valueLength : domainLength - }, - { - x: 0, - y: 0, - width: axisSize.width, - height: axisSize.height + columnGEnter.merge(columnGRect as any) + .attr("tabindex", context.tabNavigation() ? 0 : undefined) // Tabster Groupper manages these inner focusables + .attr("role", context.tabNavigation() ? "button" : undefined) // ARIA role for accessibility + .attr("aria-label", context.tabNavigation() ? (d: any) => `${d.column}: ${d.value instanceof Array ? d.value[1] - d.value[0] : d.value}` : undefined) // ARIA label for screen readers + .each(function (this: SVGElement, d: any) { + const element = d3Select(this); + const domainPos = host.dataPos(dataRow[0]) + (host.yAxisStacked() ? 0 : columnScale(d.column)) + offset; + const upperValue = d.value instanceof Array ? d.value[1] : d.value; + let valueText = d.origRow[d.idx]; + if (context.showValue()) { + const dm = context.dataMeta(); + switch (context.showValueAsPercent()) { + case "series": + const seriesSum = typeof dm.sum !== "undefined" ? dm.sum : seriesSums[d.idx]; + valueText = formatPct(valueText / seriesSum); + break; + case "domain": + const domainSum = typeof dm.sum !== "undefined" ? dm.sum : domainSums[dataRowIdx]; + valueText = formatPct(valueText / domainSum); + break; + case null: + default: + valueText = d3Format(context.showValueFormat())(valueText); + break; + } } - ); - - const _rects = element.select("rect").transition().duration(duration) - .style("fill", (d: any) => context.fillColor(d.row, d.column, d.value, d.origRow)) - ; - - if (isHorizontal) { - _rects - .attr("x", domainPos) - .attr("y", valuePos) - .attr("width", domainLength) - .attr("height", valueLength) + const upperValuePos = host.valuePos(upperValue); + const lowerValuePos = host.valuePos(d.value instanceof Array ? d.value[0] : 0); + const valuePos = Math.min(lowerValuePos, upperValuePos); + const valueLength = Math.abs(upperValuePos - lowerValuePos); + + const innerTextHeight = context.innerTextFontSize(); + const innerTextPadding = context.innerTextPadding_exists() ? context.innerTextPadding() : innerTextHeight / 2.5; + + const dataRect = context.intersectRectRect( + { + x: isHorizontal ? domainPos : valuePos, + y: isHorizontal ? valuePos : domainPos, + width: isHorizontal ? domainLength : valueLength, + height: isHorizontal ? valueLength : domainLength + }, + { + x: 0, + y: 0, + width: axisSize.width, + height: axisSize.height + } + ); + + const _rects = element.select("rect").transition().duration(duration) + .style("fill", (d: any) => context.fillColor(d.row, d.column, d.value, d.origRow)) ; - } else { - _rects - .attr("y", domainPos) - .attr("x", valuePos) - .attr("height", domainLength) - .attr("width", valueLength) + + if (isHorizontal) { + _rects + .attr("x", domainPos) + .attr("y", valuePos) + .attr("width", domainLength) + .attr("height", valueLength) + ; + } else { + _rects + .attr("y", domainPos) + .attr("x", valuePos) + .attr("height", domainLength) + .attr("width", valueLength) + ; + } + const _texts = element.select("text").transition().duration(duration) + .style("font-size", innerTextHeight + "px") + .style("fill", (d: any) => context.textColor(d.row, d.column, d.value, d.origRow)) ; - } - const _texts = element.select("text").transition().duration(duration) - .style("font-size", innerTextHeight + "px") - .style("fill", (d: any) => context.textColor(d.row, d.column, d.value, d.origRow)) - ; - _texts.style("font-family", context.innerTextFontFamily_exists() ? context.innerTextFontFamily() : null); + _texts.style("font-family", context.innerTextFontFamily_exists() ? context.innerTextFontFamily() : null); - const padding = context.innerTextPadding_exists() ? context.innerTextPadding() : 8; + const padding = context.innerTextPadding_exists() ? context.innerTextPadding() : 8; - const textHeightOffset = innerTextHeight / 2.7; + const textHeightOffset = innerTextHeight / 2.7; - if (isHorizontal) { // Column - const y = dataRect.y + dataRect.height - innerTextPadding; - _texts - .attr("x", domainPos + (domainLength / 2)) - .attr("y", y + textHeightOffset) - .attr("transform", `rotate(-90, ${domainPos + (domainLength / 2)}, ${y})`) - ; - } else { // Bar - _texts - .attr("x", dataRect.x + padding) - .attr("y", domainPos + (domainLength / 2) + textHeightOffset) - ; - } - _texts - .attr("height", domainLength) - .attr("width", valueLength) - ; - if (context.showInnerText()) { + if (isHorizontal) { // Column + const y = dataRect.y + dataRect.height - innerTextPadding; + _texts + .attr("x", domainPos + (domainLength / 2)) + .attr("y", y + textHeightOffset) + .attr("transform", `rotate(-90, ${domainPos + (domainLength / 2)}, ${y})`) + ; + } else { // Bar + _texts + .attr("x", dataRect.x + padding) + .attr("y", domainPos + (domainLength / 2) + textHeightOffset) + ; + } _texts - .text((d: any) => { - const innerText = context.innerText(d.origRow, d.origRow[columnLength], d.idx); - if (innerText) { - const clippedValueLength = isHorizontal ? dataRect.height : dataRect.width; - const innerTextObj = context.calcInnerText(clippedValueLength, innerText, valueText); - d.innerTextObj = innerTextObj; - - return innerTextObj.text; - } - return ""; - }) + .attr("height", domainLength) + .attr("width", valueLength) ; - } - const dataText = element.selectAll(".dataText").data(context.showValue() ? [`${upperValue}`] : []); - const dataTextEnter = dataText.enter().append("g") - .attr("class", "dataText") - .each(function (this: SVGElement, d) { - context.textLocal.set(this, new Text().target(this).colorStroke_default("transparent")); - }); - dataTextEnter.merge(dataText as any) - .each(function (this: SVGElement) { - const pos = { x: 0, y: 0 }; - const valueFontFamily = context.valueFontFamily(); - const valueFontSize = context.valueFontSize(); - const textSize = context.textSize(valueText, valueFontFamily, valueFontSize); - - const isPositive = parseFloat(valueText) >= 0; - - let valueAnchor = context.valueAnchor() ? context.valueAnchor() : isHorizontal ? "middle" : "start"; - - const leftSpace = dataRect.x; - const rightSpace = axisSize.width - (dataRect.x + dataRect.width); - const topSpace = dataRect.y; - const bottomSpace = axisSize.height - (dataRect.y + dataRect.height); - - let noRoomInside; - let isOutside; - let noRoomOnExpectedSide; - - if (d.innerTextObj) { - const { padding, valueTextWidth } = d.innerTextObj; - isOutside = false; - if (isHorizontal) { // Column - valueAnchor = "middle"; - pos.x = domainPos + (domainLength / 2); - - if (d.innerTextObj.category === 4) { - isOutside = true; - pos.y = valuePos - padding - (valueFontSize / 2); - } else { - pos.y = valuePos + padding + (valueFontSize / 2); + if (context.showInnerText()) { + _texts + .text((d: any) => { + const innerText = context.innerText(d.origRow, d.origRow[columnLength], d.idx); + if (innerText) { + const clippedValueLength = isHorizontal ? dataRect.height : dataRect.width; + const innerTextObj = context.calcInnerText(clippedValueLength, innerText, valueText); + d.innerTextObj = innerTextObj; + + return innerTextObj.text; } - } else { // Bar - valueAnchor = "start"; - if (d.innerTextObj.category === 4) { - isOutside = true; - pos.x = (valueLength + valuePos) + padding; - } else { - pos.x = (valueLength + valuePos) - valueTextWidth - padding; + return ""; + }) + ; + } + const dataText = element.selectAll(".dataText").data(context.showValue() ? [`${upperValue}`] : []); + const dataTextEnter = dataText.enter().append("g") + .attr("class", "dataText") + .each(function (this: SVGElement, d) { + context.textLocal.set(this, new Text().target(this).colorStroke_default("transparent")); + }); + dataTextEnter.merge(dataText as any) + .each(function (this: SVGElement) { + const pos = { x: 0, y: 0 }; + const valueFontFamily = context.valueFontFamily(); + const valueFontSize = context.valueFontSize(); + const textSize = context.textSize(valueText, valueFontFamily, valueFontSize); + + const isPositive = parseFloat(valueText) >= 0; + + let valueAnchor = context.valueAnchor() ? context.valueAnchor() : isHorizontal ? "middle" : "start"; + + const leftSpace = dataRect.x; + const rightSpace = axisSize.width - (dataRect.x + dataRect.width); + const topSpace = dataRect.y; + const bottomSpace = axisSize.height - (dataRect.y + dataRect.height); + + let noRoomInside; + let isOutside; + let noRoomOnExpectedSide; + + if (d.innerTextObj) { + const { padding, valueTextWidth } = d.innerTextObj; + isOutside = false; + if (isHorizontal) { // Column + valueAnchor = "middle"; + pos.x = domainPos + (domainLength / 2); + + if (d.innerTextObj.category === 4) { + isOutside = true; + pos.y = valuePos - padding - (valueFontSize / 2); + } else { + pos.y = valuePos + padding + (valueFontSize / 2); + } + } else { // Bar + valueAnchor = "start"; + if (d.innerTextObj.category === 4) { + isOutside = true; + pos.x = (valueLength + valuePos) + padding; + } else { + pos.x = (valueLength + valuePos) - valueTextWidth - padding; + } + pos.y = domainPos + (domainLength / 2); } - pos.y = domainPos + (domainLength / 2); - } - } else { - /* - IF this.valueCentered() and NO ROOM INSIDE - ...then ASSUME THERES ROOM OUTSIDE - IF NO ROOM OUTSIDE ON EXPECTED SIDE - ...then ASSUME THERES ROOM ON THE OPPOSITE SIDE - */ - if (isHorizontal) { // Column - noRoomInside = dataRect.height < textSize.height; - isOutside = !context.valueCentered() || noRoomInside; - - pos.x = dataRect.x + (dataRect.width / 2); - - if (isOutside) { - if (isPositive) { - noRoomOnExpectedSide = topSpace < textSize.height + padding; - if (noRoomOnExpectedSide) { - if (!noRoomInside) { - isOutside = false; - pos.y = dataRect.y + (dataRect.height / 2); + } else { + /* + IF this.valueCentered() and NO ROOM INSIDE + ...then ASSUME THERES ROOM OUTSIDE + IF NO ROOM OUTSIDE ON EXPECTED SIDE + ...then ASSUME THERES ROOM ON THE OPPOSITE SIDE + */ + if (isHorizontal) { // Column + noRoomInside = dataRect.height < textSize.height; + isOutside = !context.valueCentered() || noRoomInside; + + pos.x = dataRect.x + (dataRect.width / 2); + + if (isOutside) { + if (isPositive) { + noRoomOnExpectedSide = topSpace < textSize.height + padding; + if (noRoomOnExpectedSide) { + if (!noRoomInside) { + isOutside = false; + pos.y = dataRect.y + (dataRect.height / 2); + } else { + pos.y = dataRect.y + dataRect.height + textSize.height; + } } else { - pos.y = dataRect.y + dataRect.height + textSize.height; + pos.y = dataRect.y - (textSize.height / 2) - padding; } } else { - pos.y = dataRect.y - (textSize.height / 2) - padding; - } - } else { - noRoomOnExpectedSide = bottomSpace < textSize.height; - if (noRoomOnExpectedSide) { - if (!noRoomInside) { - isOutside = false; - pos.y = dataRect.y + (dataRect.height / 2); + noRoomOnExpectedSide = bottomSpace < textSize.height; + if (noRoomOnExpectedSide) { + if (!noRoomInside) { + isOutside = false; + pos.y = dataRect.y + (dataRect.height / 2); + } else { + pos.y = dataRect.y - (textSize.height / 2) - padding; + } } else { - pos.y = dataRect.y - (textSize.height / 2) - padding; + pos.y = dataRect.y + textSize.height + padding; } - } else { - pos.y = dataRect.y + textSize.height + padding; } + } else { + pos.y = dataRect.y + (dataRect.height / 2); } - } else { + } else { // Bar + noRoomInside = dataRect.width < textSize.width; + isOutside = !context.valueCentered() || noRoomInside; + pos.y = dataRect.y + (dataRect.height / 2); - } - } else { // Bar - noRoomInside = dataRect.width < textSize.width; - isOutside = !context.valueCentered() || noRoomInside; - - pos.y = dataRect.y + (dataRect.height / 2); - - if (isOutside) { - if (isPositive) { - noRoomOnExpectedSide = rightSpace < textSize.width + padding; - if (noRoomOnExpectedSide) { - if (context.showInnerText() || !noRoomInside) { - isOutside = false; - pos.x = dataRect.x + (dataRect.width / 2); + + if (isOutside) { + if (isPositive) { + noRoomOnExpectedSide = rightSpace < textSize.width + padding; + if (noRoomOnExpectedSide) { + if (context.showInnerText() || !noRoomInside) { + isOutside = false; + pos.x = dataRect.x + (dataRect.width / 2); + } else { + pos.x = dataRect.x - (textSize.width - padding); + } } else { - pos.x = dataRect.x - (textSize.width - padding); + pos.x = dataRect.x + dataRect.width + (textSize.width / 2) + padding; } } else { - pos.x = dataRect.x + dataRect.width + (textSize.width / 2) + padding; - } - } else { - noRoomOnExpectedSide = leftSpace < textSize.width; - if (noRoomOnExpectedSide) { - if (context.showInnerText() || !noRoomInside) { - isOutside = false; - pos.x = dataRect.x + (dataRect.width / 2); + noRoomOnExpectedSide = leftSpace < textSize.width; + if (noRoomOnExpectedSide) { + if (context.showInnerText() || !noRoomInside) { + isOutside = false; + pos.x = dataRect.x + (dataRect.width / 2); + } else { + pos.x = dataRect.x + dataRect.width + (textSize.width - padding); + } } else { - pos.x = dataRect.x + dataRect.width + (textSize.width - padding); + pos.x = dataRect.x - (textSize.width - padding); } - } else { - pos.x = dataRect.x - (textSize.width - padding); } + } else { + pos.x = dataRect.x + (dataRect.width / 2); } - } else { - pos.x = dataRect.x + (dataRect.width / 2); } } - } - const textColor = isOutside ? null : context.textColor(d.row, d.column, d.value, d.origRow); - - // Prevent overlapping labels on stacked columns - const columns = context.columns(); - const hideValue = (context.yAxisStacked() && noRoomInside) || - (isOutside && context.yAxisStacked() && columns.indexOf(d.column) !== columns.length - 1); - context.textLocal.get(this) - .pos(pos) - .anchor(valueAnchor) - .fontFamily(valueFontFamily) - .fontSize(valueFontSize) - .text(`${valueText}`) - .colorFill(textColor) - .visible(context.showValue() && !hideValue) - .render() - ; - - }); - dataText.exit() - .each(function (this: SVGElement, d) { - context.textLocal.get(this).target(null); - }) - .remove() - ; - }); + const textColor = isOutside ? null : context.textColor(d.row, d.column, d.value, d.origRow); + + // Prevent overlapping labels on stacked columns + const columns = context.columns(); + const hideValue = (context.yAxisStacked() && noRoomInside) || + (isOutside && context.yAxisStacked() && columns.indexOf(d.column) !== columns.length - 1); + context.textLocal.get(this) + .pos(pos) + .anchor(valueAnchor) + .fontFamily(valueFontFamily) + .fontSize(valueFontSize) + .text(`${valueText}`) + .colorFill(textColor) + .visible(context.showValue() && !hideValue) + .render() + ; + + }); + dataText.exit() + .each(function (this: SVGElement, d) { + context.textLocal.get(this).target(null); + }) + .remove() + ; + }); columnGRect.exit().transition().duration(duration) .style("opacity", 0) .remove() @@ -517,7 +559,6 @@ export class Column extends XYAxis { } calcInnerText(offset, innerText, valueText) { - const fontFamily = this.innerTextFontFamily_exists() ? this.innerTextFontFamily() : "Verdana"; const fontSize = this.innerTextFontSize(); const valueFontFamily = this.valueFontFamily_exists() ? this.valueFontFamily() : "Verdana"; @@ -627,6 +668,10 @@ export interface Column { tooltipFormat(_): string; tooltipStyle(): "default" | "none" | "series-table"; tooltipStyle(_: "default" | "none" | "series-table"): this; + + // Tab Navigation + tabNavigation(): boolean; + tabNavigation(_: boolean): this; } Column.prototype.publish("valueFontFamily", null, "string", "Font family of value text", null, { optional: true }); @@ -644,8 +689,9 @@ Column.prototype.publish("showValueAsPercentFormat", ".0%", "string", "D3 Format Column.prototype.publish("showDomainTotal", false, "boolean", "Show Total Value for Stacked Columns", null); Column.prototype.publish("valueCentered", false, "boolean", "Show Value in center of column"); Column.prototype.publish("valueAnchor", "middle", "set", "text-anchor for shown value text", ["start", "middle", "end"]); -Column.prototype.publish("xAxisSeriesPaddingInner", 0, "number", "Determines the ratio of the range that is reserved for blank space between band (0->1)"); +Column.prototype.publish("xAxisSeriesPaddingInner", 0.0, "number", "Determines the ratio of the range that is reserved for blank space between band (0->1)"); Column.prototype.publish("tooltipInnerTextEllipsedOnly", false, "boolean", "Show tooltip only when inner text is truncated with an ellipsis"); +Column.prototype.publish("tabNavigation", false, "boolean", "Enable or disable tab navigation"); /* const origUseClonedPalette = Column.prototype.useClonedPalette; diff --git a/packages/chart/src/Pie.css b/packages/chart/src/Pie.css index 3be1ede838..1a2caec935 100644 --- a/packages/chart/src/Pie.css +++ b/packages/chart/src/Pie.css @@ -7,13 +7,16 @@ } .chart_Pie .arc path { - stroke: white; - stroke-width: 0.75px; + stroke: transparent; + stroke-width: 2px; } .chart_Pie .arc.selected path { - stroke: red; - stroke-width: 1.5px; + stroke: #dc3545 !important; + stroke-width: 3px !important; + paint-order: fill stroke !important; + filter: drop-shadow(0 0 3px rgba(220, 53, 69, 0.4)); + transition: all 0.2s ease; } .chart_Pie polyline { @@ -21,4 +24,40 @@ stroke: black; stroke-width: 2px; fill: none; -} \ No newline at end of file +} + +.chart_Pie .arc:hover path { + stroke: rgba(108, 117, 125, 0.6); + stroke-width: 2px; + filter: brightness(1.05); +} + +.chart_Pie .arc:focus path { + stroke: #007bff !important; + stroke-width: 3px !important; + paint-order: fill stroke !important; + filter: drop-shadow(0 0 4px rgba(0, 123, 255, 0.4)); + transition: all 0.2s ease; +} + +.chart_Pie .arc.selected:focus path { + stroke: #6f42c1 !important; + filter: drop-shadow(0 0 5px rgba(111, 66, 193, 0.5)); +} + +.chart_Pie .arc:focus-visible { + outline: none; +} + +.chart_Pie .arc[data-tabster-focusable] { + outline: none !important; +} + +.chart_Pie .arc:focus { + outline: none !important; +} + +.chart_Pie .arc:active { + outline: none !important; +} + diff --git a/packages/chart/src/Pie.ts b/packages/chart/src/Pie.ts index 65bb825613..ea2ba1a92f 100644 --- a/packages/chart/src/Pie.ts +++ b/packages/chart/src/Pie.ts @@ -1,10 +1,11 @@ import { I2DChart, ITooltip } from "@hpcc-js/api"; -import { InputField, SVGWidget, Utility } from "@hpcc-js/common"; +import { d3Event, InputField, SVGWidget, Utility } from "@hpcc-js/common"; import { degreesToRadians, normalizeRadians } from "@hpcc-js/util"; import { format as d3Format } from "d3-format"; import { interpolate as d3Interpolate } from "d3-interpolate"; import { select as d3Select } from "d3-selection"; import { arc as d3Arc, pie as d3Pie } from "d3-shape"; +// import { createTabster, getGroupper, getTabster, getTabsterAttribute, Types, GroupperTabbabilities, setTabsterAttribute } from "tabster"; import "../src/Pie.css"; @@ -33,6 +34,9 @@ export class Pie extends SVGWidget { private _maxLabelBottom = 0; private _seriesValueFormatter; private _seriesPercentageFormatter; + // protected _tabster: Types.TabsterCore | null = null; + // protected _groupper: Types.GroupperAPI | null = null; + constructor() { super(); I2DChart.call(this); @@ -140,12 +144,23 @@ export class Pie extends SVGWidget { _slices; _labels; - enter(_domNode, element) { - super.enter(_domNode, element); - this._selection.widgetElement(element); + + enter(domNode, element) { + super.enter(domNode, element); + this._selection + .widgetElement(element) + .skipBringToTop(true) + ; + this._slices = element.append("g"); this._labels = element.append("g"); + + // Initialize Tabster and Groupper + // this._tabster = getTabster(domNode.ownerDocument?.defaultView) || createTabster(domNode.ownerDocument?.defaultView); + // this._groupper = getGroupper(this._tabster); + const context = this; + this .tooltipHTML(function (d) { switch (context.tooltipStyle()) { @@ -170,6 +185,11 @@ export class Pie extends SVGWidget { update(_domNode, element) { super.update(_domNode, element); const context = this; + // element + // .attr("tabindex", this.tabNavigation() ? "0" : null) + // ; + // setTabsterAttribute(this._parentRelativeDiv.node(), this.tabNavigation() ? { groupper: { tabbability: 2 }, focusable: {} } : {}, true); + this.updateD3Pie(); this._palette = this._palette.switch(this.paletteID()); this._seriesValueFormatter = d3Format(this.seriesValueFormat() as string); @@ -179,13 +199,19 @@ export class Pie extends SVGWidget { } this._smallValueLabelHeight = this.calcSmallValueLabelHeight(); this._totalValue = this.calcTotalValue(); - const innerRadius = this.calcInnerRadius(); const outerRadius = this.calcOuterRadius(); + const innerRadius = Math.max(this.calcInnerRadius(), Math.min(outerRadius / 30, 6)); const labelRadius = outerRadius + 12; + const paddingValue = this.slicePadding(); + + // Use a small pad angle for visual separation + const padAngleRadians = paddingValue > 0 ? Math.min(paddingValue, 0.05) : 0; + this.d3Arc .innerRadius(innerRadius) .padRadius(outerRadius) .outerRadius(outerRadius) + .padAngle(padAngleRadians) ; this._quadIdxArr = [[], [], [], []]; @@ -213,6 +239,13 @@ export class Pie extends SVGWidget { .on("dblclick", function (d) { context.dblclick(context.rowToObj(d.data), context.columns()[1], context._selection.selected(this)); }) + .on("keydown", function (evt, d) { + const event = d3Event(); + if (context.tabNavigation() && (event.code === "Space" || event.key === "Enter")) { + event.preventDefault(); + context._selection.click(this); + } + }) .each(function (d, i) { d3Select(this).append("path") .on("mouseout.tooltip", context.tooltip.hide) @@ -223,6 +256,9 @@ export class Pie extends SVGWidget { }) .merge(arc).transition() .attr("opacity", 1) + .attr("tabindex", context.tabNavigation() ? "0" : undefined) // Tabster Groupper manages these inner focusables + .attr("role", context.tabNavigation() ? "button" : undefined) // ARIA role for accessibility + .attr("aria-label", context.tabNavigation() ? (d: any) => `${d.data[0]}: ${d.data[1]}` : undefined) // ARIA label for screen readers .each(function (d, i) { const quad = context.getQuadrant(midAngle(d)); context._quadIdxArr[quad].push(i); @@ -429,7 +465,7 @@ export class Pie extends SVGWidget { } this.d3Pie - .padAngle(0.0025) + .padAngle(0) // No geometric padding - CSS-only solution .startAngle(startAngle) .endAngle(2 * Math.PI + startAngle) .value(function (d) { @@ -456,6 +492,8 @@ export interface Pie { startAngle(_: number): this; labelHeight(): number; labelHeight(_: number): this; + slicePadding(): number; + slicePadding(_: number): this; seriesPercentageFormat(): string; seriesPercentageFormat(_: string): this; showLabels(): boolean; @@ -463,10 +501,14 @@ export interface Pie { sortDataByValue(): "none" | "ascending" | "descending"; sortDataByValue(_: "none" | "ascending" | "descending"): this; - paletteID(_?: string): string | Pie; - useClonedPalette(_?: boolean): boolean | Pie; - outerText(_?: boolean): boolean | Pie; + paletteID(): string; + paletteID(_: string): this; + useClonedPalette(): boolean; + useClonedPalette(_: boolean): this; + outerText(): boolean; + outerText(_: boolean): this; innerRadius(): number; + innerRadius(_: number): this; innerRadius_exists(): boolean; // I2DChart @@ -492,6 +534,10 @@ export interface Pie { // SimpleSelectionMixin _selection: Utility.SimpleSelection; + + // Tab Navigation + tabNavigation(): boolean; + tabNavigation(_: boolean): this; } Pie.prototype.publish("showLabels", true, "boolean", "If true, wedge labels will display"); Pie.prototype.publish("showSeriesValue", false, "boolean", "Append data series value next to label", null, { disable: w => !w.showLabels() }); @@ -504,4 +550,6 @@ Pie.prototype.publish("innerRadius", 0, "number", "Sets inner pie hole radius as Pie.prototype.publish("minOuterRadius", 20, "number", "Minimum outer radius (pixels)"); Pie.prototype.publish("startAngle", 0, "number", "Starting angle of the first (and largest) wedge (degrees)"); Pie.prototype.publish("labelHeight", 12, "number", "Font size of labels (pixels)", null, { disable: w => !w.showLabels() }); +Pie.prototype.publish("slicePadding", 0.01, "number", "Padding between pie slices (converted to pixels)", null, { tags: ["Basic"], range: { min: 0, step: 0.01, max: 0.2 } }); Pie.prototype.publish("sortDataByValue", "descending", "set", "Sort data by value", ["none", "ascending", "descending"]); +Pie.prototype.publish("tabNavigation", false, "boolean", "Enable or disable tab navigation"); diff --git a/packages/dataflow/package.json b/packages/dataflow/package.json index bdbea31113..f30d156ba3 100644 --- a/packages/dataflow/package.json +++ b/packages/dataflow/package.json @@ -53,5 +53,5 @@ "bugs": { "url": "https://github.com/hpcc-systems/Visualization/issues" }, - "homepage": "https://github.com/hpcc-systems/Visualization/tree/trunk/packages/dataflow" + "homepage": "https://github.com/hpcc-systems/Visualization/tree/main/packages/dataflow" } \ No newline at end of file diff --git a/packages/markdown-it-plugins/package.json b/packages/markdown-it-plugins/package.json index 23819950f7..2c34e4a906 100644 --- a/packages/markdown-it-plugins/package.json +++ b/packages/markdown-it-plugins/package.json @@ -79,5 +79,5 @@ "bugs": { "url": "https://github.com/hpcc-systems/Visualization/issues" }, - "homepage": "https://github.com/hpcc-systems/Visualization/tree/trunk/packages/markdonw-it-plugins" + "homepage": "https://github.com/hpcc-systems/Visualization/tree/main/packages/markdonw-it-plugins" } \ No newline at end of file diff --git a/packages/observablehq-compiler/bin/ojscc.mjs b/packages/observablehq-compiler/bin/ojscc.mjs index 5df998c436..83fbe5c7e2 100755 --- a/packages/observablehq-compiler/bin/ojscc.mjs +++ b/packages/observablehq-compiler/bin/ojscc.mjs @@ -64,5 +64,5 @@ yargs ) .help("h") .alias("h", "help") - .epilog("https://github.com/hpcc-systems/Visualization/tree/trunk/packages/observablehq-compiler") + .epilog("https://github.com/hpcc-systems/Visualization/tree/main/packages/observablehq-compiler") .parse(); diff --git a/packages/observablehq-compiler/package.json b/packages/observablehq-compiler/package.json index 7f3efd2b72..05e6baede5 100644 --- a/packages/observablehq-compiler/package.json +++ b/packages/observablehq-compiler/package.json @@ -85,5 +85,5 @@ "bugs": { "url": "https://github.com/hpcc-systems/Visualization/issues" }, - "homepage": "https://github.com/hpcc-systems/Visualization/tree/trunk/packages/observablehq-compiler" + "homepage": "https://github.com/hpcc-systems/Visualization/tree/main/packages/observablehq-compiler" } \ No newline at end of file diff --git a/packages/util/package.json b/packages/util/package.json index 747479e8ed..fc0ee05871 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -53,5 +53,5 @@ "bugs": { "url": "https://github.com/hpcc-systems/Visualization/issues" }, - "homepage": "https://github.com/hpcc-systems/Visualization/tree/trunk/packages/util" + "homepage": "https://github.com/hpcc-systems/Visualization/tree/main/packages/util" } \ No newline at end of file