basalt-ui 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPersistedState` — the in-tab notification contract.
|
|
3
|
+
*
|
|
4
|
+
* The versioned envelope and the cross-tab path are exercised through every store that persists
|
|
5
|
+
* (`fields.test.tsx`, `search-store.test.ts`); what only shows up here is TWO instances over ONE
|
|
6
|
+
* key, which is ordinary rather than exotic: a page's store and a widget's own state legitimately
|
|
7
|
+
* name the same key. Per-instance listener sets made a write through one invisible to the other in
|
|
8
|
+
* the SAME tab — while the cross-tab path worked, which is what made it read as a caching bug.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
11
|
+
import { act, renderHook } from '@testing-library/react'
|
|
12
|
+
import { createPersistedState, readPersistedValue } from './persisted'
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
localStorage.clear()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('createPersistedState — one key, two instances', () => {
|
|
19
|
+
test('a write through A re-renders a hook on B with the new value', () => {
|
|
20
|
+
const useA = createPersistedState({ key: 'shared-draft', version: 1, initial: 'a' })
|
|
21
|
+
const useB = createPersistedState({ key: 'shared-draft', version: 1, initial: 'a' })
|
|
22
|
+
|
|
23
|
+
const a = renderHook(() => useA())
|
|
24
|
+
const b = renderHook(() => useB())
|
|
25
|
+
expect(b.result.current[0]).toBe('a')
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
a.result.current[1]('written-through-A')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(b.result.current[0]).toBe('written-through-A')
|
|
32
|
+
expect(a.result.current[0]).toBe('written-through-A')
|
|
33
|
+
expect(readPersistedValue('shared-draft', 1)).toBe('written-through-A')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('the notification is per KEY — a second key is not woken', () => {
|
|
37
|
+
const useShared = createPersistedState({ key: 'k-one', version: 1, initial: 0 })
|
|
38
|
+
const useOther = createPersistedState({ key: 'k-two', version: 1, initial: 0 })
|
|
39
|
+
|
|
40
|
+
let otherRenders = 0
|
|
41
|
+
const shared = renderHook(() => useShared())
|
|
42
|
+
renderHook(() => {
|
|
43
|
+
otherRenders += 1
|
|
44
|
+
return useOther()
|
|
45
|
+
})
|
|
46
|
+
const before = otherRenders
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
shared.result.current[1](7)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(shared.result.current[0]).toBe(7)
|
|
53
|
+
expect(otherRenders).toBe(before)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('unmounting one instance leaves the other subscribed', () => {
|
|
57
|
+
const useA = createPersistedState({ key: 'shared-unmount', version: 1, initial: 'a' })
|
|
58
|
+
const useB = createPersistedState({ key: 'shared-unmount', version: 1, initial: 'a' })
|
|
59
|
+
|
|
60
|
+
const a = renderHook(() => useA())
|
|
61
|
+
const b = renderHook(() => useB())
|
|
62
|
+
a.unmount()
|
|
63
|
+
|
|
64
|
+
const c = renderHook(() => useA())
|
|
65
|
+
act(() => {
|
|
66
|
+
c.result.current[1]('later')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(b.result.current[0]).toBe('later')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('an object value stays referentially stable between writes (the snapshot cache)', () => {
|
|
73
|
+
const useDraft = createPersistedState<{ title: string }>({
|
|
74
|
+
key: 'shared-object',
|
|
75
|
+
version: 1,
|
|
76
|
+
initial: { title: '' },
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(() => useDraft())
|
|
80
|
+
const first = result.current[0]
|
|
81
|
+
act(() => {
|
|
82
|
+
result.current[1]({ title: 'one' })
|
|
83
|
+
})
|
|
84
|
+
const written = result.current[0]
|
|
85
|
+
|
|
86
|
+
expect(first).not.toBe(written)
|
|
87
|
+
expect(written).toEqual({ title: 'one' })
|
|
88
|
+
// Same raw string → same parsed reference, which is what keeps useSyncExternalStore quiet.
|
|
89
|
+
expect(renderHook(() => useDraft()).result.current[0]).toBe(written)
|
|
90
|
+
})
|
|
91
|
+
})
|
package/src/state/persisted.ts
CHANGED
|
@@ -32,6 +32,42 @@ export type PersistedStateOptions<T> = {
|
|
|
32
32
|
readonly schema?: StandardSchemaV1<unknown, T>
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Every subscriber for ONE storage key, shared by every `createPersistedState` instance over that
|
|
37
|
+
* key — plus the single `storage` event listener the key needs.
|
|
38
|
+
*
|
|
39
|
+
* Module-level rather than per-instance because two instances of the same key are ordinary, not a
|
|
40
|
+
* mistake: a page's `createSearchStore` and a widget's `createPersistedState` legitimately name the
|
|
41
|
+
* same key, and a consumer re-exporting a store from two modules ends up there by accident. With
|
|
42
|
+
* per-instance listener sets a write through one instance updated localStorage and notified only
|
|
43
|
+
* its own hooks, so the other instance's components kept rendering the stale value until something
|
|
44
|
+
* else re-rendered them — in the SAME tab, while the cross-tab path (the `storage` event) worked.
|
|
45
|
+
*
|
|
46
|
+
* The map is keyed by the NAMESPACED key and holds no window reference; a channel is created on
|
|
47
|
+
* first subscribe, which only happens in the browser (`subscribe` is never passed on the server).
|
|
48
|
+
*/
|
|
49
|
+
type KeyChannel = {
|
|
50
|
+
readonly listeners: Set<() => void>
|
|
51
|
+
handler: ((e: StorageEvent) => void) | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const channels = new Map<string, KeyChannel>()
|
|
55
|
+
|
|
56
|
+
/** Wake every hook on a key, whichever instance holds it. */
|
|
57
|
+
function notify(storageKey: string): void {
|
|
58
|
+
const channel = channels.get(storageKey)
|
|
59
|
+
if (channel === undefined) return
|
|
60
|
+
for (const listener of channel.listeners) listener()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function channelFor(storageKey: string): KeyChannel {
|
|
64
|
+
const existing = channels.get(storageKey)
|
|
65
|
+
if (existing !== undefined) return existing
|
|
66
|
+
const created: KeyChannel = { listeners: new Set(), handler: null }
|
|
67
|
+
channels.set(storageKey, created)
|
|
68
|
+
return created
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
/** The envelope stored in localStorage: `{ v: number, value: unknown }`. */
|
|
36
72
|
type Envelope = { v: number; value: unknown }
|
|
37
73
|
|
|
@@ -73,16 +109,19 @@ function parseStorage<T>(raw: string | null, opts: PersistedStateOptions<T>): T
|
|
|
73
109
|
}
|
|
74
110
|
}
|
|
75
111
|
|
|
76
|
-
function
|
|
77
|
-
const storageKey = `basalt:${opts.key}`
|
|
112
|
+
function writeEnvelope(storageKey: string, version: number, value: unknown): void {
|
|
78
113
|
try {
|
|
79
|
-
const envelope: Envelope = { v:
|
|
114
|
+
const envelope: Envelope = { v: version, value }
|
|
80
115
|
window.localStorage.setItem(storageKey, JSON.stringify(envelope))
|
|
81
116
|
} catch {
|
|
82
117
|
// Silently fail (storage full, private browsing, etc.)
|
|
83
118
|
}
|
|
84
119
|
}
|
|
85
120
|
|
|
121
|
+
function writeStorage<T>(opts: PersistedStateOptions<T>, next: T): void {
|
|
122
|
+
writeEnvelope(`basalt:${opts.key}`, opts.version, next)
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
/**
|
|
87
126
|
* Versioned, Standard-Schema-validated localStorage state via useSyncExternalStore (no zustand).
|
|
88
127
|
* SSR-safe (getServerSnapshot returns `initial`). Cross-tab via the storage event.
|
|
@@ -100,32 +139,25 @@ export function createPersistedState<T>(
|
|
|
100
139
|
): () => readonly [T, (next: T) => void] {
|
|
101
140
|
const storageKey = `basalt:${opts.key}`
|
|
102
141
|
|
|
103
|
-
// One Set of subscriber callbacks — mirrors notifications/store.ts single-listener-to-Set pattern.
|
|
104
|
-
const listeners = new Set<() => void>()
|
|
105
|
-
|
|
106
|
-
// ONE module-scoped 'storage' event listener per key, registered lazily on first subscriber.
|
|
107
|
-
// Removed when the last subscriber unsubscribes (cleanup semantics preserved).
|
|
108
|
-
let storageHandler: ((e: StorageEvent) => void) | null = null
|
|
109
|
-
|
|
110
142
|
const subscribe = (cb: () => void): (() => void) => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
143
|
+
// The subscriber set is the KEY's, not this instance's — see `KeyChannel`.
|
|
144
|
+
const channel = channelFor(storageKey)
|
|
145
|
+
channel.listeners.add(cb)
|
|
146
|
+
|
|
147
|
+
// ONE 'storage' event listener per key, registered lazily on the first subscriber and removed
|
|
148
|
+
// when the last one leaves (cleanup semantics preserved).
|
|
149
|
+
if (channel.handler === null) {
|
|
150
|
+
channel.handler = (e: StorageEvent): void => {
|
|
151
|
+
if (e.key === storageKey) notify(storageKey)
|
|
119
152
|
}
|
|
120
|
-
window.addEventListener('storage',
|
|
153
|
+
window.addEventListener('storage', channel.handler)
|
|
121
154
|
}
|
|
122
155
|
|
|
123
156
|
return () => {
|
|
124
|
-
listeners.delete(cb)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
storageHandler = null
|
|
157
|
+
channel.listeners.delete(cb)
|
|
158
|
+
if (channel.listeners.size === 0 && channel.handler !== null) {
|
|
159
|
+
window.removeEventListener('storage', channel.handler)
|
|
160
|
+
channel.handler = null
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
163
|
}
|
|
@@ -158,8 +190,9 @@ export function createPersistedState<T>(
|
|
|
158
190
|
|
|
159
191
|
const setState = (next: T): void => {
|
|
160
192
|
writeStorage(opts, next)
|
|
161
|
-
// Notify in-tab
|
|
162
|
-
|
|
193
|
+
// Notify every in-tab listener on this KEY — the 'storage' event fires in OTHER tabs only, and
|
|
194
|
+
// a second instance of the same key has its own hooks to wake.
|
|
195
|
+
notify(storageKey)
|
|
163
196
|
}
|
|
164
197
|
|
|
165
198
|
// Detect SSR once at creation time — the environment doesn't change between renders.
|
|
@@ -209,3 +242,20 @@ export function readPersistedValue(key: string, version?: number): unknown | nul
|
|
|
209
242
|
return null
|
|
210
243
|
}
|
|
211
244
|
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write a value under the namespaced key `basalt:<key>` in the same versioned envelope
|
|
248
|
+
* `createPersistedState` writes, and wake every hook on that key — the write half of
|
|
249
|
+
* `readPersistedValue`, and plain in the same way: no React required.
|
|
250
|
+
*
|
|
251
|
+
* The one call site is a store field's `clear()`, which has to DELETE its key from the store's
|
|
252
|
+
* record from an event handler, where the hook's setter is not in reach. Not exported from the
|
|
253
|
+
* `basalt-ui/state` barrel: the two store factories are the API, this is their seam.
|
|
254
|
+
*
|
|
255
|
+
* @internal
|
|
256
|
+
*/
|
|
257
|
+
export function writePersistedValue(key: string, version: number, value: unknown): void {
|
|
258
|
+
const storageKey = `basalt:${key}`
|
|
259
|
+
writeEnvelope(storageKey, version, value)
|
|
260
|
+
notify(storageKey)
|
|
261
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -30,6 +30,10 @@ export {
|
|
|
30
30
|
type RangeValue,
|
|
31
31
|
type RangeParams,
|
|
32
32
|
type RangePresets,
|
|
33
|
+
type RangeWindow,
|
|
34
|
+
type RangeWindows,
|
|
35
|
+
type FieldFallback,
|
|
36
|
+
type FieldSetOptions,
|
|
33
37
|
type EnumField,
|
|
34
38
|
type MultiField,
|
|
35
39
|
type RangeField,
|
|
@@ -47,6 +51,8 @@ export {
|
|
|
47
51
|
type StoreEntry,
|
|
48
52
|
type StoreCoreOptions,
|
|
49
53
|
type StoreCore,
|
|
54
|
+
type FieldUse,
|
|
55
|
+
type FieldWrite,
|
|
50
56
|
resolveFieldCodec,
|
|
51
57
|
createStoreCore,
|
|
52
58
|
} from './state/fields'
|
package/src/surfaces.ts
CHANGED
|
@@ -553,8 +553,9 @@ export const SURFACES = {
|
|
|
553
553
|
// The text lane of law C1 — the plugin's `control-outside-home` seen through a 12-line host-tag
|
|
554
554
|
// window, since a regex scan has no ancestry. Same law, same wave, same promotion.
|
|
555
555
|
guardKinds: ['raw-selection-control'],
|
|
556
|
-
// The control tier itself (laws C1/C3/C5/C9). `hand-rolled-filter
|
|
557
|
-
//
|
|
556
|
+
// The control tier itself (laws C1/C3/C5/C9). `hand-rolled-filter`, `control-size-literal` and
|
|
557
|
+
// `responsive-twin` all ship `error`; `control-outside-home` is the one grace entry left in
|
|
558
|
+
// `PLUGIN_RULE_GRACE`, re-dated to 1.28.0 against the wave-7 measurement (see its `why`).
|
|
558
559
|
// The budget rules live on `.` with `PageBar` itself.
|
|
559
560
|
pluginRules: [
|
|
560
561
|
'hand-rolled-filter',
|
|
@@ -571,7 +572,7 @@ export const SURFACES = {
|
|
|
571
572
|
'C12 — one shape for refresh/sync (SyncButton); only the alias table sees a renamed copy',
|
|
572
573
|
],
|
|
573
574
|
description:
|
|
574
|
-
'The control tier (docs/CONTROLS-SPEC.md §3): FilterSet (nowrap row + measured +N fold + the mobile Filters (n) sheet), RangeFilter/CompareFilter/SelectFilter/MultiSelectFilter/SearchFilter/ToggleFilter (each bound to a FieldHandle — no value/onChange/size, law C2/C5), ViewTabs, and the action/sync family (ActionGroup, OverflowMenu, SyncButton, BarAction/GlobalAction). Every control owns its own desktop/mobile swap in CSS (C9) and renders size="ctl" internally. Resolves and renders with NO @mantine/dates installed — the custom date picker is injected through RangeFilter.customPicker from ./controls-dates.',
|
|
575
|
+
'The control tier (docs/CONTROLS-SPEC.md §3): FilterSet (nowrap row + measured +N fold + the mobile Filters (n) sheet), RangeFilter/CompareFilter/SelectFilter/MultiSelectFilter/NumberFilter/SearchFilter/ToggleFilter (each bound to a FieldHandle — no value/onChange/size, law C2/C5; NumberFilter is the field.number lane, a radio list over `options` or a stepper without), ViewTabs, and the action/sync family (ActionGroup, OverflowMenu, SyncButton, BarAction/GlobalAction). Every control owns its own desktop/mobile swap in CSS (C9) and renders size="ctl" internally. Resolves and renders with NO @mantine/dates installed — the custom date picker is injected through RangeFilter.customPicker from ./controls-dates.',
|
|
575
576
|
optionalPeers: [],
|
|
576
577
|
forbiddenImports: [],
|
|
577
578
|
},
|
|
@@ -199,6 +199,21 @@
|
|
|
199
199
|
color: var(--vx-ink);
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/*
|
|
203
|
+
* The unit beside the hero value. Mono like the number it qualifies (so `412 TSS` sits on one
|
|
204
|
+
* baseline grid), `--vx-text-sm` and `--vx-muted` so it never competes with the numeral — the whole
|
|
205
|
+
* point is that a unit is the SMALL half of the pair. `flex-shrink: 0`: the value is the row's
|
|
206
|
+
* shrinkable half (see `.value`), and a unit that ellipsized to `TS…` would state nothing.
|
|
207
|
+
*/
|
|
208
|
+
.unit {
|
|
209
|
+
flex-shrink: 0;
|
|
210
|
+
align-self: baseline;
|
|
211
|
+
font-family: var(--basalt-font-mono);
|
|
212
|
+
font-size: var(--vx-text-sm);
|
|
213
|
+
font-weight: 500;
|
|
214
|
+
color: var(--vx-muted);
|
|
215
|
+
}
|
|
216
|
+
|
|
202
217
|
/* The C1/C5 home for header actions — always pinned right, regardless of what else is present. */
|
|
203
218
|
.actions {
|
|
204
219
|
display: inline-flex;
|
|
@@ -51,10 +51,40 @@ export type WidgetHeaderProps = {
|
|
|
51
51
|
/** Pre-formatted metric value (mono, hero size). Renders on its own row with `delta`, directly
|
|
52
52
|
* under the title row — never inline with the title. */
|
|
53
53
|
value?: string
|
|
54
|
+
/**
|
|
55
|
+
* The value's unit, rendered immediately after it on the hero row: muted, mono, `--vx-text-sm`.
|
|
56
|
+
*
|
|
57
|
+
* It exists because `value` is a pre-formatted `string` and a KPI's unit is not part of the
|
|
58
|
+
* number: `412` and `TSS` are two facts at two weights, and jamming them into one string paints
|
|
59
|
+
* the unit at the hero's 24px — which reads as a second numeral. `subtitle` was the only place a
|
|
60
|
+
* unit could go before this, and a unit on its own line under the value is a different claim (the
|
|
61
|
+
* BASIS: `7-day rolling`), so cards using both had to pick one.
|
|
62
|
+
*
|
|
63
|
+
* Never a substitute for the number's own formatting: a thousands separator, a currency symbol or
|
|
64
|
+
* a `%` belong in `value`, where they are part of how the numeral reads.
|
|
65
|
+
*/
|
|
66
|
+
unit?: string
|
|
54
67
|
/** Signed delta rendered via `DeltaBadge`, on the same hero-metric row as `value`. */
|
|
55
68
|
delta?: number
|
|
56
69
|
/** Comparison timeframe forwarded to `DeltaBadge` (e.g. `MoM`). */
|
|
57
70
|
deltaPeriod?: string
|
|
71
|
+
/**
|
|
72
|
+
* Formats `delta` into the chip's label — forwarded to `DeltaBadge`'s `format`. Defaults to
|
|
73
|
+
* `${Math.abs(delta).toFixed(1)}%`.
|
|
74
|
+
*
|
|
75
|
+
* It exists because not every delta is a percentage: a pace card's trend is `0:12 /km` and a
|
|
76
|
+
* throughput card's is `0.3 km/h`, and the default printed both as a percent — a wrong unit on a
|
|
77
|
+
* KPI, which is worse than no chip. A FUNCTION rather than a label string, so the number stays the
|
|
78
|
+
* number: `delta` still drives the tone and the glyph, and there is exactly one place the sign is
|
|
79
|
+
* decided.
|
|
80
|
+
*
|
|
81
|
+
* The value arrives SIGNED, so a formatter that wants to print the sign itself (`−0:12 /km`) can —
|
|
82
|
+
* pair it with `deltaGlyph={false}` so the ▼ does not say the same thing twice.
|
|
83
|
+
*/
|
|
84
|
+
deltaFormat?: (delta: number) => string
|
|
85
|
+
/** Render `DeltaBadge`'s ▲/▼ glyph. Defaults to `true`; a zero delta never shows one. Pass
|
|
86
|
+
* `false` when `deltaFormat` prints the sign itself. */
|
|
87
|
+
deltaGlyph?: boolean
|
|
58
88
|
/** Trend visual slot, rendered below the metric row. Carries `data-placement="right"` so a
|
|
59
89
|
* wave-3 composer's `bleed` layout can select it. */
|
|
60
90
|
sparkline?: ReactNode
|
|
@@ -127,8 +157,11 @@ export function WidgetHeader({
|
|
|
127
157
|
subtitle,
|
|
128
158
|
info,
|
|
129
159
|
value,
|
|
160
|
+
unit,
|
|
130
161
|
delta,
|
|
131
162
|
deltaPeriod,
|
|
163
|
+
deltaFormat,
|
|
164
|
+
deltaGlyph,
|
|
132
165
|
sparkline,
|
|
133
166
|
count,
|
|
134
167
|
actions,
|
|
@@ -154,7 +187,19 @@ export function WidgetHeader({
|
|
|
154
187
|
{(value !== undefined || delta !== undefined) && (
|
|
155
188
|
<div className={classes.metrics}>
|
|
156
189
|
{value !== undefined && <span className={classes.value}>{value}</span>}
|
|
157
|
-
{
|
|
190
|
+
{/* Gated on `value`, not on itself: a unit with nothing to qualify is a stray word, and a
|
|
191
|
+
card that lost its number must not keep printing `TSS` beside the empty space. */}
|
|
192
|
+
{value !== undefined && unit !== undefined && (
|
|
193
|
+
<span className={classes.unit}>{unit}</span>
|
|
194
|
+
)}
|
|
195
|
+
{delta !== undefined && (
|
|
196
|
+
<DeltaBadge
|
|
197
|
+
value={delta}
|
|
198
|
+
period={deltaPeriod}
|
|
199
|
+
{...(deltaFormat !== undefined && { format: deltaFormat })}
|
|
200
|
+
{...(deltaGlyph !== undefined && { withGlyph: deltaGlyph })}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
158
203
|
</div>
|
|
159
204
|
)}
|
|
160
205
|
{subtitle !== undefined && <span className={classes.subtitle}>{subtitle}</span>}
|