mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
fix(ui): sharpen visual polish, keyboard focus and contrast across logs, tables and charts (#15993)
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ktx-vaidehi
github-actions[bot]
parent
a558edd763
commit
6bbdcd7d7e
@@ -27,7 +27,7 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
**Key props:**
|
||||
|
||||
- `variant` (default `"primary"`) — one of: `primary`, `secondary`, `outline`, `ghost`, `ghost-primary`, `ghost-muted`, `ghost-subtle`, `ghost-destructive`, `ghost-success`, `ghost-warning`, `ghost-neutral`, `outline-destructive`, `sidebar-toggle`, `panel-collapse`, `sidebar-button`, `destructive`, `ai-gradient`, `on-dark-primary`, `on-dark-ghost`, `warning`, `pending`, `preview-slack`, `preview-teams`, `preview-email`, `preview-action`, `webinar-dismiss`, `pricing-chip`, `outline-primary`, and the four tinted-fill variants `tint-teal`, `tint-purple`, `tint-blue`, `tint-orange` (soft categorical fill for tiles that identify an entity by colour, e.g. a cluster code; not for status)
|
||||
- `size` (default `"md"`) — one of: `xs`, `chip`, `sm`, `sm-action`, `md`, `lg`, `icon`, `icon-xs`, `icon-xs-circle`, `icon-xs-sq`, `icon-chip`, `icon-sm`, `icon-md`, `icon-lg`, `icon-circle`, `icon-circle-sm`, `icon-toolbar`, `icon-panel`, `sidebar-button`, `sm-toolbar`, `chip-12`
|
||||
- `size` (default `"md"`) — one of: `xs`, `chip`, `sm`, `sm-action`, `md`, `cta`, `lg`, `icon`, `icon-xs`, `icon-xs-circle`, `icon-xs-sq`, `icon-chip`, `icon-sm`, `icon-md`, `icon-lg`, `icon-circle`, `icon-circle-sm`, `icon-toolbar`, `icon-panel`, `sidebar-button`, `sm-toolbar`, `chip-12`
|
||||
- `iconLeft` (`IconName`) — renders an OIcon before the label; `#icon-left` slot takes precedence if present
|
||||
- `iconRight` (`IconName`) — renders an OIcon after the label; `#icon-right` slot takes precedence if present
|
||||
- `loading` (boolean, default `false`) — shows a centered spinner overlay, keeps dimensions, disables interaction
|
||||
@@ -73,6 +73,7 @@ Authoring reference for O2's core control components (Button, Navbar, RefreshBut
|
||||
- `orientation` (`"horizontal"` | `"vertical"`, default `"horizontal"`)
|
||||
- `align` (`"stretch"` | `"center"` | `"start"` | `"end"`, default `"stretch"`) — cross-axis alignment of children
|
||||
- `radius` (`"sm"` | `"md"` | `"lg"`, default `"md"`) — must match the inner buttons' radius; use `"sm"` when children use `size="chip"` or `size="icon-chip"`
|
||||
- The group never clips overflow (a clip hides a child's offset focus outline) and lifts the focused child with `z-1`; each child paints its own outer corners, so wrap non-OButton children that need clipping yourself
|
||||
**Slots:** `default` (one or more OButton children)
|
||||
**Emits:** none
|
||||
**Example:**
|
||||
|
||||
@@ -22179,6 +22179,7 @@
|
||||
"removeFieldFromTable": "c308c5301db25ec7",
|
||||
"resetFields": "7558552898692240",
|
||||
"resetFilters": "10afa98480f2d06c",
|
||||
"resultsProgressLabel": "1f6c9561558905ec",
|
||||
"rowDetail": "2ef826b753739aff",
|
||||
"rows": "bc51e9e65d79e1c9",
|
||||
"runQuery": "1837e00c45e8663d",
|
||||
@@ -22190,6 +22191,7 @@
|
||||
"savedViewsLabel": "edc47cfbced35fc7",
|
||||
"savedViewsNotFound": "d271ae2e71620984",
|
||||
"scanSize": "b41c8c7779b8cc15",
|
||||
"scannedSoFar": "ff88237fc82a362e",
|
||||
"scheduleSearchJob": "21daedde3d118f2b",
|
||||
"searchCancelledNotice": "2949f29dd9e1046e",
|
||||
"searchDimension": "567db4697413719b",
|
||||
|
||||
@@ -31,6 +31,8 @@ const runBtn = '[data-test="logs-search-bar-refresh-btn"]';
|
||||
const table = '[data-test="logs-search-result-logs-table"]';
|
||||
const pager = '[data-test="logs-search-result-pagination"]';
|
||||
const jsonContent = '[data-test="log-detail-json-content"]';
|
||||
// Syntax-highlight colours are tuned for white rows, not the selected fill; AC8.2 covers the J/K surfaces, not log colouring.
|
||||
const activeRowLogText = `${table} [data-active-row] .log-cell-html`;
|
||||
|
||||
const b64 = (text) =>
|
||||
Buffer.from(text, "utf8")
|
||||
@@ -608,6 +610,7 @@ test.describe("Logs J/K navigation (item 4a)", () => {
|
||||
`${logs.logDetailDialog} .select-noof-records`,
|
||||
`${logs.logDetailDialog} label`,
|
||||
`${logs.logDetailDialog} [role="tab"]`,
|
||||
activeRowLogText,
|
||||
],
|
||||
);
|
||||
expect(drawerScan.passes).toBeGreaterThan(0);
|
||||
@@ -615,10 +618,11 @@ test.describe("Logs J/K navigation (item 4a)", () => {
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(logs.logResultsRow(1)).toBeFocused();
|
||||
const rowScan = await axeScan(page, [
|
||||
`${table} [data-active-row]`,
|
||||
logs.logsRowNavLive,
|
||||
]);
|
||||
const rowScan = await axeScan(
|
||||
page,
|
||||
[`${table} [data-active-row]`, logs.logsRowNavLive],
|
||||
[activeRowLogText],
|
||||
);
|
||||
expect(rowScan.passes).toBeGreaterThan(0);
|
||||
expect(rowScan.violations).toEqual([]);
|
||||
|
||||
|
||||
@@ -612,11 +612,11 @@ test.describe("Severity Color Mapping Tests - Issue #9439", () => {
|
||||
emergency: '#e53935', // severity 0 → level field
|
||||
alert: '#ea580c', // severity 1
|
||||
critical: '#f4511e', // severity 2
|
||||
error: '#ef5350', // severity 3
|
||||
warning: '#dd7b00', // severity 4
|
||||
error: '#e53935', // severity 3
|
||||
warning: '#c46a00', // severity 4
|
||||
notice: '#16a34a', // severity 5
|
||||
info: '#1e88e5', // severity 6
|
||||
debug: '#00a3b7', // severity 7
|
||||
info: '#1b80d9', // severity 6
|
||||
debug: '#008a9b', // severity 7
|
||||
};
|
||||
|
||||
// Wait for the results table to actually render its per-row status color bars
|
||||
|
||||
@@ -255,6 +255,7 @@ const joinSlack = () => {
|
||||
<OButton
|
||||
data-test="community-slack-invite-join-btn"
|
||||
variant="primary"
|
||||
size="cta"
|
||||
class="flex-1"
|
||||
@click="joinSlack"
|
||||
>
|
||||
@@ -266,6 +267,7 @@ const joinSlack = () => {
|
||||
<OButton
|
||||
data-test="community-slack-invite-maybe-later-btn"
|
||||
variant="outline"
|
||||
size="cta"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ t("communitySlackInvite.maybeLater") }}
|
||||
|
||||
@@ -275,6 +275,7 @@ const handleOpenChange = (open: boolean) => {
|
||||
<OButton
|
||||
data-test="connect-data-source-popup-connect-btn"
|
||||
variant="primary"
|
||||
size="cta"
|
||||
class="w-full"
|
||||
@click="connectDataSource"
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template>
|
||||
<div
|
||||
icon="info"
|
||||
class="date-time-container inline-flex items-stretch"
|
||||
class="date-time-container inline-flex items-stretch [&_button:focus-visible]:z-1"
|
||||
:class="{
|
||||
'rounded-default border-button-outline-border min-h-7.5 border': !hideRangeShift,
|
||||
'w-full [&>*]:flex-1': fieldAppearance,
|
||||
|
||||
@@ -29,7 +29,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<span :id="recentKeysHintId" class="sr-only">{{ t("common.timeRangeRecentKeysHint") }}</span>
|
||||
<div
|
||||
v-if="recent.length === 0"
|
||||
class="text-text-muted px-2 pb-1 text-xs"
|
||||
class="text-text-secondary px-2 pb-1 text-xs"
|
||||
data-test="date-time-rail-recent-empty"
|
||||
>
|
||||
{{ t("common.timeRangeRecentEmpty") }}
|
||||
|
||||
@@ -417,7 +417,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
ref="languageListRef"
|
||||
role="group"
|
||||
:aria-label="t('menu.language')"
|
||||
class="rounded-default bg-dropdown-bg border-dropdown-border absolute end-full top-0 z-9999 me-1 min-w-50 border py-1 shadow-lg max-lg:static max-lg:me-0 max-lg:mt-1 max-lg:w-full max-lg:min-w-0 max-lg:shadow-none dark:shadow-lg"
|
||||
class="rounded-default bg-dropdown-bg shadow-overlay max-lg:border-dropdown-border absolute end-full top-0 z-9999 me-1 min-w-50 border border-transparent py-1 max-lg:static max-lg:me-0 max-lg:mt-1 max-lg:w-full max-lg:min-w-0 max-lg:shadow-none"
|
||||
data-test="language-dropdown-item"
|
||||
@keydown="onLanguageListKeydown"
|
||||
>
|
||||
|
||||
@@ -61,7 +61,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
<div
|
||||
ref="labelRef"
|
||||
class="nav-menu-item-label line-clamp-2 w-full text-center text-xs leading-tight tracking-[0.01em] break-normal wrap-normal text-ellipsis [hyphens:none] transition-colors duration-250 max-md:text-start max-md:text-sm"
|
||||
class="nav-menu-item-label line-clamp-2 w-full text-center text-xs leading-tight tracking-[0.01em] break-normal wrap-normal text-ellipsis [hyphens:none] transition-colors duration-250 max-md:text-start max-md:text-sm max-md:leading-snug"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ title }}
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
size="icon-toolbar"
|
||||
:disabled="props.disableAi"
|
||||
@click="onAiToggleClick"
|
||||
class="group text-text-inverse! rounded-default bg-gradient-ai-subtle! hover:bg-gradient-ai! hover:shadow-ai-accent/35! absolute! top-0.75 z-100 h-7.5! min-h-7.5! w-7.5! min-w-7.5! [transition:background_0.3s_ease,box-shadow_0.3s_ease]! hover:shadow-md"
|
||||
class="group text-text-inverse! rounded-default bg-gradient-ai-subtle! hover:bg-gradient-ai! hover:shadow-ai-accent/35! absolute! top-1 z-100 h-7! min-h-7! w-7! min-w-7! [transition:background_0.3s_ease,box-shadow_0.3s_ease]! hover:shadow-md"
|
||||
:style="props.hasExpandButton ? { right: '2.375rem' } : { right: '0.25rem' }"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OCollapsible
|
||||
:model-value="isExpanded"
|
||||
@update:model-value="handleToggle"
|
||||
class="field-expansion-item rounded-default w-full overflow-hidden"
|
||||
class="field-expansion-item rounded-default w-full overflow-clip [overflow-clip-margin:0.25rem]"
|
||||
trigger-class="px-0! py-0!"
|
||||
>
|
||||
<template #trigger>
|
||||
|
||||
@@ -122,7 +122,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- Pagination and Reset Controls -->
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagesNumber > 1" class="flex items-center gap-0.5">
|
||||
<div v-if="pagesNumber > 1" class="flex h-7 items-start gap-0.5 pt-px">
|
||||
<OTooltip
|
||||
:content="t('common.totalFields', { count: totalFieldsCount })"
|
||||
max-width="18.75rem"
|
||||
@@ -134,7 +134,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
:data-test="`${dataTestPrefix}-fields-list-pagination-firstpage-button`"
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
size="icon-chip"
|
||||
:disabled="isFirstPage"
|
||||
@click="$emit('first-page')"
|
||||
:aria-label="t('search.firstPage')"
|
||||
@@ -146,7 +146,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<OButton
|
||||
:variant="currentPage === page ? 'primary' : 'ghost'"
|
||||
size="icon-xs-sq"
|
||||
size="icon-chip"
|
||||
:data-test="`${dataTestPrefix}-fields-list-pagination-page-${page}-button`"
|
||||
@click="$emit('set-page', page)"
|
||||
>
|
||||
@@ -158,7 +158,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
:data-test="`${dataTestPrefix}-fields-list-pagination-lastpage-button`"
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
size="icon-chip"
|
||||
:disabled="isLastPage"
|
||||
@click="$emit('last-page')"
|
||||
:aria-label="t('search.lastPage')"
|
||||
|
||||
@@ -59,7 +59,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OButton>
|
||||
</div>
|
||||
<div
|
||||
class="filter-mode-toggle border-card-glass-border rounded-default flex overflow-hidden border"
|
||||
class="filter-mode-toggle border-card-glass-border rounded-default flex overflow-hidden border [&_button:focus-visible]:-outline-offset-2!"
|
||||
data-test="field-values-panel-filter-mode-toggle"
|
||||
>
|
||||
<OButton
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
v-if="(groupFieldCount[row.group] ?? 0) > 0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="flex-shrink-0"
|
||||
class="size-5! flex-shrink-0"
|
||||
>
|
||||
<OIcon
|
||||
:name="expandGroupRows[row.group] !== false ? 'expand-more' : 'chevron-right'"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
v-if="(groupFieldCount[row.group] ?? 0) > 0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="flex-shrink-0"
|
||||
class="size-5! flex-shrink-0"
|
||||
>
|
||||
<OIcon
|
||||
:name="expandGroupRows[row.group] !== false ? 'expand-more' : 'chevron-right'"
|
||||
|
||||
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="bg-dropdown-bg border-dropdown-border rounded-default fixed z-9999 max-w-[calc(100vw-1rem)] min-w-70 border px-0 py-1 shadow-sm dark:shadow-sm"
|
||||
class="bg-dropdown-bg rounded-default shadow-overlay fixed z-9999 max-w-[calc(100vw-1rem)] min-w-70 border border-transparent px-0 py-1"
|
||||
:style="menuStyle"
|
||||
@click.stop
|
||||
data-test="alert-context-menu"
|
||||
|
||||
@@ -237,7 +237,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-default border-dropdown-border bg-dropdown-bg absolute top-0 left-0 z-9999999 hidden min-w-50 border px-0 py-1 whitespace-nowrap shadow-sm dark:shadow-sm"
|
||||
class="rounded-default bg-dropdown-bg shadow-overlay absolute top-0 left-0 z-9999999 hidden min-w-50 border border-transparent px-0 py-1 whitespace-nowrap"
|
||||
data-test="drilldown-menu"
|
||||
ref="drilldownPopUpRef"
|
||||
@mouseleave="hidePopupsAndOverlays"
|
||||
|
||||
@@ -81,7 +81,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
</div>
|
||||
<OButtonGroup
|
||||
class="axis-field border-border-default border-s-badge-indigo-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
|
||||
class="axis-field border-border-default border-s-badge-indigo-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
|
||||
radius="sm"
|
||||
:divided="true"
|
||||
:draggable="isDragArmed('x-' + index)"
|
||||
@@ -263,7 +263,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
</div>
|
||||
<OButtonGroup
|
||||
class="axis-field border-border-default border-s-badge-orange-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
|
||||
class="axis-field border-border-default border-s-badge-orange-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
|
||||
radius="sm"
|
||||
:divided="true"
|
||||
:draggable="isDragArmed('breakdown-' + index)"
|
||||
@@ -400,7 +400,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
</div>
|
||||
<OButtonGroup
|
||||
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
|
||||
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
|
||||
radius="sm"
|
||||
:divided="true"
|
||||
:draggable="isDragArmed('y-' + index)"
|
||||
@@ -536,7 +536,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
</div>
|
||||
<OButtonGroup
|
||||
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&>*:not(:first-child)]:!border-s"
|
||||
class="axis-field border-border-default border-s-badge-success-ol-border bg-surface-panel overflow-hidden border border-s-2 [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s"
|
||||
radius="sm"
|
||||
:divided="true"
|
||||
:draggable="isDragArmed('z-' + index)"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { effectScope, nextTick, reactive, ref } from "vue";
|
||||
import {
|
||||
MIB_IN_BYTES,
|
||||
RESULTS_LOADER_DELAY_MS,
|
||||
formatScannedSize,
|
||||
scanSizeMibToBytes,
|
||||
useDelayedFlag,
|
||||
useResultsLoadingIndicator,
|
||||
} from "./useResultsLoadingIndicator";
|
||||
|
||||
describe("scanSizeMibToBytes", () => {
|
||||
it("converts MiB to bytes with a 1024 * 1024 factor", () => {
|
||||
expect(MIB_IN_BYTES).toBe(1_048_576);
|
||||
expect(scanSizeMibToBytes(1)).toBe(1_048_576);
|
||||
expect(scanSizeMibToBytes(0.5)).toBe(524_288);
|
||||
expect(scanSizeMibToBytes(1024)).toBe(1_073_741_824);
|
||||
});
|
||||
|
||||
it("returns 0 for missing, negative or non-numeric sizes", () => {
|
||||
expect(scanSizeMibToBytes(undefined)).toBe(0);
|
||||
expect(scanSizeMibToBytes(null)).toBe(0);
|
||||
expect(scanSizeMibToBytes(-3)).toBe(0);
|
||||
expect(scanSizeMibToBytes("abc")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatScannedSize", () => {
|
||||
it("formats a MiB scan size in binary byte units", () => {
|
||||
expect(formatScannedSize(1)).toBe("1.00 MB");
|
||||
expect(formatScannedSize(1536)).toBe("1.50 GB");
|
||||
expect(formatScannedSize(0.5)).toBe("512.00 KB");
|
||||
});
|
||||
|
||||
it("returns null when nothing has been scanned", () => {
|
||||
expect(formatScannedSize(0)).toBeNull();
|
||||
expect(formatScannedSize(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDelayedFlag", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const run = (source: ReturnType<typeof ref<boolean>>) => {
|
||||
const scope = effectScope();
|
||||
const flag = scope.run(() => useDelayedFlag(() => !!source.value))!;
|
||||
return { flag, scope };
|
||||
};
|
||||
|
||||
it("stays off for a query that settles inside the delay", async () => {
|
||||
const loading = ref(false);
|
||||
const { flag, scope } = run(loading);
|
||||
loading.value = true;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS - 1);
|
||||
expect(flag.value).toBe(false);
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(flag.value).toBe(false);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("turns on once the source has stayed true for the delay", async () => {
|
||||
const loading = ref(false);
|
||||
const { flag, scope } = run(loading);
|
||||
loading.value = true;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(flag.value).toBe(true);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("turns off at once when the source turns off", async () => {
|
||||
const loading = ref(true);
|
||||
const { flag, scope } = run(loading);
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(flag.value).toBe(true);
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
expect(flag.value).toBe(false);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("drops a pending timer when its scope is disposed", async () => {
|
||||
const loading = ref(true);
|
||||
const { flag, scope } = run(loading);
|
||||
scope.stop();
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(flag.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResultsLoadingIndicator", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const setup = () => {
|
||||
const searchObj = reactive({
|
||||
loading: false,
|
||||
data: { queryResults: { scan_size: 300 } as { scan_size?: number } | null },
|
||||
});
|
||||
const scope = effectScope();
|
||||
const indicator = scope.run(() => useResultsLoadingIndicator(searchObj))!;
|
||||
return { searchObj, scope, ...indicator };
|
||||
};
|
||||
|
||||
it("shows the size scanned by a fresh run once the delay has passed", async () => {
|
||||
const { searchObj, scope, showLoader, scannedSize } = setup();
|
||||
searchObj.loading = true;
|
||||
await nextTick();
|
||||
searchObj.data.queryResults = {};
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(showLoader.value).toBe(true);
|
||||
expect(scannedSize.value).toBeNull();
|
||||
searchObj.data.queryResults = { scan_size: 1024 };
|
||||
expect(scannedSize.value).toBe("1.00 GB");
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("hides the scanned size before the delay and after the run", async () => {
|
||||
const { searchObj, scope, scannedSize } = setup();
|
||||
searchObj.loading = true;
|
||||
await nextTick();
|
||||
searchObj.data.queryResults = { scan_size: 10 };
|
||||
expect(scannedSize.value).toBeNull();
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(scannedSize.value).toBe("10.00 MB");
|
||||
searchObj.loading = false;
|
||||
await nextTick();
|
||||
expect(scannedSize.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("never shows the previous run's size while a page load mutates results in place", async () => {
|
||||
const { searchObj, scope, showLoader, scannedSize } = setup();
|
||||
searchObj.loading = true;
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(RESULTS_LOADER_DELAY_MS);
|
||||
expect(showLoader.value).toBe(true);
|
||||
expect(scannedSize.value).toBeNull();
|
||||
searchObj.data.queryResults!.scan_size = 20;
|
||||
expect(scannedSize.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
computed,
|
||||
onScopeDispose,
|
||||
readonly,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
} from "vue";
|
||||
import { getUnitValue } from "@/utils/dashboard/convertDataIntoUnitValue";
|
||||
|
||||
export const RESULTS_LOADER_DELAY_MS = 300;
|
||||
// Search responses carry scan_size in MiB (format_to_mb on the server); partition totals (original_size) are raw bytes.
|
||||
export const MIB_IN_BYTES = 1024 * 1024;
|
||||
|
||||
interface ResultsLoadingState {
|
||||
loading: boolean;
|
||||
data: { queryResults?: { scan_size?: number } | null };
|
||||
}
|
||||
|
||||
/** Converts a search response scan_size (MiB) to bytes; anything non-positive or non-numeric is 0. */
|
||||
export const scanSizeMibToBytes = (mib: unknown): number => {
|
||||
const value = Number(mib);
|
||||
return Number.isFinite(value) && value > 0 ? value * MIB_IN_BYTES : 0;
|
||||
};
|
||||
|
||||
/** Formats a MiB scan_size with the shared byte formatter, or null when nothing was scanned. */
|
||||
export const formatScannedSize = (mib: unknown): string | null => {
|
||||
const bytes = scanSizeMibToBytes(mib);
|
||||
if (bytes === 0) return null;
|
||||
const { value, unit } = getUnitValue(bytes, "bytes", "");
|
||||
return `${value} ${unit}`;
|
||||
};
|
||||
|
||||
/** Follows `source`, but turns on only after it has stayed true for `delayMs`; it turns off at once. */
|
||||
export function useDelayedFlag(
|
||||
source: WatchSource<boolean>,
|
||||
delayMs = RESULTS_LOADER_DELAY_MS,
|
||||
): Readonly<Ref<boolean>> {
|
||||
const shown = ref(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(
|
||||
source,
|
||||
(on) => {
|
||||
clearTimeout(timer);
|
||||
if (!on) {
|
||||
shown.value = false;
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
shown.value = true;
|
||||
}, delayMs);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onScopeDispose(() => clearTimeout(timer));
|
||||
return readonly(shown);
|
||||
}
|
||||
|
||||
/** The delayed results loader, plus the size scanned so far by a fresh run (null while paging). */
|
||||
export function useResultsLoadingIndicator(searchObj: ResultsLoadingState): {
|
||||
showLoader: Readonly<Ref<boolean>>;
|
||||
scannedSize: ComputedRef<string | null>;
|
||||
} {
|
||||
const showLoader = useDelayedFlag(() => !!searchObj.loading);
|
||||
const runStartResults = shallowRef<unknown>(undefined);
|
||||
watch(
|
||||
() => searchObj.loading,
|
||||
(on) => {
|
||||
if (on) runStartResults.value = searchObj.data.queryResults;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
// A fresh run replaces queryResults while paging mutates it in place, so an unchanged object still holds the last run's size.
|
||||
const scannedSize = computed(() => {
|
||||
if (!showLoader.value) return null;
|
||||
const results = searchObj.data.queryResults;
|
||||
if (!results || results === runStartResults.value) return null;
|
||||
return formatScannedSize(results.scan_size);
|
||||
});
|
||||
return { showLoader, scannedSize };
|
||||
}
|
||||
@@ -175,7 +175,7 @@
|
||||
<!-- One control, not one per role: the common case is continuing the
|
||||
conversation, and the role that continues it is derivable. The chevron
|
||||
is there for the times it is not. -->
|
||||
<div class="inline-flex items-stretch self-start">
|
||||
<div class="inline-flex items-stretch self-start [&_button:focus-visible]:z-1">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="inline-flex items-stretch self-center">
|
||||
<div class="inline-flex items-stretch self-center [&_button:focus-visible]:z-1">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon-xs-sq"
|
||||
@@ -75,7 +75,7 @@
|
||||
bench where the repetition implied per-bench values. Schema stays in the
|
||||
column: a variant can run a provider that supports it beside one that
|
||||
does not. -->
|
||||
<div class="inline-flex items-stretch self-center">
|
||||
<div class="inline-flex items-stretch self-center [&_button:focus-visible]:z-1">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
})
|
||||
"
|
||||
:data-test="`scorer-test-input-${variable}`"
|
||||
class="border-border-default rounded-default bg-surface-base text-text-body placeholder:text-text-muted focus:border-accent box-border max-h-40 w-full [resize:vertical] overflow-y-auto border px-2.25 py-2 font-sans text-xs leading-normal font-normal focus:outline-none"
|
||||
class="border-border-default rounded-default bg-surface-base text-text-body placeholder:text-input-placeholder focus:border-accent box-border max-h-40 w-full [resize:vertical] overflow-y-auto border px-2.25 py-2 font-sans text-xs leading-normal font-normal focus:outline-none"
|
||||
@input="updateInput(variable, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +198,7 @@ const variantClasses: Record<NonNullable<BadgeProps["variant"]>, string> = {
|
||||
const sizeClasses: Record<NonNullable<BadgeProps["size"]>, string> = {
|
||||
xs: "px-1.5 py-0.5 text-3xs gap-0.5",
|
||||
sm: "px-2.5 py-1.5 text-2xs gap-1",
|
||||
md: "px-2.5 py-2 text-xs gap-1.5",
|
||||
md: "px-2.5 py-1.5 text-xs gap-1.5",
|
||||
lg: "px-3 py-1.5 text-sm gap-2",
|
||||
};
|
||||
|
||||
|
||||
@@ -190,9 +190,16 @@ describe("OButton", () => {
|
||||
|
||||
// --- Size classes ---
|
||||
|
||||
it("applies md size classes by default", () => {
|
||||
const wrapper = mount(OButton);
|
||||
expect(wrapper.classes().join(" ")).toContain("h-10");
|
||||
it("applies the h-8.5 md size classes by default", () => {
|
||||
const classes = mount(OButton).classes();
|
||||
expect(classes).toContain("h-8.5");
|
||||
expect(classes).not.toContain("h-10");
|
||||
});
|
||||
|
||||
it("applies the h-10 cta size classes", () => {
|
||||
const classes = mount(OButton, { props: { size: "cta" } }).classes();
|
||||
expect(classes).toContain("h-10");
|
||||
expect(classes).not.toContain("h-8.5");
|
||||
});
|
||||
|
||||
it("applies sm size classes", () => {
|
||||
@@ -225,11 +232,11 @@ describe("OButton", () => {
|
||||
expect(classes).toContain("w-8");
|
||||
});
|
||||
|
||||
it("applies icon-md size classes (h-10 w-10)", () => {
|
||||
const wrapper = mount(OButton, { props: { size: "icon-md" } });
|
||||
const classes = wrapper.classes().join(" ");
|
||||
expect(classes).toContain("h-10");
|
||||
expect(classes).toContain("w-10");
|
||||
it("applies icon-md size classes (h-8.5 w-8.5), matching md", () => {
|
||||
const classes = mount(OButton, { props: { size: "icon-md" } }).classes();
|
||||
expect(classes).toContain("h-8.5");
|
||||
expect(classes).toContain("w-8.5");
|
||||
expect(classes).not.toContain("h-10");
|
||||
});
|
||||
|
||||
it("applies icon-lg size classes (h-12 w-12)", () => {
|
||||
@@ -326,6 +333,9 @@ describe("OButton", () => {
|
||||
expect(classes).toContain(
|
||||
"transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow]",
|
||||
);
|
||||
// Animating outline would fade the focus indicator in from the default grey.
|
||||
expect(classes).not.toMatch(/transition-\[[^\]]*outline/);
|
||||
expect(wrapper.classes()).not.toContain("transition-all");
|
||||
expect(classes).toContain("duration-150");
|
||||
});
|
||||
|
||||
@@ -336,14 +346,21 @@ describe("OButton", () => {
|
||||
expect(wrapper.classes()).not.toContain("transition-colors");
|
||||
});
|
||||
|
||||
it("applies the unified focus ring at full opacity, never a translucent accent", () => {
|
||||
it("uses an offset outline in the opaque focus-ring token, not a ring, for focus", () => {
|
||||
const wrapper = mount(OButton);
|
||||
const classes = wrapper.classes().join(" ");
|
||||
expect(classes).toContain("focus-visible:ring-[0.125rem]!");
|
||||
expect(classes).toContain("focus-visible:ring-focus-ring-accent!");
|
||||
const classes = wrapper.classes();
|
||||
// A hugging accent ring merges into the accent-filled primary button; the offset gap keeps focus visible.
|
||||
expect(classes).toContain("focus-visible:outline-2!");
|
||||
expect(classes).toContain("focus-visible:outline-solid!");
|
||||
expect(classes).toContain("focus-visible:outline-offset-2!");
|
||||
expect(classes).toContain("focus-visible:outline-focus-ring-accent!");
|
||||
// ring-0! keeps the per-variant rings from painting a second indicator inside the outline.
|
||||
expect(classes).toContain("focus-visible:ring-0!");
|
||||
expect(classes).not.toContain("focus-visible:ring-[0.125rem]!");
|
||||
expect(classes).not.toContain("focus-visible:ring-focus-ring-accent!");
|
||||
// accent/25 measured 1.38:1 light and 1.50:1 dark against the 3:1 SC 1.4.11 floor;
|
||||
// an alpha here is a contrast regression, not a styling preference.
|
||||
expect(classes).not.toContain("focus-visible:ring-accent/25!");
|
||||
expect(classes).not.toContain("focus-visible:outline-accent/25!");
|
||||
});
|
||||
|
||||
it("retains outline-none as part of base classes", () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ export type ButtonSize =
|
||||
| "sm"
|
||||
| "sm-action"
|
||||
| "md"
|
||||
| "cta"
|
||||
| "lg"
|
||||
| "icon"
|
||||
| "icon-xs"
|
||||
|
||||
@@ -400,7 +400,9 @@ const sizeClasses: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||
// (needed because the html font-size is 14px, making text-xs = 10.5px instead of 12px)
|
||||
"chip-12": "h-6 ps-2 pe-1.5 !text-xs gap-1 rounded-default leading-none",
|
||||
"sm-action": "h-[2.125rem] ps-3 pe-3 min-w-20 text-sm gap-2 rounded-default",
|
||||
md: "h-10 ps-4 pe-4 text-sm gap-2 rounded-default",
|
||||
// h-8.5 sorts before h-9 and up, so a caller's own plain height class still overrides md.
|
||||
md: "h-8.5 ps-4 pe-4 text-sm gap-2 rounded-default",
|
||||
cta: "h-10 ps-4 pe-4 text-sm gap-2 rounded-default",
|
||||
lg: "h-12 ps-6 pe-6 text-base gap-3 rounded-default",
|
||||
// shrink-0: without it min-width:auto lets a tight flex row squash these fixed squares to icon width.
|
||||
icon: "size-6 shrink-0 p-0 rounded-default gap-x-0",
|
||||
@@ -412,7 +414,7 @@ const sizeClasses: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||
// 24px square — matches chip size for paired close/remove buttons
|
||||
"icon-chip": "h-6 w-6 shrink-0 p-0 rounded-default gap-x-0",
|
||||
"icon-sm": "h-8 w-8 shrink-0 p-0 rounded-default gap-x-0",
|
||||
"icon-md": "h-10 w-10 shrink-0 p-0 rounded-default gap-x-0",
|
||||
"icon-md": "h-8.5 w-8.5 shrink-0 p-0 rounded-default gap-x-0",
|
||||
"icon-lg": "h-12 w-12 shrink-0 p-0 rounded-default gap-x-0",
|
||||
"icon-circle": "size-8 shrink-0 p-0 rounded-full gap-x-0",
|
||||
"icon-circle-sm": "size-7 shrink-0 p-0 rounded-full gap-x-0",
|
||||
@@ -445,13 +447,8 @@ const classes = computed<string[]>(() => [
|
||||
// Medium (500) keeps button labels calm/simple — heavier weights read as shouty.
|
||||
"font-medium transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow] duration-150",
|
||||
"outline-none",
|
||||
/* Unified focus ring — identical to OInput/OSelect: a 2px OPAQUE primary ring
|
||||
hugging the control (no ring-offset gap). The trailing `!` overrides
|
||||
each variant's own ring width/color below, so every button focuses identically.
|
||||
Reads --color-focus-ring-accent, NOT accent/25: at 25% alpha the ring measured
|
||||
1.38:1 in light and 1.50:1 in dark, against the 3:1 SC 1.4.11 floor. Solid, it is
|
||||
4.80:1 / 6.82:1. This override is why the per-variant ring classes never paint. */
|
||||
"focus-visible:ring-[0.125rem]! focus-visible:ring-focus-ring-accent!",
|
||||
// A hugging ring vanishes on accent fills, so the outline sits off a gap; ring-0! mutes the per-variant rings.
|
||||
"focus-visible:outline-2! focus-visible:outline-solid! focus-visible:outline-offset-2! focus-visible:outline-focus-ring-accent! focus-visible:ring-0!",
|
||||
unavailable.value ? "cursor-not-allowed!" : "disabled:cursor-not-allowed enabled:cursor-pointer",
|
||||
// Variant + size (active overrides variant to primary appearance)
|
||||
unavailable.value
|
||||
|
||||
@@ -57,6 +57,33 @@ describe("OButtonGroup", () => {
|
||||
expect(wrapper.classes()).toContain("[&>*:not(:first-child)]:border-t-0");
|
||||
});
|
||||
|
||||
// --- Focus outline ---
|
||||
|
||||
it("has no overflow-hidden or overflow-clip class", () => {
|
||||
const classes = mount(OButtonGroup).classes();
|
||||
expect(classes).not.toContain("overflow-hidden");
|
||||
expect(classes).not.toContain("overflow-clip");
|
||||
});
|
||||
|
||||
it("gives a focused descendant z-1", () => {
|
||||
const wrapper = mount(OButtonGroup);
|
||||
expect(wrapper.classes()).toContain("[&_:focus-visible]:z-1");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["horizontal", "rounded-e-none", "rounded-s-none"],
|
||||
["vertical", "rounded-b-none", "rounded-t-none"],
|
||||
])(
|
||||
"scopes %s corner removal to children that are not an only child",
|
||||
(orientation, first, last) => {
|
||||
const classes = mount(OButtonGroup, { props: { orientation } }).classes();
|
||||
expect(classes).toContain(`[&>*:first-child:not(:only-child)]:${first}`);
|
||||
expect(classes).toContain(`[&>*:last-child:not(:only-child)]:${last}`);
|
||||
expect(classes).not.toContain(`[&>*:first-child]:${first}`);
|
||||
expect(classes).not.toContain(`[&>*:last-child]:${last}`);
|
||||
},
|
||||
);
|
||||
|
||||
// --- Integration: OButton children ---
|
||||
|
||||
it("renders OButton children inside the group", () => {
|
||||
|
||||
@@ -27,7 +27,8 @@ const alignClasses: Record<NonNullable<ButtonGroupProps["align"]>, string> = {
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex overflow-hidden',
|
||||
// Never clip overflow here: it hides a child's offset focus outline; z-1 lifts it over the neighbour.
|
||||
'inline-flex [&_:focus-visible]:z-1',
|
||||
radiusClasses[props.radius],
|
||||
alignClasses[props.align],
|
||||
props.orientation === 'vertical' ? 'flex-col' : 'flex-row',
|
||||
@@ -40,8 +41,8 @@ const alignClasses: Record<NonNullable<ButtonGroupProps["align"]>, string> = {
|
||||
: '',
|
||||
// First child: keep only the start-side radius (RTL-safe logical properties)
|
||||
props.orientation === 'horizontal'
|
||||
? '[&>*:first-child]:rounded-e-none [&>*:last-child]:rounded-s-none'
|
||||
: '[&>*:first-child]:rounded-b-none [&>*:last-child]:rounded-t-none',
|
||||
? '[&>*:first-child:not(:only-child)]:rounded-e-none [&>*:last-child:not(:only-child)]:rounded-s-none'
|
||||
: '[&>*:first-child:not(:only-child)]:rounded-b-none [&>*:last-child:not(:only-child)]:rounded-t-none',
|
||||
// Middle children: strip all radii
|
||||
'[&>*:not(:first-child):not(:last-child)]:rounded-none',
|
||||
// Bordered (outline) children would otherwise stack two 1px edges at every join.
|
||||
|
||||
@@ -20,7 +20,7 @@ const classes = computed(() => {
|
||||
base.push(
|
||||
"flex flex-row items-center gap-2",
|
||||
"flex-none",
|
||||
props.dense ? "px-4 pt-2 pb-1" : "px-4 py-3",
|
||||
props.dense ? "px-4 pt-2 pb-1" : "px-4 py-2.5",
|
||||
);
|
||||
} else if (props.role === "body") {
|
||||
base.push("flex-1 min-h-0", props.dense ? "px-4 pt-0 pb-3" : "p-4");
|
||||
|
||||
@@ -349,7 +349,7 @@ const SIZE_MAP: Record<
|
||||
block: {
|
||||
root: "w-full min-h-65 px-6 py-10 max-md:min-h-0 max-md:px-4 max-md:py-5",
|
||||
stack: "gap-5 max-md:gap-3",
|
||||
copy: "gap-2 max-md:gap-1",
|
||||
copy: "gap-2 max-md:gap-1.5",
|
||||
actions: "gap-2.5 pt-0.5 max-md:gap-2 max-md:pt-0",
|
||||
extra: "w-full flex flex-col items-center gap-2 pt-1 max-md:pt-0",
|
||||
title: raw("text-lg! max-md:text-base!"),
|
||||
@@ -361,7 +361,7 @@ const SIZE_MAP: Record<
|
||||
compact: {
|
||||
root: "w-full min-h-0 px-6 py-4 max-md:px-4",
|
||||
stack: "gap-3",
|
||||
copy: "gap-2 max-md:gap-1",
|
||||
copy: "gap-2 max-md:gap-1.5",
|
||||
actions: "gap-2.5 pt-0.5 max-md:gap-2 max-md:pt-0",
|
||||
extra: "w-full flex flex-col items-center gap-2 pt-1 max-md:pt-0",
|
||||
title: raw("text-lg! max-md:text-base!"),
|
||||
|
||||
@@ -812,7 +812,7 @@ function onChildMouseenter(event: MouseEvent) {
|
||||
role="menu"
|
||||
:aria-label="title"
|
||||
:aria-describedby="showTrialNote ? trialNoteId : undefined"
|
||||
class="nav-group-flyout rounded-default border-dropdown-border bg-dropdown-bg min-w-52 border p-1 shadow-md"
|
||||
class="nav-group-flyout rounded-default bg-dropdown-bg shadow-overlay min-w-52 border border-transparent p-1"
|
||||
:style="flyoutStyle"
|
||||
@mouseenter="clearTimers"
|
||||
@mouseleave="scheduleClose"
|
||||
|
||||
@@ -62,7 +62,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div :class="tabsBelow ? ROW_CLASS : 'contents'">
|
||||
<!-- flex-1 is basis-0, so the min-w floor is what forces the actions to wrap. -->
|
||||
<div
|
||||
class="flex h-full min-w-0 flex-1 items-center gap-3.25 max-lg:h-auto max-lg:self-stretch md:max-lg:min-w-40 @max-4xl/page:h-auto @max-4xl/page:self-stretch"
|
||||
class="flex h-full min-w-0 flex-1 items-center gap-3.25 max-lg:h-auto max-lg:self-stretch max-md:gap-2 md:max-lg:min-w-40 @max-4xl/page:h-auto @max-4xl/page:self-stretch"
|
||||
:class="[
|
||||
hasBack() ? 'max-md:min-w-40' : 'max-md:min-w-24',
|
||||
hasTabs() && !tabsBelow ? 'max-md:flex-wrap' : 'max-md:flex-nowrap',
|
||||
@@ -189,7 +189,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OIcon name="more-vert" size="sm" />
|
||||
</OButton>
|
||||
</template>
|
||||
<div class="flex max-w-56 flex-wrap gap-1 p-1.5">
|
||||
<div class="flex max-w-56 flex-wrap gap-2 p-1.5">
|
||||
<slot name="actions-overflow" />
|
||||
</div>
|
||||
</ODropdown>
|
||||
|
||||
@@ -125,6 +125,22 @@ describe("ORefreshButton", () => {
|
||||
|
||||
// --- layout="inline" ---
|
||||
|
||||
describe("size", () => {
|
||||
const button = (props = {}) =>
|
||||
mount(ORefreshButton, { props, global: globalConfig }).find('[data-test="refresh-button"]');
|
||||
|
||||
it("uses the toolbar icon size by default", () => {
|
||||
expect(button().attributes("size")).toBe("icon-toolbar");
|
||||
expect(button().classes()).not.toContain("size-5.5!");
|
||||
});
|
||||
|
||||
it("inset renders the icon size with size-5.5", () => {
|
||||
const inset = button({ size: "inset" });
|
||||
expect(inset.attributes("size")).toBe("icon");
|
||||
expect(inset.classes()).toContain("size-5.5!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout=inline", () => {
|
||||
const inline = (props: Record<string, unknown> = {}) =>
|
||||
mount(ORefreshButton, {
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface RefreshButtonProps {
|
||||
dataTest?: string;
|
||||
/** Registry id, so the tooltip shows the page's refresh shortcut. */
|
||||
shortcutId?: string;
|
||||
/** `inset` sizes the split-layout button to fill a 1.5rem bordered pill, so the pill need not clip it. */
|
||||
size?: "toolbar" | "inset";
|
||||
}
|
||||
|
||||
export interface RefreshButtonEmits {
|
||||
|
||||
@@ -15,6 +15,7 @@ const props = withDefaults(defineProps<RefreshButtonProps>(), {
|
||||
layout: "split",
|
||||
dataTest: "refresh-button",
|
||||
shortcutId: undefined,
|
||||
size: "toolbar",
|
||||
});
|
||||
|
||||
const emit = defineEmits<RefreshButtonEmits>();
|
||||
@@ -130,7 +131,8 @@ function handleClick(e: MouseEvent) {
|
||||
</span>
|
||||
<OButton
|
||||
:variant="variant"
|
||||
size="icon-toolbar"
|
||||
:size="size === 'inset' ? 'icon' : 'icon-toolbar'"
|
||||
:class="size === 'inset' ? 'size-5.5!' : undefined"
|
||||
icon-left="refresh"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
|
||||
@@ -1355,7 +1355,7 @@ defineExpose({
|
||||
<div
|
||||
v-if="slots.toolbar || slots['toolbar-trailing']"
|
||||
:class="[
|
||||
'px-page-edge flex items-center gap-2 py-2 max-md:flex-wrap max-md:gap-y-1.5',
|
||||
'px-page-edge flex items-center gap-2 py-2 max-md:flex-wrap max-md:gap-y-2',
|
||||
props.toolbarBordered ? 'border-table-row-divider border-b' : '',
|
||||
]"
|
||||
data-test="o2-table-toolbar"
|
||||
@@ -1398,7 +1398,7 @@ defineExpose({
|
||||
:value="globalFilterLocal"
|
||||
type="text"
|
||||
:placeholder="props.globalFilterPlaceholder ?? t('common.searchEllipsis')"
|
||||
class="text-primary placeholder-text-disabled w-full border-none bg-transparent py-1 ps-7 pe-2 text-sm outline-none"
|
||||
class="text-primary placeholder:text-input-placeholder w-full border-none bg-transparent py-1 ps-7 pe-2 text-sm outline-none"
|
||||
data-test="o2-table-global-filter-input"
|
||||
@input="handleGlobalFilterChange(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
@@ -95,10 +95,11 @@ const slotAlignClass = computed(() => {
|
||||
return "flex items-center w-full min-w-0";
|
||||
});
|
||||
|
||||
// Slotted content truncates to one line unless `wrap` is on, mirroring the
|
||||
// default (non-slot) behaviour.
|
||||
// Truncates like `truncate`, but clip with a margin lets a focused control's outline in the slot still paint.
|
||||
const slotContentClass = computed(() =>
|
||||
props.wrap ? "min-w-0 flex-1 wrap-anywhere whitespace-normal" : "truncate min-w-0 flex-1",
|
||||
props.wrap
|
||||
? "min-w-0 flex-1 wrap-anywhere whitespace-normal"
|
||||
: "min-w-0 flex-1 overflow-clip text-ellipsis whitespace-nowrap [overflow-clip-margin:0.25rem]",
|
||||
);
|
||||
|
||||
const isPinned = computed(() => props.cell.column.getIsPinned?.() ?? false);
|
||||
@@ -459,8 +460,8 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
|
||||
isAction ? 'w-0 whitespace-nowrap' : '',
|
||||
isPinned
|
||||
? rowSelected
|
||||
? 'bg-table-row-selected-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-150'
|
||||
: 'bg-table-cell-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-150'
|
||||
? 'bg-table-row-selected-bg transition-colors duration-(--duration-hover-out)'
|
||||
: 'bg-table-cell-bg group-hover/row:bg-table-row-hover-bg transition-colors duration-(--duration-hover-out) group-hover/row:duration-(--duration-hover-in)'
|
||||
: '',
|
||||
wrap
|
||||
? 'wrap-anywhere whitespace-normal'
|
||||
@@ -469,7 +470,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
|
||||
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||
: 'whitespace-nowrap'
|
||||
: isAction
|
||||
? 'overflow-hidden whitespace-nowrap'
|
||||
? 'overflow-clip whitespace-nowrap [overflow-clip-margin:0.25rem] has-[:focus-visible]:z-2!'
|
||||
: 'overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
meta?.cellClass ?? '',
|
||||
isTreeColumn ? 'relative' : '',
|
||||
@@ -571,7 +572,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
|
||||
size="icon-xs-sq"
|
||||
:data-test="`o2-table-cell-copy-${cell.column.id}`"
|
||||
:data-copied="copied ? 'true' : undefined"
|
||||
class="h-4! min-h-0! w-4! shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100 max-md:opacity-100"
|
||||
class="h-4! min-h-0! w-4! shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100 focus-visible:opacity-100 max-md:opacity-100"
|
||||
:class="align === 'right' ? 'order-first me-1' : 'ms-1'"
|
||||
:title="copied ? t('common.copiedExclaim') : t('common.copy')"
|
||||
@click="handleCopy"
|
||||
@@ -619,7 +620,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
|
||||
<!-- empty:hidden covers what the vnode walk can't see: slot content that IS a
|
||||
component but renders nothing, e.g. the AI button on a non-AI build. -->
|
||||
<div
|
||||
class="peer bg-surface-overlay border-border-default rounded-default flex items-center gap-1 border border-solid px-1 py-0.5 shadow-lg empty:hidden"
|
||||
class="peer bg-surface-overlay rounded-default shadow-overlay flex items-center gap-1 border border-solid border-transparent px-1 py-0.5 empty:hidden"
|
||||
>
|
||||
<slot
|
||||
name="cell-hover-actions"
|
||||
|
||||
@@ -71,3 +71,72 @@ describe("OTableBodyRow detail row measurement", () => {
|
||||
expect(measure).toHaveBeenCalledWith(wrapper.find('[data-test="o2-table-row-0"]').element);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OTableBodyRow selection marking", () => {
|
||||
let wrapper: VueWrapper | undefined;
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
const mountSelectable = (props: Record<string, unknown>) => {
|
||||
const Host = defineComponent({
|
||||
setup: () => () =>
|
||||
h("table", [
|
||||
h("tbody", [h(OTableBodyRow, { row: row as any, table: {} as any, ...props })]),
|
||||
]),
|
||||
});
|
||||
wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||
return wrapper.find('[data-test="o2-table-row-0"]').classes();
|
||||
};
|
||||
|
||||
it("omits the hover fill classes on a selected row", () => {
|
||||
expect(mountSelectable({ isRowSelected: false })).toContain("hover:bg-table-row-hover-bg");
|
||||
const selected = mountSelectable({ isRowSelected: true, clickable: true });
|
||||
expect(selected).toContain("bg-table-row-selected-bg");
|
||||
expect(selected).not.toContain("hover:bg-table-row-hover-bg");
|
||||
expect(selected).not.toContain("focus-visible:bg-table-row-hover-bg");
|
||||
});
|
||||
|
||||
it("sets the tinted secondary-text variable on selected, active, hovered and focused rows", () => {
|
||||
const tinted = "[--color-text-secondary:var(--color-table-row-text-secondary-tinted)]";
|
||||
expect(mountSelectable({ isRowSelected: true, clickable: true })).toContain(tinted);
|
||||
expect(mountSelectable({ isActive: true })).toContain(tinted);
|
||||
const idle = mountSelectable({ clickable: true });
|
||||
expect(idle).not.toContain(tinted);
|
||||
expect(idle).toEqual(expect.arrayContaining([`hover:${tinted}`, `focus-visible:${tinted}`]));
|
||||
});
|
||||
|
||||
it("outlines a selected spine row instead of stacking a bar on the spine", () => {
|
||||
const classes = mountSelectable({ isRowSelected: true, statusBarColor: "red" });
|
||||
expect(classes).toContain("ring-accent");
|
||||
expect(classes).not.toContain("o2-table-row-selected-bar");
|
||||
});
|
||||
|
||||
it("draws the selection bar on a selected row without a visible spine", () => {
|
||||
for (const statusBarColor of [undefined, "transparent"]) {
|
||||
const classes = mountSelectable({ isRowSelected: true, statusBarColor });
|
||||
expect(classes).toContain("o2-table-row-selected-bar");
|
||||
expect(classes).not.toContain("ring-accent");
|
||||
wrapper?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a clickable row an inset focus-visible outline and no outline transition", () => {
|
||||
const classes = mountSelectable({ clickable: true });
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"focus-visible:outline-solid",
|
||||
"focus-visible:outline-2",
|
||||
"focus-visible:-outline-offset-2",
|
||||
"focus-visible:outline-focus-ring-accent",
|
||||
]),
|
||||
);
|
||||
expect(classes).toContain("transition-[background-color]");
|
||||
expect(classes).not.toContain("transition-colors");
|
||||
});
|
||||
|
||||
it("puts the hover-in and hover-out motion tokens on the background transition", () => {
|
||||
const classes = mountSelectable({});
|
||||
expect(classes).toContain("duration-(--duration-hover-out)");
|
||||
expect(classes).toContain("hover:duration-(--duration-hover-in)");
|
||||
expect(classes.some((c) => c.startsWith("focus-visible:outline"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,11 @@ const isStriped = computed(() => {
|
||||
return props.striped && props.row.index % 2 === 1;
|
||||
});
|
||||
|
||||
// A row with no detected level gets "transparent", which paints no spine.
|
||||
const hasVisibleSpine = computed(
|
||||
() => !!props.statusBarColor && props.statusBarColor !== "transparent",
|
||||
);
|
||||
|
||||
const hasExpansionSlot = computed(() => !!slots.expansion);
|
||||
|
||||
// ── Tree mode ────────────────────────────────────────────────────
|
||||
@@ -221,12 +226,20 @@ function onRowMouseleave() {
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
:class="[
|
||||
'group/row',
|
||||
'transition-colors duration-150',
|
||||
'transition-[background-color] duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
|
||||
clickable ? 'cursor-pointer' : '',
|
||||
'hover:bg-table-row-hover-bg',
|
||||
clickable ? 'focus-visible:bg-table-row-hover-bg focus:outline-none' : '',
|
||||
isRowSelected || isActive ? 'bg-table-row-selected-bg' : '',
|
||||
isRowSelected || isActive
|
||||
? 'bg-table-row-selected-bg [--color-text-secondary:var(--color-table-row-text-secondary-tinted)]'
|
||||
: 'hover:bg-table-row-hover-bg hover:[--color-text-secondary:var(--color-table-row-text-secondary-tinted)]',
|
||||
clickable
|
||||
? 'focus-visible:outline-focus-ring-accent outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid'
|
||||
: '',
|
||||
clickable && !isRowSelected && !isActive
|
||||
? 'focus-visible:bg-table-row-hover-bg focus-visible:[--color-text-secondary:var(--color-table-row-text-secondary-tinted)]'
|
||||
: '',
|
||||
isActive ? 'ring-focus-ring-accent ring-2 ring-inset' : '',
|
||||
isRowSelected && !isActive && hasVisibleSpine ? 'ring-accent ring-1 ring-inset' : '',
|
||||
(isRowSelected || isActive) && !hasVisibleSpine ? 'o2-table-row-selected-bar' : '',
|
||||
!isRowSelected && !isActive && isStriped ? 'bg-table-row-striped-bg' : '',
|
||||
statusBarColor ? 'o2-table-row-with-status' : '',
|
||||
rowClass,
|
||||
@@ -416,6 +429,22 @@ function onRowMouseleave() {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* keep(complex-state): selection bar on a row without a status spine, drawn like the spine so a pinned or expand cell cannot cover it */
|
||||
.o2-table-row-selected-bar > td:first-child {
|
||||
position: relative;
|
||||
}
|
||||
.o2-table-row-selected-bar > td:first-child::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0.125rem;
|
||||
background: var(--color-accent);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* keep(generated-content): continuation of the tree connector vertical line
|
||||
through the warning row, drawn as an ::after off the inline connector-x var. */
|
||||
.o2-table-tree-warning-cell::after {
|
||||
|
||||
@@ -846,7 +846,7 @@ function getStandardStickyTotalStyle(header: any): Record<string, any> {
|
||||
v-if="(header.column.columnDef.meta as any)?.closable"
|
||||
type="button"
|
||||
:data-test="`o2-table-th-remove-${header.id}-btn`"
|
||||
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 max-md:opacity-100"
|
||||
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 max-md:opacity-100"
|
||||
:aria-label="t('components.table.removeColumnAria')"
|
||||
@click.stop="emit('close-column', header.column.columnDef)"
|
||||
>
|
||||
@@ -1260,7 +1260,7 @@ function getStandardStickyTotalStyle(header: any): Record<string, any> {
|
||||
v-if="(header.column.columnDef.meta as any)?.closable"
|
||||
type="button"
|
||||
:data-test="`o2-table-th-remove-${header.id}-btn`"
|
||||
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 max-md:opacity-100"
|
||||
class="rounded-default text-text-secondary hover:text-text-body hover:bg-table-row-hover-bg inline-flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 max-md:opacity-100"
|
||||
:aria-label="t('components.table.removeColumnAria')"
|
||||
@click.stop="emit('close-column', header.column.columnDef)"
|
||||
>
|
||||
|
||||
@@ -78,7 +78,7 @@ const selectionLabel = computed(() => {
|
||||
<div
|
||||
:data-test="`o2-table-pagination-${position}`"
|
||||
:class="[
|
||||
'flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1',
|
||||
'flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 max-md:gap-y-2',
|
||||
bordered ? 'border-border-default border-t' : '',
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -257,8 +257,8 @@ const hasInsideLabel = computed(
|
||||
'z-10001 w-(--reka-combobox-trigger-width) max-w-(--reka-combobox-trigger-width) min-w-(--reka-combobox-trigger-width)',
|
||||
'max-h-60 overflow-hidden',
|
||||
'max-lg:max-h-[var(--reka-popper-available-height,15rem)]',
|
||||
'rounded-default border shadow-lg',
|
||||
'bg-select-content-bg border-select-content-border',
|
||||
'rounded-default shadow-overlay border border-transparent',
|
||||
'bg-select-content-bg',
|
||||
'p-1',
|
||||
// Clip-path reveal: unveiled at full size from its trigger edge (no
|
||||
// scale/squish). Wipes down by default; top-placed wipes up. Soft
|
||||
|
||||
@@ -491,12 +491,12 @@ const wrapperClasses = computed(() => [
|
||||
:id="errorId"
|
||||
:data-test="parentDataTest ? `${parentDataTest}-error` : undefined"
|
||||
:data-test-error-text="effectiveError"
|
||||
class="text-input-error-text text-xs leading-none"
|
||||
class="text-input-error-text text-xs leading-snug"
|
||||
role="alert"
|
||||
>
|
||||
{{ effectiveError }}
|
||||
</span>
|
||||
<span v-else-if="helpText" class="text-input-hint text-xs leading-none">
|
||||
<span v-else-if="helpText" class="text-input-hint text-xs leading-snug">
|
||||
{{ helpText }}
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
|
||||
@@ -1482,7 +1482,7 @@ const fieldWidthClass = computed(() => {
|
||||
:class="[
|
||||
'z-10001 min-w-(--reka-popover-trigger-width)',
|
||||
'flex flex-col overflow-hidden',
|
||||
'rounded-default shadow-lg',
|
||||
'rounded-default shadow-overlay border border-transparent',
|
||||
'bg-select-content-bg',
|
||||
]"
|
||||
:style="[
|
||||
@@ -1535,7 +1535,7 @@ const fieldWidthClass = computed(() => {
|
||||
'cursor-pointer outline-none select-none',
|
||||
'hover:bg-select-item-hover-bg',
|
||||
'focus-visible:bg-select-item-hover-bg',
|
||||
'transition-colors duration-100',
|
||||
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
|
||||
'border-select-content-border mb-1 border-b pb-1.5',
|
||||
]"
|
||||
data-test="o-select-all"
|
||||
@@ -1654,7 +1654,7 @@ const fieldWidthClass = computed(() => {
|
||||
:class="[
|
||||
'flex h-full w-full items-center justify-between gap-2 ps-3 pe-3 text-sm',
|
||||
'text-select-item-text cursor-pointer select-none',
|
||||
'transition-colors duration-100',
|
||||
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
|
||||
vRow.index === highlightedIndex
|
||||
? 'bg-select-item-hover-bg'
|
||||
: 'hover:bg-select-item-hover-bg',
|
||||
@@ -1700,7 +1700,7 @@ const fieldWidthClass = computed(() => {
|
||||
'pe-3 text-sm',
|
||||
'text-select-item-text',
|
||||
'cursor-pointer outline-none select-none',
|
||||
'transition-colors duration-100',
|
||||
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
|
||||
// Use flex-col for stacked rich items; flex-row for inline subLabel or simple items
|
||||
(filteredOptions[vRow.index].subLabel &&
|
||||
!filteredOptions[vRow.index].subLabelInline) ||
|
||||
@@ -2070,8 +2070,8 @@ const fieldWidthClass = computed(() => {
|
||||
'z-10001 min-w-(--reka-select-trigger-width) max-lg:max-w-[calc(100vw-1rem)]',
|
||||
'overflow-hidden',
|
||||
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
|
||||
'rounded-default border shadow-md',
|
||||
'bg-select-content-bg border-select-content-border',
|
||||
'rounded-default shadow-overlay border border-transparent',
|
||||
'bg-select-content-bg',
|
||||
// Clip-path reveal: unveiled at full size from its trigger edge (no
|
||||
// scale/squish). Wipes down by default; top-placed wipes up. Soft
|
||||
// ease-out-expo in (200ms), quick wipe out (140ms).
|
||||
|
||||
@@ -53,7 +53,7 @@ onUnmounted(() => {
|
||||
'py-1.5 ps-3 pe-3 text-sm',
|
||||
'text-select-item-text rounded-default',
|
||||
'cursor-pointer outline-none select-none',
|
||||
'transition-colors duration-100',
|
||||
'transition-colors duration-(--duration-hover-out) data-highlighted:duration-(--duration-hover-in)',
|
||||
'data-highlighted:bg-select-item-hover-bg',
|
||||
'data-[state=checked]:bg-select-item-selected-bg data-[state=checked]:text-select-item-selected-text',
|
||||
'data-disabled:text-select-item-disabled data-disabled:pointer-events-none data-disabled:cursor-not-allowed',
|
||||
|
||||
@@ -63,7 +63,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:placeholder="
|
||||
modelValue.length > 0 ? '' : (placeholder ?? t('common.typeAndPressEnterOrComma'))
|
||||
"
|
||||
class="text-text-body placeholder:text-text-secondary min-w-25 [flex:1_1_6.25rem] border-0 bg-transparent p-1 text-sm outline-none"
|
||||
class="text-text-body placeholder:text-input-placeholder min-w-25 [flex:1_1_6.25rem] border-0 bg-transparent p-1 text-sm outline-none"
|
||||
@keydown.enter.prevent="addTag"
|
||||
@input="handleInput"
|
||||
@keydown.delete="handleBackspace"
|
||||
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="o-field-row group rounded-default hover:bg-field-list-row-hover-bg relative flex h-6 w-full items-center gap-1 ps-1"
|
||||
class="o-field-row group rounded-default hover:bg-field-list-row-hover-bg relative flex h-6 w-full items-center gap-1 ps-1 max-md:h-7"
|
||||
:class="[highlight && 'bg-field-list-row-hover-bg!']"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -195,51 +195,48 @@ describe("OTab", () => {
|
||||
expect(wrapper.find('[role="tab"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- Transition & focus ring (post-migration tweaks) ---
|
||||
// --- Transition & focus outline ---
|
||||
|
||||
it("applies the multi-property transition utility for smooth state changes", () => {
|
||||
const wrapper = mountTab();
|
||||
const classes = wrapper.find('[role="tab"]').classes();
|
||||
// The migration replaced `transition-colors` with an explicit
|
||||
// multi-property transition that also animates border-color and box-shadow.
|
||||
it("puts the hover-in and hover-out motion tokens on the colour transition", () => {
|
||||
const classes = mountTab().find('[role="tab"]').classes();
|
||||
expect(classes).toContain(
|
||||
"transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow]",
|
||||
"transition-[color,background-color,border-color,text-decoration-color,fill,stroke]",
|
||||
);
|
||||
expect(classes).toContain("duration-150");
|
||||
// Sanity: the old narrow utility should NOT be present anymore.
|
||||
expect(classes).toContain("duration-(--duration-hover-out)");
|
||||
expect(classes).toContain("hover:duration-(--duration-hover-in)");
|
||||
expect(classes).not.toContain("transition-colors");
|
||||
});
|
||||
|
||||
it("renders ring offset utilities so the focus ring is visually separated from the tab edge", () => {
|
||||
const wrapper = mountTab();
|
||||
const classes = wrapper.find('[role="tab"]').classes();
|
||||
expect(classes).toContain("ring-offset-1");
|
||||
expect(classes).toContain("ring-offset-surface-base");
|
||||
it("uses an accent outline, not a box-shadow ring, for focus", () => {
|
||||
const classes = mountTab().find('[role="tab"]').classes();
|
||||
expect(classes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"focus-visible:outline-solid",
|
||||
"focus-visible:outline-2",
|
||||
"focus-visible:outline-focus-ring-accent",
|
||||
]),
|
||||
);
|
||||
expect(classes.some((c) => /(^|:)ring-/.test(c))).toBe(false);
|
||||
});
|
||||
|
||||
it("applies a focus-visible ring using the tabs indicator color", () => {
|
||||
const wrapper = mountTab();
|
||||
const classes = wrapper.find('[role="tab"]').classes();
|
||||
expect(classes).toContain("focus-visible:outline-none");
|
||||
expect(classes).toContain("focus-visible:ring-2");
|
||||
expect(classes).toContain("focus-visible:ring-tabs-indicator");
|
||||
it("uses a negative focus outline offset on a horizontal tab", () => {
|
||||
const classes = mountTab().find('[role="tab"]').classes();
|
||||
expect(classes).toContain("focus-visible:-outline-offset-2");
|
||||
expect(classes).not.toContain("focus-visible:outline-offset-2");
|
||||
});
|
||||
|
||||
it("keeps the focus ring utilities on disabled tabs (keyboard navigation still highlights them)", () => {
|
||||
const wrapper = mountTab({ disable: true });
|
||||
const classes = wrapper.find('[role="tab"]').classes();
|
||||
// Focus ring classes belong to baseClasses and must remain regardless of state.
|
||||
expect(classes).toContain("focus-visible:ring-2");
|
||||
expect(classes).toContain("focus-visible:ring-tabs-indicator");
|
||||
expect(classes).toContain("ring-offset-1");
|
||||
it("uses a positive focus outline offset on a vertical tab", () => {
|
||||
const classes = mountTab({ isVertical: true }).find('[role="tab"]').classes();
|
||||
expect(classes).toContain("focus-visible:outline-offset-2");
|
||||
expect(classes).not.toContain("focus-visible:-outline-offset-2");
|
||||
});
|
||||
|
||||
it("keeps the focus ring utilities on the active tab", () => {
|
||||
const wrapper = mountTab({ name: "tab1", activeTab: "tab1" });
|
||||
const classes = wrapper.find('[role="tab"]').classes();
|
||||
expect(classes).toContain("focus-visible:ring-2");
|
||||
expect(classes).toContain("focus-visible:ring-tabs-indicator");
|
||||
expect(classes).toContain("ring-offset-surface-base");
|
||||
it("keeps the focus outline classes on disabled and active tabs", () => {
|
||||
for (const opts of [{ disable: true }, { name: "tab1", activeTab: "tab1" }]) {
|
||||
const classes = mountTab(opts).find('[role="tab"]').classes();
|
||||
expect(classes).toContain("focus-visible:outline-2");
|
||||
expect(classes).toContain("focus-visible:outline-focus-ring-accent");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Grip-gated drag (reorderable) ---
|
||||
|
||||
@@ -92,10 +92,11 @@ const baseClasses = computed<string>(() =>
|
||||
: "px-2",
|
||||
"font-normal text-sm whitespace-nowrap",
|
||||
isVertical.value ? "rounded-default" : "rounded-t-default",
|
||||
"outline-none transition-[color,background-color,border-color,text-decoration-color,fill,stroke,box-shadow] duration-150",
|
||||
"outline-none transition-[color,background-color,border-color,text-decoration-color,fill,stroke] duration-(--duration-hover-out) hover:duration-(--duration-hover-in)",
|
||||
"select-none",
|
||||
"ring-offset-1 ring-offset-surface-base",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tabs-indicator",
|
||||
"focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-focus-ring-accent",
|
||||
// The horizontal strip scrolls on x, which clips y as well, so an outside outline would lose its bottom edge.
|
||||
isVertical.value ? "focus-visible:outline-offset-2" : "focus-visible:-outline-offset-2",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ onBeforeUnmount(() => {
|
||||
// Surface — a context menu is visually the same object as a dropdown
|
||||
// menu, so it deliberately shares the dropdown token set rather than
|
||||
// duplicating one that would have to be kept in sync.
|
||||
'bg-dropdown-bg border-dropdown-border rounded-default border shadow-md',
|
||||
'bg-dropdown-bg rounded-default shadow-overlay border border-transparent',
|
||||
// Typography
|
||||
'text-dropdown-item-text text-sm',
|
||||
// Animation — same clip-path reveal as ODropdown, wiping away from
|
||||
|
||||
@@ -56,7 +56,7 @@ function onSelect(event: Event) {
|
||||
'relative flex items-center gap-2',
|
||||
'rounded-default w-full px-3 py-1.5',
|
||||
'cursor-pointer outline-none select-none',
|
||||
'transition-colors duration-150',
|
||||
'transition-colors duration-(--duration-hover-out) data-[highlighted]:duration-(--duration-hover-in)',
|
||||
unavailable
|
||||
? 'text-dropdown-item-disabled focus-visible:ring-focus-ring-accent cursor-not-allowed focus-visible:ring-2 focus-visible:ring-inset'
|
||||
: variantClasses[variant],
|
||||
|
||||
@@ -304,7 +304,7 @@ onBeforeUnmount(() => {
|
||||
'min-w-40 p-1 max-lg:max-w-[calc(100vw-1rem)]',
|
||||
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
|
||||
// Surface
|
||||
'bg-dropdown-bg border-dropdown-border rounded-default border shadow-md',
|
||||
'bg-dropdown-bg rounded-default shadow-overlay border border-transparent',
|
||||
// Typography
|
||||
'text-dropdown-item-text text-sm',
|
||||
// Animation — clip-path reveal: the menu is unveiled at full size from
|
||||
|
||||
@@ -57,7 +57,7 @@ const variantClasses: Record<NonNullable<DropdownItemProps["variant"]>, string>
|
||||
'group/item relative flex items-center gap-2',
|
||||
'rounded-default w-full px-3 py-1.5',
|
||||
'cursor-pointer outline-none select-none',
|
||||
'transition-colors duration-150',
|
||||
'transition-colors duration-(--duration-hover-out) data-[highlighted]:duration-(--duration-hover-in)',
|
||||
variantClasses[variant],
|
||||
'data-[disabled]:text-dropdown-item-disabled data-[disabled]:cursor-not-allowed',
|
||||
unavailable &&
|
||||
@@ -75,7 +75,7 @@ const variantClasses: Record<NonNullable<DropdownItemProps["variant"]>, string>
|
||||
<slot name="icon-left">
|
||||
<OIcon v-if="props.iconLeft" :name="props.iconLeft" size="sm" />
|
||||
</slot>
|
||||
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span><slot /></span>
|
||||
<OText
|
||||
:id="reasonId"
|
||||
|
||||
@@ -83,10 +83,10 @@ function onKeydown(event: KeyboardEvent) {
|
||||
type="button"
|
||||
:text-value="textValue"
|
||||
:aria-describedby="description ? descriptionDomId : undefined"
|
||||
class="group/sub rounded-default text-dropdown-item-text data-[highlighted]:bg-dropdown-item-hover-bg relative flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-start transition-colors duration-150 outline-none select-none"
|
||||
class="group/sub rounded-default text-dropdown-item-text data-[highlighted]:bg-dropdown-item-hover-bg relative flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-start transition-colors duration-(--duration-hover-out) outline-none select-none data-[highlighted]:duration-(--duration-hover-in)"
|
||||
>
|
||||
<slot name="icon-left" />
|
||||
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span v-if="description" class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span><slot name="trigger" /></span>
|
||||
<OText
|
||||
:id="descriptionDomId"
|
||||
@@ -106,7 +106,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
:collision-padding="8"
|
||||
:hide-when-detached="true"
|
||||
:style="{ zIndex: contentZIndex }"
|
||||
class="bg-dropdown-bg border-dropdown-border rounded-default text-dropdown-item-text max-h-[var(--reka-popper-available-height,75vh)] max-w-[calc(100vw-1rem)] min-w-40 overflow-y-auto border p-1 text-sm shadow-md"
|
||||
class="bg-dropdown-bg rounded-default text-dropdown-item-text shadow-overlay max-h-[var(--reka-popper-available-height,75vh)] max-w-[calc(100vw-1rem)] min-w-40 overflow-y-auto border border-transparent p-1 text-sm"
|
||||
@keydown.capture="onKeydown"
|
||||
@pointer-down-outside="onPointerDownOutside"
|
||||
>
|
||||
|
||||
@@ -243,7 +243,7 @@ onBeforeUnmount(() => {
|
||||
'outline-none max-lg:max-w-[calc(100vw-1rem)]',
|
||||
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
|
||||
// Surface
|
||||
'bg-dropdown-bg border-dropdown-border rounded-default border shadow-md',
|
||||
'bg-dropdown-bg rounded-default shadow-overlay border border-transparent',
|
||||
// Open/close reveal animation (matches ODropdown)
|
||||
'data-[state=open]:animate-[o2-reveal-down-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
|
||||
'data-[state=closed]:animate-[o2-reveal-down-out_100ms_cubic-bezier(0.4,0,1,1)]',
|
||||
|
||||
@@ -306,7 +306,7 @@ const contentStyle = computed(() => ({
|
||||
const contentClasses = computed(() => [
|
||||
"z-10100 px-2.5 py-1.5",
|
||||
"bg-surface-overlay rounded-default",
|
||||
"text-xs text-text-body font-medium leading-relaxed",
|
||||
"text-xs text-text-body font-medium leading-snug",
|
||||
// Force long unbreakable tokens (file paths, hashes, URLs) to wrap inside the
|
||||
// max-width box instead of overflowing onto the content behind the tooltip.
|
||||
// overflow-wrap is inherited, so nested content (e.g. pre-wrap divs) wraps too.
|
||||
|
||||
@@ -60,11 +60,14 @@
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.025em;
|
||||
/* Headings tighten as they grow; 13px h6 stays at 0 because small text needs its spacing. */
|
||||
--tracking-heading-display: -0.04em;
|
||||
--tracking-heading: -0.02em;
|
||||
|
||||
/* --- Font weight scale --- */
|
||||
--font-normal: normal;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-semibold: 550; /* Geist is variable, so 550 renders between medium and bold */
|
||||
--font-bold: 700;
|
||||
/* --- Primary palette — blue-teal #3F7994 ---
|
||||
The runtime theme engine is the AUTHORITY for brand colour. `constants/themes.ts`
|
||||
@@ -265,10 +268,8 @@
|
||||
--color-chart-table-23: #ffe6e6;
|
||||
--color-chart-table-24: #e8f4fd;
|
||||
|
||||
/* --- Chart grid line (dashed split-line) — light value; dark in dark.css.
|
||||
1.4.11 exempt (object required to understand content), raised for
|
||||
perceptibility rather than to hit 3:1. --- */
|
||||
--color-chart-gridline: rgba(0, 0, 0, 0.2);
|
||||
/* Solid horizontal split-line, faint on purpose: 1.4.11 exempt, the series carries the data. */
|
||||
--color-chart-gridline: rgba(0, 10, 23, 0.09);
|
||||
|
||||
/* Expected-range band fill; alpha lives in the token so the legend swatch matches. */
|
||||
--color-chart-band: rgba(115, 115, 115, 0.22);
|
||||
@@ -607,13 +608,18 @@
|
||||
--color-log-severity-emergency-indicator: #e53935;
|
||||
--color-log-severity-alert-indicator: #ea580c;
|
||||
--color-log-severity-critical-indicator: #f4511e;
|
||||
--color-log-severity-error-indicator: #ef5350;
|
||||
--color-log-severity-warning-indicator: #dd7b00;
|
||||
--color-log-severity-error-indicator: #e53935;
|
||||
--color-log-severity-warning-indicator: #c46a00;
|
||||
--color-log-severity-notice-indicator: #16a34a;
|
||||
--color-log-severity-info-indicator: #1e88e5;
|
||||
--color-log-severity-debug-indicator: #00a3b7;
|
||||
--color-log-severity-info-indicator: #1b80d9;
|
||||
--color-log-severity-debug-indicator: #008a9b;
|
||||
--color-log-severity-trace-indicator: #8497a0;
|
||||
--color-log-severity-ok-indicator: #43a047;
|
||||
/* Logs level-column text: own knobs, so retuning them never moves the shared status text. */
|
||||
--color-log-severity-error-text: #b8323f;
|
||||
--color-log-severity-warning-text: #a14b0f;
|
||||
--color-log-severity-debug-text: #5c606c;
|
||||
--color-log-severity-info-text: var(--color-text-body);
|
||||
--color-log-table-header-bg: #f1f3f7;
|
||||
--color-log-table-row-bg: #ffffff;
|
||||
--color-log-table-row-border: #e4e6ec;
|
||||
|
||||
@@ -389,8 +389,8 @@ describe("the real token sheets", () => {
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["light", 1076, 1064],
|
||||
["dark", 1077, 1065],
|
||||
["light", 1081, 1069],
|
||||
["dark", 1082, 1070],
|
||||
] as const)("%s theme", (theme, total, resolved) => {
|
||||
const scope = THEMES[theme];
|
||||
const names = colorTokens(scope);
|
||||
|
||||
@@ -575,8 +575,8 @@
|
||||
/* Input (OInput, OTextarea) */
|
||||
--color-input-bg: var(--color-surface-base);
|
||||
--color-input-border: var(--color-border-control);
|
||||
--color-input-border-hover: var(--color-primary-300);
|
||||
--color-input-border-focus: var(--color-primary-300);
|
||||
--color-input-border-hover: var(--color-accent);
|
||||
--color-input-border-focus: var(--color-accent);
|
||||
--color-input-border-error: var(--color-error-500);
|
||||
/* Disabled-bg uses grey-200 (#e5e5e5) — `surface-subtle` (grey-100 #f5f5f5)
|
||||
was almost identical to the enabled white bg in light mode, making
|
||||
@@ -586,7 +586,7 @@
|
||||
--color-input-disabled-text: var(--color-text-secondary);
|
||||
--color-input-focus-ring: var(--color-focus-ring-accent);
|
||||
--color-input-text: var(--color-text-heading);
|
||||
--color-input-placeholder: var(--color-text-disabled);
|
||||
--color-input-placeholder: var(--color-text-placeholder);
|
||||
/* text-heading (not text-secondary) so labels don't read as disabled/hint text */
|
||||
--color-input-label: var(--color-text-heading);
|
||||
--color-input-hint: var(--color-text-secondary);
|
||||
@@ -601,14 +601,14 @@
|
||||
--color-select-bg: var(--color-surface-base);
|
||||
--color-select-border: var(--color-border-control);
|
||||
--color-select-disabled-border: var(--color-border-default);
|
||||
--color-select-border-hover: var(--color-primary-300);
|
||||
--color-select-border-focus: var(--color-primary-300);
|
||||
--color-select-border-hover: var(--color-accent);
|
||||
--color-select-border-focus: var(--color-accent);
|
||||
--color-select-border-error: var(--color-error-500);
|
||||
/* Same rationale as input-disabled-bg. */
|
||||
--color-select-disabled-bg: var(--color-grey-200);
|
||||
--color-select-disabled-text: var(--color-text-secondary);
|
||||
--color-select-text: var(--color-text-soft);
|
||||
--color-select-placeholder: var(--color-text-disabled);
|
||||
--color-select-placeholder: var(--color-text-placeholder);
|
||||
/* text-heading, not text-secondary (same as input-label) */
|
||||
--color-select-label: var(--color-text-heading);
|
||||
--color-select-icon: var(--color-text-secondary);
|
||||
@@ -692,7 +692,7 @@
|
||||
--color-file-disabled-bg: var(--color-surface-subtle);
|
||||
--color-file-disabled-border: var(--color-border-default);
|
||||
--color-file-text: var(--color-text-heading);
|
||||
--color-file-placeholder: var(--color-text-disabled);
|
||||
--color-file-placeholder: var(--color-text-placeholder);
|
||||
--color-file-label: var(--color-text-secondary);
|
||||
--color-file-icon: var(--color-text-secondary);
|
||||
--color-file-error-text: var(--color-error-600);
|
||||
@@ -712,7 +712,7 @@
|
||||
--color-datepicker-disabled-bg: var(--color-surface-subtle);
|
||||
--color-datepicker-disabled-border: var(--color-border-default);
|
||||
--color-datepicker-text: var(--color-text-heading);
|
||||
--color-datepicker-placeholder: var(--color-text-disabled);
|
||||
--color-datepicker-placeholder: var(--color-text-placeholder);
|
||||
--color-datepicker-label: var(--color-text-label);
|
||||
--color-datepicker-icon: var(--color-text-secondary);
|
||||
--color-datepicker-error-text: var(--color-error-600);
|
||||
@@ -765,8 +765,19 @@
|
||||
--color-table-header-bg: var(--color-surface-panel);
|
||||
--color-table-header-text: var(--color-text-secondary);
|
||||
--color-table-header-border: var(--color-border-default);
|
||||
--color-table-row-hover-bg: var(--color-primary-50);
|
||||
--color-table-row-selected-bg: var(--color-primary-100);
|
||||
/* Hover clears ~1.19:1 on white and selected sits ~1.08:1 above it, so both read without sinking secondary text. */
|
||||
--color-table-row-hover-bg: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary-100) 85%,
|
||||
var(--color-primary-50)
|
||||
);
|
||||
--color-table-row-selected-bg: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary-200) 30%,
|
||||
var(--color-primary-100)
|
||||
);
|
||||
/* Rows re-point text-secondary to this on the hover/selected fills, where plain grey-500 falls below 4.5:1. */
|
||||
--color-table-row-text-secondary-tinted: var(--color-grey-600);
|
||||
--color-table-row-striped-bg: var(--color-surface-subtle);
|
||||
/* Row dividers use the SOFT border: lighter than the structural table outline /
|
||||
sticky-header underline so rows read as a calm list, not a hard grid. The
|
||||
@@ -1988,6 +1999,7 @@
|
||||
--color-table-header-border: var(--color-table-header-border);
|
||||
--color-table-row-hover-bg: var(--color-table-row-hover-bg);
|
||||
--color-table-row-selected-bg: var(--color-table-row-selected-bg);
|
||||
--color-table-row-text-secondary-tinted: var(--color-table-row-text-secondary-tinted);
|
||||
--color-table-row-striped-bg: var(--color-table-row-striped-bg);
|
||||
--color-table-row-divider: var(--color-table-row-divider);
|
||||
--color-table-border: var(--color-table-border);
|
||||
@@ -2171,6 +2183,10 @@
|
||||
--color-log-severity-debug-indicator: var(--color-log-severity-debug-indicator);
|
||||
--color-log-severity-trace-indicator: var(--color-log-severity-trace-indicator);
|
||||
--color-log-severity-ok-indicator: var(--color-log-severity-ok-indicator);
|
||||
--color-log-severity-error-text: var(--color-log-severity-error-text);
|
||||
--color-log-severity-warning-text: var(--color-log-severity-warning-text);
|
||||
--color-log-severity-debug-text: var(--color-log-severity-debug-text);
|
||||
--color-log-severity-info-text: var(--color-log-severity-info-text);
|
||||
--color-log-table-row-bg: var(--color-log-table-row-bg);
|
||||
--color-log-table-row-border: var(--color-log-table-row-border);
|
||||
--color-purple-700: var(--color-purple-700);
|
||||
|
||||
@@ -179,6 +179,12 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
|
||||
category: "exempt",
|
||||
why: "SC 1.4.3 exempts text and boundaries that belong to a disabled, inactive control",
|
||||
},
|
||||
{
|
||||
fg: "--color-table-row-text-secondary-tinted",
|
||||
bg: "--color-table-row-selected-bg",
|
||||
category: "text",
|
||||
why: "secondary text inside a selected row sits on the selected fill, the lower-contrast of the two row fills in both themes",
|
||||
},
|
||||
{
|
||||
fg: "--color-table-row-divider",
|
||||
category: "exempt",
|
||||
@@ -702,20 +708,6 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
themes: ["dark"],
|
||||
},
|
||||
{
|
||||
fg: "--color-input-border-focus",
|
||||
category: "pending",
|
||||
why: "fails the threshold against the page surface it is painted on",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
themes: ["light"],
|
||||
},
|
||||
{
|
||||
fg: "--color-input-border-hover",
|
||||
category: "pending",
|
||||
why: "fails the threshold against the page surface it is painted on",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
themes: ["light"],
|
||||
},
|
||||
{
|
||||
fg: "--color-label-chip-email-text",
|
||||
bg: "--color-label-chip-email-bg",
|
||||
@@ -846,20 +838,6 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
|
||||
why: "a schedule band's own solid fill, painted on it directly, never on the page — amber falls short of AA for this shared white label",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
},
|
||||
{
|
||||
fg: "--color-select-border-focus",
|
||||
category: "pending",
|
||||
why: "fails the threshold against the page surface it is painted on",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
themes: ["light"],
|
||||
},
|
||||
{
|
||||
fg: "--color-select-border-hover",
|
||||
category: "pending",
|
||||
why: "fails the threshold against the page surface it is painted on",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
themes: ["light"],
|
||||
},
|
||||
{
|
||||
fg: "--color-slider-error-text",
|
||||
category: "pending",
|
||||
@@ -907,12 +885,6 @@ export const CONTRAST_PAIRS: readonly ContrastPair[] = [
|
||||
why: "fails the threshold against the page surface it is painted on",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
},
|
||||
{
|
||||
fg: "--color-text-placeholder",
|
||||
category: "pending",
|
||||
why: "fails the threshold against the page surface it is painted on",
|
||||
issue: "WCAG-AA colour remediation (follow-up PR)",
|
||||
},
|
||||
{
|
||||
fg: "--color-thread-tool-link",
|
||||
category: "pending",
|
||||
|
||||
@@ -64,7 +64,7 @@ const LARGE_TEXT_MIN = 3;
|
||||
const NON_TEXT_MIN = 3;
|
||||
|
||||
/** Ratchets. Both only ever go down; see the module docstring. */
|
||||
const MAX_PENDING = 93;
|
||||
const MAX_PENDING = 88;
|
||||
const MAX_EXEMPT = 37;
|
||||
/** Manifest entries on a bare alias, which are never judged. Only goes down. */
|
||||
const MAX_UNJUDGED = 7;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
well = recessed / segmented track
|
||||
overlay = menus, dropdowns, tooltips */
|
||||
--d-chrome: #0e0f11; /* title bar + left activity-bar strip */
|
||||
--d-base: #0d1117; /* MAIN: content card + table canvas */
|
||||
--d-base: #111111; /* MAIN: content card + table canvas */
|
||||
--d-well: #2d2d2d; /* recessed wells, segmented-control track */
|
||||
--d-panel: #181818; /* folder sidebar, sub rails, cards, dialogs (DM) */
|
||||
--d-overlay: #202020; /* menus, dropdowns, tooltips, table-header (top tier,
|
||||
@@ -66,12 +66,9 @@
|
||||
label==muted==disabled==placeholder all landed on one value). */
|
||||
|
||||
/* Row / item states on the content surface */
|
||||
--d-hover: var(
|
||||
--color-primary-900
|
||||
); /* row & list-item hover — MATCHES
|
||||
the dashboard-folder hover (theme-tinted, not grey) */
|
||||
--d-selected: color-mix(in srgb, var(--color-primary-500) 30%, var(--d-panel));
|
||||
/* selected row — teal-tinted (brand signal) */
|
||||
/* Both mix into the panel so selected stays >=1.2:1 above hover and severity spines keep 3:1 even on bright custom-theme palettes. */
|
||||
--d-hover: color-mix(in srgb, var(--color-primary-900) 50%, var(--d-panel));
|
||||
--d-selected: color-mix(in srgb, var(--color-primary-500) 24%, var(--d-panel));
|
||||
--d-zebra: #252526; /* striped-row bg — VS Code list alternating row */
|
||||
|
||||
/* --- Text — grey palette steps, one per tier (light half of the ramp) --- */
|
||||
@@ -82,9 +79,8 @@
|
||||
--color-text-label: var(--color-grey-500); /* form labels, metadata */
|
||||
--color-text-muted: var(--color-grey-500); /* de-emphasized */
|
||||
--color-text-disabled: var(--color-grey-600); /* dimmer than labels so disabled reads disabled */
|
||||
--color-text-placeholder: var(
|
||||
--color-grey-500
|
||||
); /* input placeholders — dim but legible, above disabled */
|
||||
/* Between grey-500 and grey-400: the lowest grey that clears 4.5:1 on the dark canvas without matching secondary text. */
|
||||
--color-text-placeholder: #7c7c7c;
|
||||
--color-text-link: var(--color-primary-400); /* clickable links (teal) */
|
||||
--color-text-link-hover: var(--color-primary-300); /* link hover state (teal) */
|
||||
--color-text-code: var(--color-grey-300); /* inline code, monospace */
|
||||
@@ -147,7 +143,7 @@
|
||||
--color-surface-panel: var(--d-panel); /* folder rails, cards, table-header base */
|
||||
--color-surface-overlay: var(--d-overlay); /* dropdowns, dialogs, selects, popovers */
|
||||
--color-surface-subtle: var(--d-well); /* recessed fills, segmented track */
|
||||
--color-surface-subtle-hover: var(--d-panel); /* hover on a recessed fill (one step up) */
|
||||
--color-surface-subtle-hover: #383838;
|
||||
/* Floating chip on a dark panel — a lighter grey so it lifts off the surface. */
|
||||
--color-surface-chip: var(--color-grey-700);
|
||||
--color-surface-chip-hover: var(--color-grey-600);
|
||||
@@ -610,6 +606,11 @@
|
||||
--shadow-md-c2: rgb(0 0 0 / 0.35);
|
||||
--shadow-lg-c1: rgb(0 0 0 / 0.55);
|
||||
--shadow-lg-c2: rgb(0 0 0 / 0.4);
|
||||
/* eslint-disable local/no-hardcoded-px -- optical shadow geometry, not layout */
|
||||
--shadow-overlay:
|
||||
inset 0 1px 0 #ffffff0f, 0 0 0 1px var(--d-border), 0 6px 12px -3px #01040966,
|
||||
0 6px 18px 0 #01040966;
|
||||
/* eslint-enable local/no-hardcoded-px */
|
||||
}
|
||||
|
||||
/* Dashboard classic series palette — dark overrides (light values in base.css) */
|
||||
@@ -664,8 +665,7 @@
|
||||
--color-chart-table-23: rgba(255, 180, 180, 0.15);
|
||||
--color-chart-table-24: rgba(180, 225, 245, 0.15);
|
||||
|
||||
/* Chart grid line — dark override, raised for perceptibility (see base.css) */
|
||||
--color-chart-gridline: rgba(255, 255, 255, 0.25);
|
||||
--color-chart-gridline: rgba(240, 250, 255, 0.18);
|
||||
|
||||
/* Chart expected-range band fill — dark override (see base.css) */
|
||||
--color-chart-band: rgba(255, 255, 255, 0.18);
|
||||
@@ -806,6 +806,12 @@
|
||||
--color-table-header-bg: var(--d-overlay); /* header band lifts off rows */
|
||||
--color-table-row-hover-bg: var(--d-hover); /* neutral hover (not teal-washed) */
|
||||
--color-table-row-selected-bg: var(--d-selected); /* teal-tinted selection */
|
||||
/* grey-400 drops to ~4.3:1 on the selected fill of brighter custom themes. */
|
||||
--color-table-row-text-secondary-tinted: color-mix(
|
||||
in srgb,
|
||||
var(--color-grey-300) 50%,
|
||||
var(--color-grey-400)
|
||||
);
|
||||
--color-table-row-striped-bg: var(--d-zebra); /* panel +1 step zebra */
|
||||
--color-table-row-divider: var(--d-divider); /* solid, visible row separators */
|
||||
--color-table-highlight-bg: color-mix(in srgb, var(--color-warning-700) 40%, transparent);
|
||||
@@ -874,6 +880,9 @@
|
||||
--color-log-severity-debug-indicator: #3daab8;
|
||||
--color-log-severity-trace-indicator: #7a9baa;
|
||||
--color-log-severity-ok-indicator: #4dad55;
|
||||
--color-log-severity-error-text: #eb938d;
|
||||
--color-log-severity-warning-text: #e3a15a;
|
||||
--color-log-severity-debug-text: #a8abb2;
|
||||
--color-log-table-header-bg: var(--color-table-header-bg);
|
||||
--color-log-table-row-bg: #21242c;
|
||||
--color-log-table-row-border: rgba(255, 255, 255, 0.14);
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
--color-text-secondary: var(--color-grey-500); /* supporting text, descriptions, captions */
|
||||
--color-text-label: var(--color-grey-400); /* form labels, metadata, badges */
|
||||
--color-text-muted: var(--color-grey-300); /* disabled, placeholder, de-emphasized */
|
||||
/* label / disabled / placeholder (+ border-strong, scrollbar-thumb-hover) are
|
||||
intentionally equal for now: all route through the grey-400 primitive so any one
|
||||
can diverge later. Distinct intents kept as distinct tokens on purpose, NOT aliases. */
|
||||
/* label, disabled, border-strong and scrollbar-thumb-hover share grey-400 but stay separate tokens so each can diverge. */
|
||||
--color-text-disabled: var(--color-grey-400);
|
||||
--color-text-placeholder: var(--color-grey-400);
|
||||
/* grey-500, not grey-400: placeholder text must clear 4.5:1 on white. */
|
||||
--color-text-placeholder: var(--color-grey-500);
|
||||
--color-text-soft: rgba(0, 0, 0, 0.75); /* black at 75% — readable but not harsh */
|
||||
--color-text-link: var(--color-primary-600); /* clickable links */
|
||||
--color-text-link-hover: var(--color-primary-700); /* link hover state */
|
||||
@@ -188,6 +187,19 @@
|
||||
--color-focus-ring-danger: var(--color-error-500);
|
||||
--color-focus-ring-success: var(--color-success-600);
|
||||
--color-focus-ring-warning: var(--color-warning-700);
|
||||
|
||||
/* Motion: hover tints land instantly and fade out, so a moving pointer never trails a lagging highlight. */
|
||||
--duration-hover-in: 0ms;
|
||||
--duration-hover-out: 150ms;
|
||||
--duration-overlay-in: 140ms;
|
||||
--duration-overlay-out: 100ms;
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* Floating menus and popovers: the 1px ring lives in the shadow; dark values in dark.css. */
|
||||
/* eslint-disable local/no-hardcoded-px -- optical shadow geometry, not layout */
|
||||
--shadow-overlay:
|
||||
0 0 0 1px #00000014, 0 1px 1px #00000005, 0 4px 8px -4px #0000000a, 0 16px 24px -8px #0000000f;
|
||||
/* eslint-enable local/no-hardcoded-px */
|
||||
}
|
||||
|
||||
/* ── Register ALL semantic tokens for Tailwind utilities ── */
|
||||
@@ -405,6 +417,7 @@
|
||||
--shadow-scroll-both:
|
||||
inset 0 8px 6px -6px var(--shadow-scroll-c), inset 0 -8px 6px -6px var(--shadow-scroll-c);
|
||||
--shadow-glow: 0 0 4px var(--shadow-glow-c);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
/* Directional / inset roles — not points on the elevation scale, so they
|
||||
cannot be expressed as one. */
|
||||
--shadow-sticky-left-geom: 2px 0 4px -2px;
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} مللي ثانية",
|
||||
"scanSize": "حجم المسح",
|
||||
"deltaScanSize": "حجم دلتا سكان",
|
||||
"scannedSoFar": "تم فحص {size}",
|
||||
"resultsProgressLabel": "تقدم البحث",
|
||||
"histogramTitleError": "خطأ أثناء إنشاء عنوان المدرج التكراري.",
|
||||
"selectLogStream": "اختر تدفق السجلات",
|
||||
"searchInspect": "ابحث عن الفحص",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Scan-Größe",
|
||||
"deltaScanSize": "Delta-Scan-Größe",
|
||||
"scannedSoFar": "Gescannt: {size}",
|
||||
"resultsProgressLabel": "Suchfortschritt",
|
||||
"histogramTitleError": "Fehler beim Generieren des Histogrammtitels.",
|
||||
"selectLogStream": "Logs-Stream auswählen",
|
||||
"searchInspect": "Such-Inspektor",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Scan Size",
|
||||
"deltaScanSize": "Delta Scan Size",
|
||||
"scannedSoFar": "Scanned {size}",
|
||||
"resultsProgressLabel": "Search progress",
|
||||
"histogramTitleError": "Error while generating histogram title.",
|
||||
"selectLogStream": "Select logs stream",
|
||||
"searchInspect": "Search Inspect",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Tamaño de escaneo",
|
||||
"deltaScanSize": "Tamaño de escaneo delta",
|
||||
"scannedSoFar": "Escaneado {size}",
|
||||
"resultsProgressLabel": "Progreso de la búsqueda",
|
||||
"histogramTitleError": "Error al generar el título del histograma.",
|
||||
"selectLogStream": "Seleccione el stream de logs",
|
||||
"searchInspect": "Buscar e inspeccionar",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Taille de scan",
|
||||
"deltaScanSize": "Taille de scan delta",
|
||||
"scannedSoFar": "{size} analysés",
|
||||
"resultsProgressLabel": "Progression de la recherche",
|
||||
"histogramTitleError": "Erreur lors de la génération du titre de l'histogramme.",
|
||||
"selectLogStream": "Sélectionnez le flux de journaux",
|
||||
"searchInspect": "Rechercher Inspect",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Dimensione scansione",
|
||||
"deltaScanSize": "Dimensione scansione delta",
|
||||
"scannedSoFar": "Scansionati {size}",
|
||||
"resultsProgressLabel": "Avanzamento ricerca",
|
||||
"histogramTitleError": "Errore durante la generazione del titolo dell'istogramma.",
|
||||
"selectLogStream": "Seleziona lo stream dei log",
|
||||
"searchInspect": "Cerca Ispeziona",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "スキャンサイズ",
|
||||
"deltaScanSize": "デルタスキャンサイズ",
|
||||
"scannedSoFar": "{size} をスキャン済み",
|
||||
"resultsProgressLabel": "検索の進捗",
|
||||
"histogramTitleError": "ヒストグラムタイトルの生成中にエラーが発生しました。",
|
||||
"selectLogStream": "ログストリームを選択",
|
||||
"searchInspect": "検索を検査",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "스캔 크기",
|
||||
"deltaScanSize": "델타 스캔 크기",
|
||||
"scannedSoFar": "스캔한 크기: {size}",
|
||||
"resultsProgressLabel": "검색 진행률",
|
||||
"histogramTitleError": "히스토그램 제목을 생성하는 중 오류가 발생했습니다.",
|
||||
"selectLogStream": "로그 스트림 선택",
|
||||
"searchInspect": "검색 검사",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Scangrootte",
|
||||
"deltaScanSize": "Delta-scangrootte",
|
||||
"scannedSoFar": "Gescand: {size}",
|
||||
"resultsProgressLabel": "Zoekvoortgang",
|
||||
"histogramTitleError": "Fout bij het genereren van de histogramtitel.",
|
||||
"selectLogStream": "Logstream selecteren",
|
||||
"searchInspect": "Zoek Inspect",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Rozmiar skanowania",
|
||||
"deltaScanSize": "Delta rozmiaru skanowania",
|
||||
"scannedSoFar": "Przeskanowano {size}",
|
||||
"resultsProgressLabel": "Postęp wyszukiwania",
|
||||
"histogramTitleError": "Błąd podczas generowania tytułu histogramu.",
|
||||
"selectLogStream": "Wybierz strumień logów",
|
||||
"searchInspect": "Inspekcja wyszukiwania",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Tamanho da análise",
|
||||
"deltaScanSize": "Tamanho da análise delta",
|
||||
"scannedSoFar": "Analisado {size}",
|
||||
"resultsProgressLabel": "Progresso da pesquisa",
|
||||
"histogramTitleError": "Erro ao gerar o título do histograma.",
|
||||
"selectLogStream": "Selecione o stream de logs",
|
||||
"searchInspect": "Inspecionar pesquisa",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} мс",
|
||||
"scanSize": "Размер сканирования",
|
||||
"deltaScanSize": "Дельта размера сканирования",
|
||||
"scannedSoFar": "Просканировано {size}",
|
||||
"resultsProgressLabel": "Прогресс поиска",
|
||||
"histogramTitleError": "Ошибка при формировании заголовка гистограммы.",
|
||||
"selectLogStream": "Выбрать поток логов",
|
||||
"searchInspect": "Инспекция поиска",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Tarama Boyutu",
|
||||
"deltaScanSize": "Delta Tarama Boyutu",
|
||||
"scannedSoFar": "Taranan: {size}",
|
||||
"resultsProgressLabel": "Arama ilerlemesi",
|
||||
"histogramTitleError": "Histogram başlığı oluşturulurken hata.",
|
||||
"selectLogStream": "Günlükler akışını seçin",
|
||||
"searchInspect": "Aramayı İncele",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} ms",
|
||||
"scanSize": "Kích thước quét",
|
||||
"deltaScanSize": "Kích thước quét Delta",
|
||||
"scannedSoFar": "Đã quét {size}",
|
||||
"resultsProgressLabel": "Tiến độ tìm kiếm",
|
||||
"histogramTitleError": "Lỗi khi tạo tiêu đề biểu đồ histogram.",
|
||||
"selectLogStream": "Chọn stream nhật ký",
|
||||
"searchInspect": "Kiểm tra tìm kiếm",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} 毫秒",
|
||||
"scanSize": "扫描大小",
|
||||
"deltaScanSize": "增量扫描大小",
|
||||
"scannedSoFar": "已扫描 {size}",
|
||||
"resultsProgressLabel": "搜索进度",
|
||||
"histogramTitleError": "生成直方图标题时出错。",
|
||||
"selectLogStream": "选择日志流",
|
||||
"searchInspect": "搜索检查",
|
||||
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"tookChip": "{took} 毫秒",
|
||||
"scanSize": "掃描大小",
|
||||
"deltaScanSize": "掃描大小差異",
|
||||
"scannedSoFar": "已掃描 {size}",
|
||||
"resultsProgressLabel": "搜尋進度",
|
||||
"histogramTitleError": "產生直方圖標題時發生錯誤。",
|
||||
"selectLogStream": "選擇日誌串流",
|
||||
"searchInspect": "搜尋檢查",
|
||||
|
||||
@@ -29,7 +29,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
(not `flex-1`) keeps this sized to its content so the More button
|
||||
sits right after the pinned items instead of being pushed to the
|
||||
far right; it still shrinks + clips when content overflows. -->
|
||||
<div class="flex min-w-0 flex-initial flex-nowrap items-center gap-1 overflow-hidden">
|
||||
<div
|
||||
class="-my-1 flex min-w-0 flex-initial flex-nowrap items-center gap-1 overflow-hidden py-1"
|
||||
>
|
||||
<!-- View Mode: Dropdown when very narrow, Toggle Group otherwise -->
|
||||
<ODropdown v-if="toolbarToggleAsDropdown" side="bottom" align="start">
|
||||
<template #trigger>
|
||||
@@ -1003,7 +1005,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
searchObj.meta.logsVisualizeToggle === 'build'
|
||||
"
|
||||
>
|
||||
<div v-if="config.isEnterprise == 'true'" class="flex items-center">
|
||||
<div
|
||||
v-if="config.isEnterprise == 'true'"
|
||||
class="flex items-center [&_button:focus-visible]:z-1"
|
||||
>
|
||||
<OButton
|
||||
v-if="visualizeSearchRequestTraceIds.length > 0"
|
||||
data-test="logs-search-bar-visualize-cancel-btn"
|
||||
@@ -1059,7 +1064,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</ODropdownItem>
|
||||
</ODropdown>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<div v-else class="flex items-center [&_button:focus-visible]:z-1">
|
||||
<!-- Cancel button when query is running -->
|
||||
<OButton
|
||||
v-if="visualizeSearchRequestTraceIds.length > 0 && config.isEnterprise == 'true'"
|
||||
@@ -1118,7 +1123,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</ODropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<div v-else class="flex items-center [&_button:focus-visible]:z-1">
|
||||
<!-- Lift the focused half so its offset outline paints over the joined twin. -->
|
||||
<!-- Cancel button for patterns tab -->
|
||||
<OButton
|
||||
v-if="searchObj.meta.logsVisualizeToggle === 'patterns' && patternsState.loading"
|
||||
|
||||
@@ -1334,45 +1334,90 @@ describe("SearchResult Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("results progress bar (#14303)", () => {
|
||||
const progressBars = () => wrapper.findAllComponents({ name: "LoadingProgress" });
|
||||
const barsLoading = () => progressBars().filter((bar: any) => bar.props("loading") === true);
|
||||
describe("results progress line (#14303)", () => {
|
||||
const line = () => wrapper.find('[data-test="logs-results-progress"] [role="progressbar"]');
|
||||
|
||||
const setProgress = async (loading: boolean, percentage: number | undefined) => {
|
||||
wrapper.vm.searchObj.loading = loading;
|
||||
wrapper.vm.searchObj.loadingProgressPercentage = percentage;
|
||||
const startQuery = async (percentage: number | undefined) => {
|
||||
wrapper.vm.searchObj.loading = false;
|
||||
await nextTick();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
wrapper.vm.searchObj.loadingHistogram = false;
|
||||
wrapper.vm.searchObj.loadingHistogramProgressPercentage = 0;
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
wrapper.vm.searchObj.loadingProgressPercentage = percentage;
|
||||
wrapper.vm.searchObj.loading = true;
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
it("binds the streaming results percentage to its own progress bar", async () => {
|
||||
await setProgress(true, 42);
|
||||
expect(barsLoading()).toHaveLength(1);
|
||||
expect(barsLoading()[0].props("loadingProgressPercentage")).toBe(42);
|
||||
const passDelay = async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("falls back to 0 when no progress has been reported yet", async () => {
|
||||
await setProgress(true, undefined);
|
||||
expect(barsLoading()).toHaveLength(1);
|
||||
expect(barsLoading()[0].props("loadingProgressPercentage")).toBe(0);
|
||||
it("waits 300ms so a fast query never flashes the line", async () => {
|
||||
await startQuery(42);
|
||||
vi.advanceTimersByTime(299);
|
||||
await nextTick();
|
||||
expect(line().exists()).toBe(false);
|
||||
vi.advanceTimersByTime(1);
|
||||
await nextTick();
|
||||
expect(line().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("stays mounted with loading=false so it can run its own fade-out", async () => {
|
||||
await setProgress(false, 37);
|
||||
const settled = progressBars().filter(
|
||||
(bar: any) => bar.props("loadingProgressPercentage") === 37,
|
||||
it("binds the streaming results percentage to the line", async () => {
|
||||
await startQuery(42);
|
||||
await passDelay();
|
||||
expect(line().attributes("style")).toContain("width: 42%");
|
||||
expect(line().attributes("aria-valuenow")).toBe("42");
|
||||
});
|
||||
|
||||
it("keeps a 5% floor when no progress has been reported yet", async () => {
|
||||
await startQuery(undefined);
|
||||
await passDelay();
|
||||
expect(line().attributes("style")).toContain("width: 5%");
|
||||
});
|
||||
|
||||
it("removes the line at once when the query settles", async () => {
|
||||
await startQuery(42);
|
||||
await passDelay();
|
||||
wrapper.vm.searchObj.loading = false;
|
||||
await nextTick();
|
||||
expect(line().exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="logs-results-progress"]').classes()).toContain("opacity-0");
|
||||
});
|
||||
|
||||
it("does not let the histogram progress drive the results line", async () => {
|
||||
await startQuery(42);
|
||||
wrapper.vm.searchObj.loadingHistogram = true;
|
||||
wrapper.vm.searchObj.loadingHistogramProgressPercentage = 90;
|
||||
await passDelay();
|
||||
expect(line().attributes("style")).toContain("width: 42%");
|
||||
});
|
||||
|
||||
it("shows the size a fresh run has scanned so far, in place of the scan chip", async () => {
|
||||
wrapper.vm.searchObj.meta.logsVisualizeToggle = "logs";
|
||||
wrapper.vm.searchObj.data.histogram.chartParams.titleParts = {
|
||||
start: 1,
|
||||
end: 10,
|
||||
total: "10",
|
||||
took: 5,
|
||||
scanLabel: "Scan Size",
|
||||
scanSize: "300.00 MB",
|
||||
};
|
||||
await startQuery(10);
|
||||
wrapper.vm.searchObj.data.queryResults = { hits: [], scan_size: 1536 };
|
||||
await passDelay();
|
||||
expect(wrapper.find('[data-test="logs-result-scan-progress"]').text()).toBe(
|
||||
"Scanned 1.50 GB",
|
||||
);
|
||||
expect(settled).toHaveLength(1);
|
||||
expect(settled[0].props("loading")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let the histogram bar stand in for the results bar", async () => {
|
||||
await setProgress(true, 42);
|
||||
expect(barsLoading()).toHaveLength(1);
|
||||
expect(barsLoading()[0].props("loadingProgressPercentage")).toBe(42);
|
||||
expect(wrapper.vm.searchObj.loadingHistogram).toBe(false);
|
||||
expect(wrapper.find('[data-test="logs-result-scan-chip"]').exists()).toBe(false);
|
||||
wrapper.vm.searchObj.loading = false;
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-test="logs-result-scan-progress"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="logs-result-scan-chip"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
>{{ recordsChips.time }}</OTag
|
||||
>
|
||||
<OTag
|
||||
v-if="recordsChips.scan"
|
||||
v-if="recordsChips.scan && !scannedSize"
|
||||
type="logsResultChip"
|
||||
value="warn"
|
||||
class="max-md:hidden"
|
||||
@@ -94,6 +94,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
>
|
||||
</template>
|
||||
<span v-else class="min-w-0 truncate">{{ noOfRecordsTitle }}</span>
|
||||
<span
|
||||
v-if="scannedSize"
|
||||
class="text-text-secondary shrink-0 text-xs"
|
||||
data-test="logs-result-scan-progress"
|
||||
>{{ t("search.scannedSoFar", { size: scannedSize }) }}</span
|
||||
>
|
||||
</template>
|
||||
<!-- Patterns mode: structured chips -->
|
||||
<template v-else>
|
||||
@@ -172,9 +178,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template v-else>
|
||||
<!-- Refresh in bordered wrapper -->
|
||||
<div
|
||||
class="border-card-glass-border rounded-default inline-flex h-6 items-center overflow-hidden border px-1"
|
||||
class="border-card-glass-border rounded-default inline-flex h-6 items-center border px-1"
|
||||
>
|
||||
<ORefreshButton
|
||||
size="inset"
|
||||
:last-run-at="searchObj.meta.lastRunAt"
|
||||
:loading="searchObj.loading || searchObj.loadingHistogram"
|
||||
:disabled="searchObj.loading || searchObj.loadingHistogram"
|
||||
@@ -281,11 +288,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Outside scrollContainerRef so the results progress bar can't scroll away. -->
|
||||
<div class="relative">
|
||||
<LoadingProgress
|
||||
<div
|
||||
data-test="logs-results-progress"
|
||||
:loading="searchObj.loading"
|
||||
:loadingProgressPercentage="searchObj.loadingProgressPercentage || 0"
|
||||
/>
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-999 h-0.5"
|
||||
:class="showResultsLoader ? 'opacity-100' : 'opacity-0'"
|
||||
>
|
||||
<div
|
||||
v-if="showResultsLoader"
|
||||
role="progressbar"
|
||||
:aria-label="t('search.resultsProgressLabel')"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="resultsProgressWidth"
|
||||
class="bg-accent h-full transition-[width] duration-300 ease-out"
|
||||
:style="{ width: `${resultsProgressWidth}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Combined scroll: histogram + logs/patterns scroll together vertically.
|
||||
@@ -565,6 +583,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<span
|
||||
v-if="logsCellHtml(col.id, row)"
|
||||
class="log-cell-html"
|
||||
:class="severityFieldTextClass(col.id, value)"
|
||||
:data-test="
|
||||
col.id === logsTimestampCol ? 'log-row-timestamp-value' : undefined
|
||||
"
|
||||
@@ -572,6 +591,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
:class="severityFieldTextClass(col.id, value)"
|
||||
:data-test="
|
||||
col.id === logsTimestampCol ? 'log-row-timestamp-value' : undefined
|
||||
"
|
||||
@@ -960,8 +980,13 @@ import {
|
||||
import LogsMissingStreamBanner from "@/plugins/logs/LogsMissingStreamBanner.vue";
|
||||
import O2AIContextAddBtn from "@/components/common/O2AIContextAddBtn.vue";
|
||||
import { useLogsHighlighter } from "@/composables/useLogsHighlighter";
|
||||
import { severityIndicatorColor, severityRowClass } from "@/utils/logs/statusParser";
|
||||
import {
|
||||
severityFieldTextClass,
|
||||
severityIndicatorColor,
|
||||
severityRowClass,
|
||||
} from "@/utils/logs/statusParser";
|
||||
import useLogSeverity from "@/composables/useLogs/useLogSeverity";
|
||||
import { useResultsLoadingIndicator } from "@/composables/useLogs/useResultsLoadingIndicator";
|
||||
import { isFilterableLogField } from "@/utils/logs/streamNameColumn";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import {
|
||||
@@ -2463,6 +2488,11 @@ export default defineComponent({
|
||||
// First paint has nothing to show, so the skeleton is right; once rows exist we switch to `streaming` so partial results stay on screen.
|
||||
const isResultsSkeleton = computed(() => searchObj.loading && !hasResultRows.value);
|
||||
const isResultsStreaming = computed(() => searchObj.loading && hasResultRows.value);
|
||||
const { showLoader: showResultsLoader, scannedSize } = useResultsLoadingIndicator(searchObj);
|
||||
// A floor keeps the line visible before the first progress event arrives.
|
||||
const resultsProgressWidth = computed(() =>
|
||||
Math.round(Math.min(100, Math.max(5, searchObj.loadingProgressPercentage || 0))),
|
||||
);
|
||||
|
||||
const getPartitionPaginations = computed(() => {
|
||||
return searchObj.data.queryResults?.partitionDetail?.paginations || [];
|
||||
@@ -2939,6 +2969,7 @@ export default defineComponent({
|
||||
getLogRowStatusColor,
|
||||
getLogRowClass,
|
||||
logRowSeveritySrText,
|
||||
severityFieldTextClass,
|
||||
expandedLogIds,
|
||||
onExpandedLogIdsChange,
|
||||
openLogDetailsByRow,
|
||||
@@ -2987,6 +3018,9 @@ export default defineComponent({
|
||||
hasResultRows,
|
||||
isResultsSkeleton,
|
||||
isResultsStreaming,
|
||||
showResultsLoader,
|
||||
scannedSize,
|
||||
resultsProgressWidth,
|
||||
reorderSelectedFields,
|
||||
getPaginations,
|
||||
refreshPagination,
|
||||
|
||||
@@ -28,7 +28,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-bind="item.filter ? { radius: 'sm', divided: true } : {}"
|
||||
:class="
|
||||
item.filter
|
||||
? 'border-border-default bg-surface-panel overflow-hidden border [&>*:not(:first-child)]:!border-s'
|
||||
? 'border-border-default bg-surface-panel overflow-hidden border [&_button:focus-visible]:-outline-offset-2! [&>*:not(:first-child)]:!border-s'
|
||||
: 'flex'
|
||||
"
|
||||
:data-test="item.filter ? `profiles-filter-chip-${item.filter.key}` : undefined"
|
||||
|
||||
@@ -123,6 +123,8 @@ body {
|
||||
font-size: var(--text-compact);
|
||||
font-weight: var(--font-normal);
|
||||
line-height: var(--leading-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -147,35 +149,35 @@ body {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-2xl);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
letter-spacing: var(--tracking-heading-display);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: var(--leading-xl);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: var(--leading-lg);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: var(--leading-md);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: var(--leading-base);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
}
|
||||
|
||||
h6 {
|
||||
|
||||
@@ -152,6 +152,14 @@ html.theme-switching *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-timing-function: linear !important;
|
||||
}
|
||||
|
||||
/* 0.01ms, not none: transitionend still fires for code that waits on it. */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -100,7 +100,7 @@ const FALLBACKS: Record<string, string> = {
|
||||
"--color-chart-table-22": "#FFF2CC",
|
||||
"--color-chart-table-23": "#FFE6E6",
|
||||
"--color-chart-table-24": "#E8F4FD",
|
||||
"--color-chart-gridline": "rgba(0, 0, 0, 0.2)",
|
||||
"--color-chart-gridline": "rgba(0, 10, 23, 0.09)",
|
||||
"--color-chart-band": "rgba(115, 115, 115, 0.22)",
|
||||
// --color-chart-crosshair-bg is deliberately absent: it is dark-only, and the empty
|
||||
// string light returns is what makes ECharts use its own auto default (base.css:262).
|
||||
|
||||
@@ -18,9 +18,12 @@ import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
colorToRgba,
|
||||
createDistinctSeriesColor,
|
||||
getAreaFillOpacity,
|
||||
getAreaGradientColor,
|
||||
getAreaStyleOverride,
|
||||
getDistinctSeriesColors,
|
||||
getSeriesHash,
|
||||
} from "./colorPalette";
|
||||
|
||||
const COLOR = "#5b8ef0";
|
||||
@@ -162,3 +165,121 @@ describe("area fill tokens", () => {
|
||||
expect(value(darkCss, "--chart-area-stacked-fill-bottom-opacity")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDistinctSeriesColors", () => {
|
||||
// Vitest stubs CSS imports, so the shipped light/dark series palettes are read from disk.
|
||||
const palette = (file: string) => {
|
||||
const css = readFileSync(resolve(process.cwd(), "src/lib/styles/tokens", file), "utf8");
|
||||
return Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => css.match(new RegExp(`--color-chart-series-${i + 1}:\\s*(#[0-9a-f]{6})`, "i"))![1],
|
||||
);
|
||||
};
|
||||
const phases = ["Failed", "Pending", "Running", "Succeeded", "Unknown"];
|
||||
|
||||
it.each(["base.css", "dark.css"])("gives pod phases five different colours in %s", (file) => {
|
||||
const colors = palette(file);
|
||||
// The bare name hash sends Pending and Succeeded to one slot and Failed to its near-twin amber.
|
||||
expect(getSeriesHash("Pending", colors)).toBe(getSeriesHash("Succeeded", colors));
|
||||
const assigned = getDistinctSeriesColors(phases, colors);
|
||||
expect(new Set(assigned.values()).size).toBe(5);
|
||||
const amberTwins = [colors[5], colors[11]];
|
||||
expect(phases.filter((p) => amberTwins.includes(assigned.get(p)!))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a name that does not collide on its hash colour", () => {
|
||||
const colors = palette("base.css");
|
||||
expect(getDistinctSeriesColors(phases, colors).get("Running")).toBe(
|
||||
colors[getSeriesHash("Running", colors)],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not depend on the order the names arrive in", () => {
|
||||
const colors = palette("base.css");
|
||||
const forward = getDistinctSeriesColors(phases, colors);
|
||||
const backward = getDistinctSeriesColors([...phases].reverse(), colors);
|
||||
phases.forEach((p) => expect(backward.get(p)).toBe(forward.get(p)));
|
||||
});
|
||||
|
||||
it("keeps every uncontested name on its colour in any subset of the names", () => {
|
||||
const colors = palette("base.css");
|
||||
const full = getDistinctSeriesColors(phases, colors);
|
||||
const uncontested = phases.filter((p) => full.get(p) === colors[getSeriesHash(p, colors)]);
|
||||
expect(uncontested.length).toBeGreaterThanOrEqual(3);
|
||||
for (let mask = 1; mask < 1 << phases.length; mask++) {
|
||||
const subset = phases.filter((_, i) => mask & (1 << i));
|
||||
const assigned = getDistinctSeriesColors(subset, colors);
|
||||
subset
|
||||
.filter((p) => uncontested.includes(p))
|
||||
.forEach((p) => expect(assigned.get(p)).toBe(full.get(p)));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a name's colour when other names disappear, unless a sorted-earlier name shares its hash slot", () => {
|
||||
const colors = palette("base.css");
|
||||
const alone = getDistinctSeriesColors(["Succeeded"], colors).get("Succeeded");
|
||||
expect(alone).toBe(colors[getSeriesHash("Succeeded", colors)]);
|
||||
expect(getDistinctSeriesColors(["Running", "Succeeded"], colors).get("Succeeded")).toBe(alone);
|
||||
expect(getDistinctSeriesColors(["Succeeded", "Unknown"], colors).get("Succeeded")).toBe(alone);
|
||||
// Pending hashes to the same slot and sorts first, so it holds that colour whenever present.
|
||||
expect(getDistinctSeriesColors(["Pending", "Succeeded"], colors).get("Succeeded")).not.toBe(
|
||||
alone,
|
||||
);
|
||||
});
|
||||
|
||||
it("never auto-assigns a reserved colour or a near shade of it", () => {
|
||||
const colors = palette("base.css");
|
||||
const home = colors[getSeriesHash("Running", colors)];
|
||||
const nearShade = `#${((parseInt(home.slice(1), 16) + 0x020202) & 0xffffff).toString(16).padStart(6, "0")}`;
|
||||
[home, nearShade].forEach((reserved) => {
|
||||
const assigned = getDistinctSeriesColors(phases, colors, [reserved]);
|
||||
expect([...assigned.values()]).not.toContain(home);
|
||||
expect(new Set(assigned.values()).size).toBe(phases.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses colours only once every slot is taken", () => {
|
||||
const names = Array.from({ length: 14 }, (_, i) => `series-${i}`);
|
||||
const assigned = getDistinctSeriesColors(names, palette("base.css"));
|
||||
expect(new Set(assigned.values()).size).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDistinctSeriesColor", () => {
|
||||
const root = document.documentElement.style;
|
||||
const hexes = ["#5b8ef0", "#34d399", "#fb923c", "#f472b6"];
|
||||
afterEach(() => hexes.forEach((_, i) => root.removeProperty(`--color-chart-series-${i + 1}`)));
|
||||
|
||||
it("leaves non-hash colour modes to getSeriesColor", () => {
|
||||
const color = createDistinctSeriesColor(["a", "b"], { mode: "fixed" }, "light");
|
||||
expect(color("a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips names that have their own colorBySeries mapping", () => {
|
||||
hexes.forEach((hex, i) => root.setProperty(`--color-chart-series-${i + 1}`, hex));
|
||||
const color = createDistinctSeriesColor(
|
||||
["a", "b"],
|
||||
{ mode: "palette-classic-by-series", colorBySeries: [{ value: "a", color: "#000000" }] },
|
||||
"light",
|
||||
);
|
||||
expect(color("a")).toBeUndefined();
|
||||
expect(color("b")).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
|
||||
it("keeps a colorBySeries colour away from the automatic slices", () => {
|
||||
hexes.forEach((hex, i) => root.setProperty(`--color-chart-series-${i + 1}`, hex));
|
||||
const auto = createDistinctSeriesColor(
|
||||
["a", "b"],
|
||||
{ mode: "palette-classic-by-series" },
|
||||
"light",
|
||||
);
|
||||
const bColor = auto("b")!;
|
||||
const color = createDistinctSeriesColor(
|
||||
["a", "b"],
|
||||
{ mode: "palette-classic-by-series", colorBySeries: [{ value: "a", color: bColor }] },
|
||||
"light",
|
||||
);
|
||||
expect(color("b")).toBeDefined();
|
||||
expect(color("b")).not.toBe(bColor);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,6 +266,7 @@ interface ColorConfig {
|
||||
| "continuous-light-to-dark-blue";
|
||||
fixedColor?: string[];
|
||||
seriesBy?: SeriesBy;
|
||||
colorBySeries?: Array<{ value: string; color?: string }>;
|
||||
}
|
||||
|
||||
function getDomainPartitions(chartMin: number, chartMax: number, numPartitions: number) {
|
||||
@@ -332,6 +333,83 @@ export const getSeriesColor = (
|
||||
}
|
||||
};
|
||||
|
||||
// Below this redmean distance two palette colours read as one hue: the amber/yellow pair sits at 20-34.
|
||||
const NEAR_COLOR_DISTANCE = 60;
|
||||
|
||||
const toRgb = (value: string): [number, number, number] | null => {
|
||||
const hex = /^#([0-9a-f]{6})$/i.exec(value.trim());
|
||||
if (hex) {
|
||||
const n = parseInt(hex[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(value.trim());
|
||||
return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null;
|
||||
};
|
||||
|
||||
const isNearColor = (a: string, b: string): boolean => {
|
||||
const x = toRgb(a);
|
||||
const y = toRgb(b);
|
||||
if (!x || !y) return a === b;
|
||||
const rMean = (x[0] + y[0]) / 2;
|
||||
const [dr, dg, db] = [x[0] - y[0], x[1] - y[1], x[2] - y[2]];
|
||||
const distance = Math.sqrt(
|
||||
(2 + rMean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rMean) / 256) * db * db,
|
||||
);
|
||||
return distance < NEAR_COLOR_DISTANCE;
|
||||
};
|
||||
|
||||
/** Name-hashed palette colours; a name leaves its hash slot only when an earlier name or a reserved colour already holds that colour. */
|
||||
export const getDistinctSeriesColors = (
|
||||
names: string[],
|
||||
palette: string[],
|
||||
reserved: string[] = [],
|
||||
): Map<string, string> => {
|
||||
const colors = new Map<string, string>();
|
||||
const takenColors = [...reserved];
|
||||
const takenSlots = new Set(
|
||||
palette.flatMap((color, i) => (reserved.some((r) => isNearColor(color, r)) ? [i] : [])),
|
||||
);
|
||||
const isApart = (slot: number) => takenColors.every((c) => !isNearColor(palette[slot], c));
|
||||
const take = (name: string, slot: number) => {
|
||||
takenSlots.add(slot);
|
||||
takenColors.push(palette[slot]);
|
||||
colors.set(name, palette[slot]);
|
||||
};
|
||||
// Sorted, so the same set of names always gets the same colours.
|
||||
const unique = [...new Set(names)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
// Uncontested hash slots are claimed before any probing, so a probe never takes another name's own colour.
|
||||
const displaced = unique.filter((name) => {
|
||||
const home = getSeriesHash(name, palette);
|
||||
if (takenSlots.has(home) || !isApart(home)) return true;
|
||||
take(name, home);
|
||||
return false;
|
||||
});
|
||||
displaced.forEach((name) => {
|
||||
const home = getSeriesHash(name, palette);
|
||||
const slots = palette.map((_, i) => (home + i) % palette.length);
|
||||
const isFree = (slot: number) => !takenSlots.has(slot);
|
||||
take(name, slots.find((i) => isFree(i) && isApart(i)) ?? slots.find(isFree) ?? home);
|
||||
});
|
||||
return colors;
|
||||
};
|
||||
|
||||
/** Per-chart override of getSeriesColor's name hash: returns the distinct colour, or undefined where getSeriesColor decides. */
|
||||
export const createDistinctSeriesColor = (
|
||||
names: string[],
|
||||
colorCfg: ColorConfig | null | undefined,
|
||||
theme: string,
|
||||
): ((name: string) => string | undefined) => {
|
||||
if (colorCfg && colorCfg.mode !== "palette-classic-by-series") return () => undefined;
|
||||
const mappings = (colorCfg?.colorBySeries ?? []).filter((m) => m?.color);
|
||||
const mapped = new Set(mappings.map((m) => m.value));
|
||||
const colors = getDistinctSeriesColors(
|
||||
names.filter((name) => !mapped.has(name)),
|
||||
getColorPalette(theme),
|
||||
mappings.map((m) => String(m.color)),
|
||||
);
|
||||
return (name) => colors.get(name);
|
||||
};
|
||||
|
||||
// Dashboard table row-highlight palette. The light/dark swap lives in the
|
||||
// --color-chart-table-* tokens (base.css / dark.css); this resolves them via
|
||||
// chartColor(). `_theme` kept for call-site compatibility, ignored.
|
||||
@@ -375,12 +453,12 @@ export const colorToRgba = (color: string, alpha: number): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Dashed split-line style for chart grids — subtle so the lines recede behind
|
||||
* Solid split-line style for chart grids — subtle so the lines recede behind
|
||||
* the data. Theme-aware: faint white on dark, faint black on light. Shared by
|
||||
* the PromQL and SQL chart builders so the grid look stays consistent.
|
||||
*/
|
||||
export const getGridLineStyle = (_theme?: string) => ({
|
||||
type: "dashed",
|
||||
type: "solid",
|
||||
width: 1,
|
||||
// light/dark swap lives in the --color-chart-gridline token (base/dark css).
|
||||
color: chartColor("--color-chart-gridline"),
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock("./calculateGridForSubPlot", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./colorPalette", () => ({
|
||||
createDistinctSeriesColor: vi.fn(() => () => undefined),
|
||||
ColorModeWithoutMinMax: { palette: "palette" },
|
||||
getMetricMinMaxValue: vi.fn(() => [0, 100]),
|
||||
getSeriesColor: vi.fn(() => "#FF0000"),
|
||||
@@ -2710,7 +2711,7 @@ describe("Convert PromQL Data Utils", () => {
|
||||
mockAnnotations,
|
||||
);
|
||||
|
||||
expect(result.options.xAxis.splitLine.show).toBe(true);
|
||||
expect(result.options.xAxis.splitLine.show).toBe(false);
|
||||
expect(result.options.yAxis.splitLine.show).toBe(true);
|
||||
});
|
||||
|
||||
@@ -2744,7 +2745,7 @@ describe("Convert PromQL Data Utils", () => {
|
||||
mockAnnotations,
|
||||
);
|
||||
|
||||
expect(result.options.xAxis.splitLine.show).toBe(true);
|
||||
expect(result.options.xAxis.splitLine.show).toBe(false);
|
||||
expect(result.options.yAxis.splitLine.show).toBe(true);
|
||||
});
|
||||
|
||||
@@ -2884,8 +2885,8 @@ describe("Convert PromQL Data Utils", () => {
|
||||
|
||||
// Based on the ternary logic: panelSchema?.config?.show_gridlines !== undefined ? panelSchema.config.show_gridlines : true
|
||||
// When show_gridlines is null, it's not undefined, so it uses the null value
|
||||
// The splitLine.show gets set to null, not the default true
|
||||
expect(result.options.xAxis.splitLine.show).toBe(null);
|
||||
// The y splitLine.show gets set to null, not the default true; the x axis never draws gridlines
|
||||
expect(result.options.xAxis.splitLine.show).toBe(false);
|
||||
expect(result.options.yAxis.splitLine.show).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -3557,7 +3558,7 @@ describe("Convert PromQL Data Utils", () => {
|
||||
mockAnnotations,
|
||||
);
|
||||
|
||||
expect(result.options.xAxis.splitLine.show).toBe(true);
|
||||
expect(result.options.xAxis.splitLine.show).toBe(false);
|
||||
expect(result.options.legend.show).toBe(false);
|
||||
expect(result.extras.isTimeSeries).toBe(true);
|
||||
});
|
||||
@@ -3846,10 +3847,42 @@ describe("Convert PromQL Data Utils", () => {
|
||||
// Should show both axis borders and gridlines
|
||||
expect(result.options.xAxis.axisLine.show).toBe(true);
|
||||
expect(result.options.yAxis.axisLine.show).toBe(true);
|
||||
expect(result.options.xAxis.splitLine.show).toBe(true);
|
||||
expect(result.options.xAxis.splitLine.show).toBe(false);
|
||||
expect(result.options.yAxis.splitLine.show).toBe(true);
|
||||
});
|
||||
|
||||
it("draws the x baseline by default and hides it when axis_border_show is false", async () => {
|
||||
const searchQueryData = [
|
||||
{
|
||||
resultType: "matrix",
|
||||
result: [
|
||||
{
|
||||
metric: { __name__: "baseline_metric" },
|
||||
values: [[1640435200, "10"]],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const build = (config: Record<string, unknown>) =>
|
||||
convertPromQLData(
|
||||
{ id: "panel1", type: "line", config, queries: [{ config: { promql_legend: "" } }] },
|
||||
searchQueryData,
|
||||
mockStore,
|
||||
mockChartPanelRef,
|
||||
mockHoveredSeriesState,
|
||||
mockAnnotations,
|
||||
);
|
||||
|
||||
const byDefault = await build({});
|
||||
expect(byDefault.options.xAxis.axisLine.show).toBe(true);
|
||||
expect(byDefault.options.yAxis.axisLine.show).toBe(false);
|
||||
expect(byDefault.options.xAxis.axisLabel.fontSize).toBe(12);
|
||||
expect(byDefault.options.xAxis.axisLabel.color).toBe(byDefault.options.yAxis.axisLabel.color);
|
||||
|
||||
const hidden = await build({ axis_border_show: false });
|
||||
expect(hidden.options.xAxis.axisLine.show).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty result but valid structure", async () => {
|
||||
const panelSchema = {
|
||||
id: "panel1",
|
||||
|
||||
@@ -33,8 +33,10 @@ import { ANNOTATION_MARK_AREA_Z } from "@/utils/dashboard/constants";
|
||||
import { escapeHtml } from "@/utils/html";
|
||||
import { addCommasToNumber } from "@/utils/formatters";
|
||||
import {
|
||||
chartAxisLine,
|
||||
chartColor,
|
||||
chartNumber,
|
||||
chartTextColor,
|
||||
dataZoomBrushStyle,
|
||||
CHART_SELECTION_FILL,
|
||||
} from "@/utils/chartTheme";
|
||||
@@ -388,7 +390,6 @@ export const convertPromQLData = async (
|
||||
// Set gridlines visibility based on config.show_gridlines (default: true)
|
||||
const showGridlines =
|
||||
panelSchema?.config?.show_gridlines !== undefined ? panelSchema.config.show_gridlines : true;
|
||||
// Subtle dashed grid lines so they recede behind the data
|
||||
const gridLineStyle = getGridLineStyle(store.state.theme);
|
||||
|
||||
await importMoment();
|
||||
@@ -852,13 +853,16 @@ export const convertPromQLData = async (
|
||||
axisLine: {
|
||||
show: searchQueryData?.every((it: any) => it && it.result && it.result.length == 0)
|
||||
? true
|
||||
: (panelSchema.config?.axis_border_show ?? false),
|
||||
: (panelSchema.config?.axis_border_show ?? true),
|
||||
lineStyle: { color: chartAxisLine() },
|
||||
},
|
||||
splitLine: {
|
||||
show: showGridlines,
|
||||
show: false,
|
||||
lineStyle: gridLineStyle,
|
||||
},
|
||||
axisLabel: {
|
||||
color: chartTextColor(),
|
||||
fontSize: 12,
|
||||
// hide axis label if overlaps
|
||||
hideOverlap: true,
|
||||
// For time-based x-axis (type: "time"), rotation and truncation are not applicable
|
||||
@@ -873,6 +877,8 @@ export const convertPromQLData = async (
|
||||
min: getFinalAxisValue(panelSchema.config.y_axis_min, min, true),
|
||||
max: getFinalAxisValue(panelSchema.config.y_axis_max, max, false),
|
||||
axisLabel: {
|
||||
color: chartTextColor(),
|
||||
fontSize: 12,
|
||||
// hide colliding tick labels instead of drawing them on top of each
|
||||
// other (short panels fit fewer labels than ECharts offers)
|
||||
hideOverlap: true,
|
||||
@@ -962,8 +968,8 @@ export const convertPromQLData = async (
|
||||
if (options.xAxis) {
|
||||
(Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]).forEach(
|
||||
(axis: { splitLine: { show: boolean; lineStyle: unknown } }) => {
|
||||
// if (!axis.splitLine) axis.splitLine = {};
|
||||
axis.splitLine.show = showGridlines;
|
||||
// Gridlines are horizontal only: the time axis draws none.
|
||||
axis.splitLine.show = false;
|
||||
axis.splitLine.lineStyle = gridLineStyle;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ vi.mock("../query/sqlUtils", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./colorPalette", () => ({
|
||||
createDistinctSeriesColor: vi.fn(() => () => undefined),
|
||||
ColorModeWithoutMinMax: vi.fn(),
|
||||
getSeriesColor: vi.fn(() => "#ff0000"),
|
||||
getSQLMinMaxValue: vi.fn(() => [0, 100]),
|
||||
@@ -9648,6 +9649,51 @@ describe("convertSQLData", () => {
|
||||
});
|
||||
|
||||
describe("Show Gridlines Configuration", () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
[undefined, true],
|
||||
])(
|
||||
"h-bar with show_gridlines=%s draws value-axis gridlines=%s and none on the category axis",
|
||||
async (showGridlines, expected) => {
|
||||
const schema = {
|
||||
...mockPanelSchema,
|
||||
type: "h-bar",
|
||||
config: { ...mockPanelSchema.config, show_gridlines: showGridlines },
|
||||
queries: [
|
||||
{
|
||||
fields: { x: [{ alias: "category" }], y: [{ alias: "value" }], z: [], breakdown: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const searchData = [
|
||||
[
|
||||
{ category: "A", value: 100 },
|
||||
{ category: "B", value: 200 },
|
||||
],
|
||||
];
|
||||
|
||||
const result = await convertSQLData(
|
||||
schema,
|
||||
searchData,
|
||||
mockStore,
|
||||
mockChartPanelRef,
|
||||
mockHoveredSeriesState,
|
||||
mockResultMetaData,
|
||||
mockMetadata,
|
||||
mockChartPanelStyle,
|
||||
mockAnnotations,
|
||||
);
|
||||
|
||||
const valueAxes = [result.options.xAxis].flat();
|
||||
const categoryAxes = [result.options.yAxis].flat();
|
||||
expect(valueAxes[0].type).toBe("value");
|
||||
expect(categoryAxes[0].type).toBe("category");
|
||||
valueAxes.forEach((axis: any) => expect(axis.splitLine.show).toBe(expected));
|
||||
categoryAxes.forEach((axis: any) => expect(axis.splitLine.show).toBe(false));
|
||||
},
|
||||
);
|
||||
|
||||
it("should use default gridlines (true) when config is undefined", async () => {
|
||||
const schema = {
|
||||
...mockPanelSchema,
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("./shared/gridBuilder", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../colorPalette", () => ({
|
||||
createDistinctSeriesColor: vi.fn(() => () => undefined),
|
||||
getSeriesColor: vi.fn((colorConfig, name) => {
|
||||
// Simple mock that returns colors based on name
|
||||
const colors: Record<string, string> = {
|
||||
@@ -1004,6 +1005,33 @@ describe("PieConverter", () => {
|
||||
expect(result.series[0].data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("colours each slice from the chart-wide distinct map before the name hash", async () => {
|
||||
const { createDistinctSeriesColor } = await import("../colorPalette");
|
||||
vi.mocked(createDistinctSeriesColor).mockImplementationOnce((names, colorCfg, theme) => {
|
||||
expect(names).toEqual(["Pending", "Succeeded"]);
|
||||
expect(colorCfg).toBeUndefined();
|
||||
expect(theme).toBe("light");
|
||||
return (name: string) => (name === "Succeeded" ? "#5b8ef0" : undefined);
|
||||
});
|
||||
const processedData: ProcessedPromQLData[] = [
|
||||
{
|
||||
series: [
|
||||
{ name: "Pending", values: [[1, "3"]] },
|
||||
{ name: "Succeeded", values: [[1, "3"]] },
|
||||
],
|
||||
timestamps: [1],
|
||||
queryIndex: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = converter.convert(processedData, { type: "pie" }, mockStore, mockExtras);
|
||||
|
||||
const colorOf = (name: string) =>
|
||||
result.series[0].data.find((d: any) => d.name === name).itemStyle?.color;
|
||||
expect(colorOf("Succeeded")).toBe("#5b8ef0");
|
||||
expect(colorOf("Pending")).toBe("#CCCCCC");
|
||||
});
|
||||
|
||||
it("should handle when getSeriesColor returns null", async () => {
|
||||
const { getSeriesColor } = await import("../colorPalette");
|
||||
vi.mocked(getSeriesColor).mockReturnValueOnce(null);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PromQLChartConverter, ProcessedPromQLData } from "./shared/types";
|
||||
import { applyAggregation } from "./shared/dataProcessor";
|
||||
import { buildTooltip } from "./shared/axisBuilder";
|
||||
import { buildPieChartConfig } from "./shared/gridBuilder";
|
||||
import { getSeriesColor } from "../colorPalette";
|
||||
import { createDistinctSeriesColor, getSeriesColor } from "../colorPalette";
|
||||
import { getUnitValue, formatUnitValue } from "../convertDataIntoUnitValue";
|
||||
import { createSeriesOverrideLookup } from "../seriesOverrides";
|
||||
import { MAX_SEATS_PRECISION, pieLegendPercentDecimals } from "../legendConfiguration";
|
||||
@@ -78,20 +78,28 @@ export class PieConverter implements PromQLChartConverter {
|
||||
});
|
||||
});
|
||||
|
||||
const distinctColor = createDistinctSeriesColor(
|
||||
seriesDataCollection.map((s) => s.name),
|
||||
config.color,
|
||||
store.state.theme,
|
||||
);
|
||||
|
||||
// Now apply colors and build data with unit formatting
|
||||
seriesDataCollection.forEach((seriesData) => {
|
||||
// Extract numeric values for color calculation
|
||||
const numericValues = seriesData.rawValues.map(([, val]) => parseFloat(val));
|
||||
|
||||
const color = getSeriesColor(
|
||||
config.color || null,
|
||||
seriesData.name,
|
||||
numericValues,
|
||||
chartMin,
|
||||
chartMax,
|
||||
store.state.theme,
|
||||
config.color?.colorBySeries,
|
||||
);
|
||||
const color =
|
||||
distinctColor(seriesData.name) ??
|
||||
getSeriesColor(
|
||||
config.color || null,
|
||||
seriesData.name,
|
||||
numericValues,
|
||||
chartMin,
|
||||
chartMax,
|
||||
store.state.theme,
|
||||
config.color?.colorBySeries,
|
||||
);
|
||||
|
||||
data.push({
|
||||
name: seriesData.name,
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("@/utils/dashboard/chartColorUtils", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/dashboard/colorPalette", () => ({
|
||||
createDistinctSeriesColor: vi.fn(() => () => undefined),
|
||||
getSeriesColor: vi.fn(() => "#FF5733"),
|
||||
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/dashboard/colorPalette", () => ({
|
||||
createDistinctSeriesColor: vi.fn(() => () => undefined),
|
||||
getSeriesColor: vi.fn(() => "#FF5733"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { getSeriesColor } from "../../colorPalette";
|
||||
import { createDistinctSeriesColor, getSeriesColor } from "../../colorPalette";
|
||||
import { createSeriesOverrideLookup } from "../../seriesOverrides";
|
||||
import { chartColor } from "@/utils/chartTheme";
|
||||
import {
|
||||
@@ -47,6 +47,11 @@ export function applyPieDonutChart(ctx: SQLContext): void {
|
||||
getPieChartRadius,
|
||||
} = ctx;
|
||||
const seriesOverrides = createSeriesOverrideLookup(panelSchema?.config);
|
||||
const distinctColor = createDistinctSeriesColor(
|
||||
(options?.xAxis?.[0]?.data ?? []).map(String),
|
||||
panelSchema?.config?.color,
|
||||
store.state.theme,
|
||||
);
|
||||
|
||||
if (panelSchema.type === "pie") {
|
||||
// Add more padding for pie chart
|
||||
@@ -93,6 +98,7 @@ export function applyPieDonutChart(ctx: SQLContext): void {
|
||||
? null
|
||||
: (params: any) => {
|
||||
return (
|
||||
distinctColor(String(params.name)) ??
|
||||
getSeriesColor(
|
||||
panelSchema?.config?.color,
|
||||
params.name,
|
||||
@@ -101,7 +107,8 @@ export function applyPieDonutChart(ctx: SQLContext): void {
|
||||
chartMax,
|
||||
store.state.theme,
|
||||
panelSchema?.config?.color?.colorBySeries,
|
||||
) ?? null
|
||||
) ??
|
||||
null
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -233,6 +240,7 @@ export function applyPieDonutChart(ctx: SQLContext): void {
|
||||
? null
|
||||
: (params: any) => {
|
||||
return (
|
||||
distinctColor(String(params.name)) ??
|
||||
getSeriesColor(
|
||||
panelSchema?.config?.color,
|
||||
params.name,
|
||||
@@ -241,7 +249,8 @@ export function applyPieDonutChart(ctx: SQLContext): void {
|
||||
chartMax,
|
||||
store.state.theme,
|
||||
panelSchema?.config?.color?.colorBySeries,
|
||||
) ?? null
|
||||
) ??
|
||||
null
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
calculateChartDimensions,
|
||||
calculatePieChartRadius,
|
||||
} from "../../legendConfiguration";
|
||||
import { chartColor, dataZoomBrushStyle } from "@/utils/chartTheme";
|
||||
import { chartAxisLine, chartColor, chartTextColor, dataZoomBrushStyle } from "@/utils/chartTheme";
|
||||
import { getPropsByChartTypeForSeries } from "../../sqlChartSeriesProps";
|
||||
import { processData } from "../../sqlProcessData";
|
||||
import { fillMissingValues } from "../../sqlMissingValueFiller";
|
||||
@@ -74,7 +74,6 @@ export function buildSQLContext(
|
||||
// Set gridlines visibility based on config.show_gridlines (default: true)
|
||||
const showGridlines =
|
||||
panelSchema?.config?.show_gridlines !== undefined ? panelSchema.config.show_gridlines : true;
|
||||
// Subtle dashed grid lines so they recede behind the data
|
||||
const gridLineStyle = getGridLineStyle(store.state.theme);
|
||||
const extras: any = {};
|
||||
|
||||
@@ -663,6 +662,8 @@ export function buildSQLContext(
|
||||
fontSize: 14,
|
||||
},
|
||||
axisLabel: {
|
||||
color: chartTextColor(),
|
||||
fontSize: 12,
|
||||
interval:
|
||||
panelSchema.type == "h-stacked"
|
||||
? "auto"
|
||||
@@ -679,13 +680,14 @@ export function buildSQLContext(
|
||||
rotate: labelRotation,
|
||||
},
|
||||
splitLine: {
|
||||
show: showGridlines,
|
||||
show: false,
|
||||
lineStyle: gridLineStyle,
|
||||
},
|
||||
axisLine: {
|
||||
show: searchQueryData?.every((it: any) => it.length == 0)
|
||||
? true
|
||||
: (panelSchema.config?.axis_border_show ?? false),
|
||||
: (panelSchema.config?.axis_border_show ?? true),
|
||||
lineStyle: { color: chartAxisLine() },
|
||||
},
|
||||
axisTick: {
|
||||
show: xAxisKeys.length + breakDownKeys.length == 1 ? false : true,
|
||||
@@ -720,6 +722,8 @@ export function buildSQLContext(
|
||||
ellipsis: "..",
|
||||
},
|
||||
axisLabel: {
|
||||
color: chartTextColor(),
|
||||
fontSize: 12,
|
||||
hideOverlap: true,
|
||||
formatter: function (value: any) {
|
||||
try {
|
||||
@@ -775,7 +779,8 @@ export function buildSQLContext(
|
||||
if (options.xAxis) {
|
||||
(Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]).forEach(
|
||||
(axis: { splitLine: { show: boolean; lineStyle: unknown } }) => {
|
||||
axis.splitLine.show = showGridlines;
|
||||
// Gridlines run along the value axis only; the category axis draws none.
|
||||
axis.splitLine.show = false;
|
||||
axis.splitLine.lineStyle = gridLineStyle;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveLogSeverity,
|
||||
resolveSeverityFieldValue,
|
||||
setSeverityInferenceEnabled,
|
||||
severityFieldTextClass,
|
||||
severityIndicatorColor,
|
||||
severityRowClass,
|
||||
severityStringValues,
|
||||
@@ -1048,6 +1049,23 @@ describe("indicator colours and row classes (A7)", () => {
|
||||
expect(severityRowClass(sev({ status: 503 }))).toBe("o2-log-level-error o2-log-level-src-http");
|
||||
});
|
||||
|
||||
it("colours only a severity field's own cell by its level", () => {
|
||||
expect(severityFieldTextClass("level", "ERROR")).toBe(
|
||||
"font-mono font-medium text-log-severity-error-text",
|
||||
);
|
||||
expect(severityFieldTextClass("level", "warn")).toBe(
|
||||
"font-mono font-medium text-log-severity-warning-text",
|
||||
);
|
||||
expect(severityFieldTextClass("severity", "debug")).toBe(
|
||||
"font-mono font-medium text-log-severity-debug-text",
|
||||
);
|
||||
expect(severityFieldTextClass("level", "info")).toBe(
|
||||
"font-mono font-medium text-log-severity-info-text",
|
||||
);
|
||||
expect(severityFieldTextClass("message", "ERROR")).toBe("");
|
||||
expect(severityFieldTextClass("status", 503)).toBe("");
|
||||
});
|
||||
|
||||
it("defines every indicator token in light and dark", () => {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const tokens = join(here, "../../lib/styles/tokens");
|
||||
@@ -1070,8 +1088,8 @@ describe("indicator colours and row classes (A7)", () => {
|
||||
expect(base).toContain(token);
|
||||
expect(dark).toContain(token);
|
||||
}
|
||||
expect(base).toContain("--color-log-severity-warning-indicator: #dd7b00;");
|
||||
expect(base).toContain("--color-log-severity-debug-indicator: #00a3b7;");
|
||||
expect(base).toContain("--color-log-severity-warning-indicator: #c46a00;");
|
||||
expect(base).toContain("--color-log-severity-debug-indicator: #008a9b;");
|
||||
expect(base).toContain("--color-log-severity-trace-indicator: #8497a0;");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user