diff --git a/packages/chart/index.html b/packages/chart/index.html index 4464af916e..df176d63e3 100644 --- a/packages/chart/index.html +++ b/packages/chart/index.html @@ -16,7 +16,43 @@ 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, + #placeholder3 { width: 100%; height: 500px; background-color: #fff; @@ -29,9 +65,56 @@

ESM Quick Test

-
+ +
+
+ +
+ When enabled, you can use Tab to navigate through points and Space/Enter to select them +
+
+
+ + +
+ Quickly preview how scatter behaves with lines or area fill enabled +
+
+
+ +
+ 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..c9cb02a787 100644 --- a/packages/chart/src/Column.css +++ b/packages/chart/src/Column.css @@ -1,17 +1,45 @@ .chart_Column .columnRect { - fill: steelblue; - cursor: pointer; + fill: steelblue; + cursor: pointer; } .chart_Column .data.axis path { - display: none; + display: none; } .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; + 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; + transition: all 0.2s ease; +} + +.chart_Column .dataCell.selected:focus .columnRect { + stroke: #6f42c1 !important; +} + +.chart_Column .dataCell:focus-visible { + outline: none; +} + +.chart_Column .dataCell:active { + outline: none !important; +} \ No newline at end of file diff --git a/packages/chart/src/Column.ts b/packages/chart/src/Column.ts index db8d6cf35c..ec393b8217 100644 --- a/packages/chart/src/Column.ts +++ b/packages/chart/src/Column.ts @@ -1,5 +1,5 @@ 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"; @@ -34,6 +34,7 @@ export class Column extends XYAxis { layerEnter(host: XYAxis, element, duration: number = 250) { super.layerEnter(host, element, duration); + const context = this; this .tooltipHTML(function (d) { @@ -84,6 +85,18 @@ export class Column extends XYAxis { this.isHorizontal = isHorizontal; const context = this; + if (this.tabNavigation() && host.parentRelativeDiv) { + host.parentRelativeDiv + .attr("tabindex", "0") + .attr("role", "group") + .attr("aria-label", `${this.columns()[0] || "Chart"} data`); + } else if (host.parentRelativeDiv) { + host.parentRelativeDiv + .attr("tabindex", 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 +123,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 +190,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 +215,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 : null) // Tabster Groupper manages these inner focusables + .attr("role", context.tabNavigation() ? "button" : null) // ARIA role for accessibility + .attr("aria-label", context.tabNavigation() ? (d: any) => `${d.origRow[0]} - ${d.column}: ${d.value instanceof Array ? d.value[1] - d.value[0] : d.value}` : null) + .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 +543,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"; @@ -644,7 +669,7 @@ 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"); /* diff --git a/packages/chart/src/Pie.css b/packages/chart/src/Pie.css index 3be1ede838..60cce75e21 100644 --- a/packages/chart/src/Pie.css +++ b/packages/chart/src/Pie.css @@ -2,18 +2,20 @@ cursor: pointer; } -.chart_Pie > g > text { +.chart_Pie>g>text { cursor: pointer; } .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; + transition: all 0.2s ease; } .chart_Pie polyline { @@ -21,4 +23,29 @@ stroke: black; stroke-width: 2px; fill: none; +} + +.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; + transition: all 0.2s ease; +} + +.chart_Pie .arc.selected:focus path { + stroke: #6f42c1 !important; +} + +.chart_Pie .arc:focus-visible { + outline: none; +} + +.chart_Pie .arc:active { + outline: none !important; } \ No newline at end of file diff --git a/packages/chart/src/Pie.ts b/packages/chart/src/Pie.ts index 65bb825613..b35af3ac14 100644 --- a/packages/chart/src/Pie.ts +++ b/packages/chart/src/Pie.ts @@ -1,5 +1,5 @@ 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"; @@ -33,6 +33,7 @@ export class Pie extends SVGWidget { private _maxLabelBottom = 0; private _seriesValueFormatter; private _seriesPercentageFormatter; + constructor() { super(); I2DChart.call(this); @@ -91,6 +92,11 @@ export class Pie extends SVGWidget { }, 0); } + calcPadAngleRadians(): number { + const paddingValue = this.slicePadding(); + return paddingValue > 0 ? Math.min(paddingValue, 0.05) : 0; + } + getLabelText(d, truncate?) { let len; let label = d.data[0]; @@ -140,12 +146,19 @@ 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"); + const context = this; + this .tooltipHTML(function (d) { switch (context.tooltipStyle()) { @@ -168,8 +181,10 @@ export class Pie extends SVGWidget { } update(_domNode, element) { + this.selectionGlow(!this.tabNavigation()); super.update(_domNode, element); const context = this; + this.updateD3Pie(); this._palette = this._palette.switch(this.paletteID()); this._seriesValueFormatter = d3Format(this.seriesValueFormat() as string); @@ -179,13 +194,15 @@ 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; + this.d3Arc .innerRadius(innerRadius) .padRadius(outerRadius) .outerRadius(outerRadius) + .padAngle(this.calcPadAngleRadians()) ; this._quadIdxArr = [[], [], [], []]; @@ -213,6 +230,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 +247,9 @@ export class Pie extends SVGWidget { }) .merge(arc).transition() .attr("opacity", 1) + .attr("tabindex", context.tabNavigation() ? "0" : null) + .attr("role", context.tabNavigation() ? "button" : null) + .attr("aria-label", context.tabNavigation() ? (d: any) => `${d.data[0]}: ${d.data[1]}` : null) .each(function (d, i) { const quad = context.getQuadrant(midAngle(d)); context._quadIdxArr[quad].push(i); @@ -429,7 +456,7 @@ export class Pie extends SVGWidget { } this.d3Pie - .padAngle(0.0025) + .padAngle(this.calcPadAngleRadians()) .startAngle(startAngle) .endAngle(2 * Math.PI + startAngle) .value(function (d) { @@ -456,6 +483,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 +492,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 +525,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 +541,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/chart/src/Scatter.css b/packages/chart/src/Scatter.css index 234c33283a..be6a73e3b2 100644 --- a/packages/chart/src/Scatter.css +++ b/packages/chart/src/Scatter.css @@ -6,11 +6,38 @@ .chart_Scatter .point .pointSelection { fill: none; - stroke: none; + stroke: transparent; + stroke-width: 2px; pointer-events: all; + transition: all 0.2s ease; } .chart_Scatter .point .pointSelection.selected { - fill: none; - stroke: red; + stroke: #dc3545 !important; + stroke-width: 3px !important; + paint-order: fill stroke !important; +} + +.chart_Scatter .point .pointSelection:hover { + stroke: rgba(108, 117, 125, 0.6); + stroke-width: 2px; + filter: brightness(1.05); +} + +.chart_Scatter .point .pointSelection:focus { + stroke: #007bff !important; + stroke-width: 3px !important; + paint-order: fill stroke !important; } + +.chart_Scatter .point .pointSelection.selected:focus { + stroke: #6f42c1 !important; +} + +.chart_Scatter .point .pointSelection:focus-visible { + outline: none; +} + +.chart_Scatter .point .pointSelection:active { + outline: none !important; +} \ No newline at end of file diff --git a/packages/chart/src/Scatter.ts b/packages/chart/src/Scatter.ts index 9984a2c3fc..e53d683d7b 100644 --- a/packages/chart/src/Scatter.ts +++ b/packages/chart/src/Scatter.ts @@ -1,5 +1,5 @@ import { INDChart, ITooltip } from "@hpcc-js/api"; -import { InputField } from "@hpcc-js/common"; +import { d3Event, InputField } from "@hpcc-js/common"; import { extent as d3Extent } from "d3-array"; import { scaleLinear as d3ScaleLinear, scaleLog as d3ScaleLog, scalePow as d3ScalePow, scaleSqrt as d3ScaleSqrt } from "d3-scale"; import { select as d3Select } from "d3-selection"; @@ -129,6 +129,18 @@ export class Scatter extends XYAxis { const height = isHorizontal ? this.height() : this.width(); const context = this; + if (this.tabNavigation() && host.parentRelativeDiv) { + host.parentRelativeDiv + .attr("tabindex", "0") + .attr("role", "group") + .attr("aria-label", `${this.columns()[0] || "Chart"} data`); + } else if (host.parentRelativeDiv) { + host.parentRelativeDiv + .attr("tabindex", 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()); @@ -258,6 +270,15 @@ export class Scatter extends XYAxis { .on("dblclick", function (d: any, _idx) { context.dblclick(host.rowToObj(host.data()[d.rowIdx]), 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); + } + } + }) ; }) .merge(points) @@ -279,6 +300,9 @@ export class Scatter extends XYAxis { .attr("cx", function (d) { return context.xPos(host, d); }) .attr("cy", function (d) { return context.yPos(host, d); }) .attr("r", d2.size) + .attr("tabindex", context.tabNavigation() ? 0 : null) + .attr("role", context.tabNavigation() ? "button" : null) + .attr("aria-label", context.tabNavigation() ? (d: any) => `${d.column || "Value"}: ${d.value} @ ${d.label}` : null) ; const element = d3Select(this).select(".pointShape"); @@ -371,6 +395,7 @@ export interface Scatter { tooltipHTML(_): string; tooltipFormat(_): string; tooltipStyle(): "default" | "none" | "series-table"; + tooltipStyle(_: "default" | "none" | "series-table"): this; } Scatter.prototype.publish("paletteID", "default", "set", "Color palette for this widget", Scatter.prototype._palette.switch(), { tags: ["Basic", "Shared"] }); Scatter.prototype.publish("pointSizeScale", "linear", "set", "pointSizeScale", ["linear", "pow", "log", "sqrt"]); diff --git a/packages/chart/src/XYAxis.ts b/packages/chart/src/XYAxis.ts index 936daa6268..f74d646e1c 100644 --- a/packages/chart/src/XYAxis.ts +++ b/packages/chart/src/XYAxis.ts @@ -323,6 +323,7 @@ export class XYAxis extends SVGWidget { protected _prevXAxisType; update(domNode, element) { + this.selectionGlow(!this.tabNavigation()); super.update(domNode, element); const context = this; @@ -754,6 +755,10 @@ export interface XYAxis { xAxisPadding(_: number): this; yAxisPadding(): number; yAxisPadding(_: number): this; + + // Tab Navigation + tabNavigation(): boolean; + tabNavigation(_: boolean): this; } XYAxis.prototype.publish("orientation", "horizontal", "set", "Selects orientation for the axis", ["horizontal", "vertical"]); @@ -801,3 +806,4 @@ XYAxis.prototype.publish("regions", [], "array", "Regions"); XYAxis.prototype.publish("layers", [], "widgetArray", "Layers", null, { render: false }); XYAxis.prototype.publishProxy("xAxisPadding", "domainAxis", "padding"); XYAxis.prototype.publishProxy("yAxisPadding", "valueAxis", "padding"); +XYAxis.prototype.publish("tabNavigation", false, "boolean", "Enable or disable tab navigation"); diff --git a/packages/common/index.html b/packages/common/index.html index 6eb04986a3..2751ddd5d0 100644 --- a/packages/common/index.html +++ b/packages/common/index.html @@ -2,7 +2,8 @@ - Home + IconBar Demo + -

ESM Quick Test

-
+

IconBar Quick Test

+

Buttons are grouped with spacers, hover to explore the built-in styling.

+
+
+
diff --git a/packages/common/src/SVGWidget.ts b/packages/common/src/SVGWidget.ts index 2c5fd7e29b..e7295771a0 100644 --- a/packages/common/src/SVGWidget.ts +++ b/packages/common/src/SVGWidget.ts @@ -92,6 +92,7 @@ const intersectCircleLine = function (c: Point, r: number, a1: Point, a2: Point) }; export class SVGGlowFilter { + protected id; protected filter; protected feOffset; protected feColorMatrix; @@ -99,6 +100,7 @@ export class SVGGlowFilter { protected feBlend; constructor(target, id: string) { + this.id = id; this.filter = target.append("filter") .attr("id", id) .attr("width", "130%") @@ -136,8 +138,14 @@ export class SVGGlowFilter { ].join(" "); } + enable(enable: boolean) { + this.filter.attr("id", enable ? this.id : `disabled_${this.id}`); + return this; + } + update(color: string) { this.feColorMatrix.attr("values", this.rgb2ColorMatrix(color)); + return this; } } @@ -147,7 +155,7 @@ export class SVGWidget extends Widget { protected _boundingBox; protected transition; protected _drawStartPos: "center" | "origin"; - protected _svgSelectionFilter; + protected _svgSelectionFilter: SVGGlowFilter; protected _parentRelativeDiv; protected _parentOverlay; @@ -278,6 +286,10 @@ export class SVGWidget extends Widget { return retVal; } + get parentRelativeDiv() { + return this._parentRelativeDiv; + } + parentOverlay() { return this._parentOverlay; } @@ -289,7 +301,10 @@ export class SVGWidget extends Widget { update(domNode, element) { super.update(domNode, element); if (this._svgSelectionFilter) { - this._svgSelectionFilter.update(this.selectionGlowColor()); + this._svgSelectionFilter + .enable(this.selectionGlow()) + .update(this.selectionGlowColor()) + ; } } @@ -558,8 +573,11 @@ export class SVGWidget extends Widget { SVGWidget.prototype._class += " common_SVGWidget"; export interface SVGWidget { + selectionGlow(): boolean; + selectionGlow(_: boolean): this; selectionGlowColor(): string; selectionGlowColor(_: string): this; } +SVGWidget.prototype.publish("selectionGlow", true, "boolean", "Selection Glow"); SVGWidget.prototype.publish("selectionGlowColor", "red", "html-color", "Selection Glow Color"); diff --git a/packages/common/src/TitleBar.css b/packages/common/src/TitleBar.css index b002ad1b2a..ea56c1ff3d 100644 --- a/packages/common/src/TitleBar.css +++ b/packages/common/src/TitleBar.css @@ -11,27 +11,39 @@ margin: 0px; white-space: nowrap; line-height: 28px; - z-index:1; + z-index: 1; } .common_IconBar .icon-bar a { - text-align: center; /* Center-align text */ + position: relative; + z-index: 0; + /* Center-align text */ + text-align: center; padding-top: 4px; padding-left: 2px; padding-right: 2px; padding-bottom: 4px; - transition: all 0.3s ease; /* Add transition for hover effects */ - color: darkgray; /* White text color */ + /* Add transition for hover effects */ + transition: all 0.3s ease; + /* White text color */ + color: darkgray; +} + +.common_IconBar .icon-bar a:focus, +.common_IconBar .icon-bar a:focus-visible { + outline: 2px solid #0078d4; + outline-offset: 2px; + z-index: 2; } .common_IconBar .icon-bar a.disabled { opacity: 0.3; pointer-events: none; - color: darkgray; + color: darkgray; } .common_IconBar .icon-bar a:hover { - background-color: whitesmoke; /* Add a hover color */ + background-color: whitesmoke; } .common_IconBar .icon-bar a { @@ -39,36 +51,38 @@ } .common_IconBar .icon-bar a.selected { - background-color: #efe5e5; /* Add a hover color */ + /* Add a hover color */ + background-color: #efe5e5; } + .common_IconBar .icon-bar div.spacer { - text-align: center; /* Center-align text */ - height:28px; + text-align: center; + height: 28px; border-left-style: solid; border-left-width: 1px; border-left-color: transparent; padding-top: 0px; - padding-left: 2px; - margin-left: 2px; + padding-left: 2px; + margin-left: 2px; padding-bottom: 0px; } .common_IconBar .icon-bar div.spacer.vline { border-left-color: darkgray; - padding-left: 4px; - margin-left: 4px; + padding-left: 4px; + margin-left: 4px; } .common_IconBar .icon-bar a.spacer:hover { background-color: transparent; } -.common_TitleBar > .title-title { +.common_TitleBar>.title-title { margin: 4px; } -.common_TitleBar > .icon-bar { +.common_TitleBar>.icon-bar { margin: 4px; } @@ -77,7 +91,8 @@ position: static; } -.common_TitleBar .data-count{ + +.common_TitleBar .data-count { position: absolute; visibility: hidden; } @@ -90,10 +105,11 @@ font-size: 20px; font-weight: bold; } + .common_TitleBar .description-text { padding: 0px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; font-weight: normal; -} +} \ No newline at end of file diff --git a/packages/common/src/TitleBar.ts b/packages/common/src/TitleBar.ts index 79c8ad62c4..58ea2d70b7 100644 --- a/packages/common/src/TitleBar.ts +++ b/packages/common/src/TitleBar.ts @@ -24,6 +24,12 @@ export class Button extends HTMLWidget { context.click(); d3Event.preventDefault(); }) + .on("keydown", function (this: HTMLElement) { + if (d3Event.key === " " || d3Event.key === "Spacebar" || d3Event.code === "Space" || d3Event.key === "Enter") { + this.click(); + d3Event.preventDefault(); + } + }) .on("mousemove", this.mouseMove) .on("mouseout", this.mouseOut) .append("i") diff --git a/packages/graph/index.html b/packages/graph/index.html index acafdaa2ce..1c815ef391 100644 --- a/packages/graph/index.html +++ b/packages/graph/index.html @@ -42,6 +42,7 @@

ESM Quick Test

new Test() .target("placeholder1") + .tabNavigation(true) .render() ; @@ -52,6 +53,7 @@

ESM Quick Test

new Test() .target("placeholder2") + .render() ; diff --git a/packages/graph/src/common/graphT.css b/packages/graph/src/common/graphT.css index d6c8ae8c81..6964a86d5a 100644 --- a/packages/graph/src/common/graphT.css +++ b/packages/graph/src/common/graphT.css @@ -30,4 +30,10 @@ .graph_GraphT g.selected circle { stroke: navy !important; +} + +.graph_GraphT .graphVertex:focus, +.graph_GraphT .graphVertex:focus-visible { + outline: 2px solid #0078d4; + outline-offset: 2px; } \ No newline at end of file diff --git a/packages/graph/src/common/graphT.ts b/packages/graph/src/common/graphT.ts index 492426b67f..7e2ad8df68 100644 --- a/packages/graph/src/common/graphT.ts +++ b/packages/graph/src/common/graphT.ts @@ -747,6 +747,34 @@ export class GraphT d.element as any + }; + if (event.ctrlKey || event.metaKey) { + if (context._selection.isSelected(selectionItem)) { + context._selection.remove(selectionItem); + } else { + context._selection.append(selectionItem); + } + } else { + context._selection.clear(); + context._selection.append(selectionItem); + } + + context.selectionChanged(); + const selected = d.element.classed("selected"); + const eventOrigin = context.resolveEventOrigin(); + context.vertex_click(d.props.origData || d.props, "", selected, eventOrigin); + }) .on("mousein", function (d) { Utility.safeRaise(this); context.highlightVertex(d3Select(this), d); @@ -798,6 +826,10 @@ export class GraphT d.props.centroid) .attr("opacity", d => d.props.hidden ? 0 : 1) .attr("filter", d => d.props.centroid ? "url(#" + this.id() + "_glow)" : null) + .attr("tabindex", d => context.tabNavigation() && !d.props.hidden ? 0 : null) + .attr("role", context.tabNavigation() ? "button" : null) + .attr("aria-label", d => context.tabNavigation() ? context.vertexAriaLabel(d) : null) + .attr("aria-hidden", d => d.props.hidden ? "true" : null) .each(function (this: any, d) { const props = context.calcProps( { @@ -815,6 +847,10 @@ export class GraphT): string { + return d.props?.text || `Vertex ${d.id}`; + } + hasSubgraphs() { switch (this.layout()) { case "DOT": @@ -1008,6 +1044,21 @@ export class GraphT> { constructor() { diff --git a/packages/graph/tests/test6.ts b/packages/graph/tests/test6.ts index 3248948c31..0ef31738c1 100644 --- a/packages/graph/tests/test6.ts +++ b/packages/graph/tests/test6.ts @@ -1,4 +1,5 @@ import { GraphHtml, VertexProps } from "../src/index.ts"; +import { genData2 } from "./data"; const VERTEX_ARR: VertexProps[] = [{ id: 0, @@ -71,6 +72,7 @@ export class Test6 extends GraphHtml { constructor() { super(); + // const g = genData2(); this .data({ vertices: VERTEX_ARR,