basalt-ui 2.3.0 → 2.4.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.
@@ -21,7 +21,7 @@ import { useCallback } from 'react'
21
21
  import type { ReactElement } from 'react'
22
22
  import { ChartShell } from './CartesianChart'
23
23
  import type { ChartShellProps, ShellContext } from './CartesianChart'
24
- import { defaultFormatX, utcHeaderDate, visibleSeries, xGuide, yGuide } from './compile'
24
+ import { defaultFormatX, engineRow, utcHeaderDate, visibleSeries, xGuide, yGuide } from './compile'
25
25
  import type { CartesianDefinition } from './compile'
26
26
  import { cursorBinding, sourceOnlyFocus } from './cursor'
27
27
  import type { XScaleModel } from './scales'
@@ -205,7 +205,7 @@ function panelDefinition<T, X extends ChartX>(
205
205
  const { x, d } = sourceOf<T>(primary, owned)
206
206
  const extra: readonly ChartTooltipRow[] =
207
207
  tooltip?.rows?.(d, { visible, hidden: hiddenSeries }) ?? []
208
- return { title: header(x, d), rows: [...plan.rows(d), ...extra] }
208
+ return { title: header(x, d), rows: [...plan.rows(d), ...extra.map(engineRow)] }
209
209
  },
210
210
  },
211
211
  }
@@ -31,6 +31,13 @@ export type ChartSeries<T> = {
31
31
  * tier, a config value) is a step: a smoothed curve draws values that never existed. */
32
32
  curve?: CurveName | undefined
33
33
  strokeWidth?: number | undefined
34
+ /**
35
+ * What a `null` value does to the line (and its `area`). `'break'` (default) leaves a gap;
36
+ * `'connect'` draws through the series' non-null points only, skipping the null rows, so a series
37
+ * that is null on most shared x values (one lift in a wide table) is one continuous line. Points,
38
+ * markers, the cursor and the tooltip still read real values: a null row has no tooltip row.
39
+ */
40
+ gaps?: 'break' | 'connect' | undefined
34
41
  /** Dims the plotted stroke (a faint companion line). Strokes only. */
35
42
  strokeOpacity?: number | undefined
36
43
  /** Bars and areas. */
@@ -53,6 +60,12 @@ export type ChartSeries<T> = {
53
60
  note?: string | undefined
54
61
  /** Per-series tooltip value format; receives the datum so a row can read more than the value. */
55
62
  formatValue?: ((v: number, d: T) => string) | undefined
63
+ /**
64
+ * A cursor dot for a series whose mark the consumer draws itself (`marks`, so `getValue` stays
65
+ * `null`): the y of the dot at each datum, `null` for none. The dot shows while the cursor is on
66
+ * that datum's x and takes the series color. A series drawn from `plot.rows` already has one.
67
+ */
68
+ cursorValue?: ((d: T) => number | null) | undefined
56
69
  /** A marker at a point (a PR star, a status dot); `null` for none. */
57
70
  getMarker?:
58
71
  | ((d: T) => {
@@ -95,12 +108,24 @@ export type AxisConfig<T> = {
95
108
  scale?: 'linear' | 'log' | undefined
96
109
  }
97
110
 
111
+ /** A rule along a band's boundary, painted with the band. A bound at the plot edge draws none. */
112
+ export type ZoneEdge<S extends string> = {
113
+ side: S | 'both'
114
+ color: string
115
+ dashed?: boolean | undefined
116
+ /** Default `1`. */
117
+ width?: number | undefined
118
+ }
119
+
98
120
  /** A horizontal band on one y axis. `±Infinity` reaches the plot edge. */
99
121
  export type ZoneSpec = {
100
122
  from: number
101
123
  to: number
102
124
  fill: string
103
125
  axisSide?: 'left' | 'right' | undefined
126
+ /** A rule on the band's visual `top` (the larger bound) and/or `bottom` (the smaller), whichever
127
+ * of `from`/`to` that is. */
128
+ edge?: ZoneEdge<'top' | 'bottom'> | undefined
104
129
  }
105
130
 
106
131
  /**
@@ -113,6 +138,8 @@ export type XZoneSpec<X extends ChartX = ChartX> = {
113
138
  to?: X | undefined
114
139
  fill: string
115
140
  align?: 'center' | 'edge' | undefined
141
+ /** A rule on the band's `start` (`from`) and/or `end` (`to`) boundary; an omitted bound has none. */
142
+ edge?: ZoneEdge<'start' | 'end'> | undefined
116
143
  }
117
144
 
118
145
  /** A horizontal reference line — annotation only, no domain role beyond staying inside the plot. */
@@ -121,6 +148,13 @@ export type RefLine = {
121
148
  color: string
122
149
  dashed?: boolean | undefined
123
150
  axis?: 'left' | 'right' | undefined
151
+ /**
152
+ * Names the line in the legend (its color, dashed as `dashed`; a toggle hides the line), so a
153
+ * threshold needs no always-`null` series. `mark` is the swatch shape. Default `'line'`. The
154
+ * toggle state is keyed by the line's index in `refLines`: reordering the array while a line is
155
+ * toggled off moves the "off" to whichever line now sits at that index.
156
+ */
157
+ legend?: { label: string; mark?: 'line' | 'bar' | undefined } | undefined
124
158
  }
125
159
 
126
160
  /** A fill between the line and a value, on one side of it, tracking the line. */
@@ -138,7 +172,9 @@ export type ChartTooltipRow = {
138
172
  label: string
139
173
  value: string
140
174
  /** The row swatch; omitted, the row has none. */
141
- color?: string
175
+ color?: string | undefined
176
+ /** The swatch shape; `'dashed'` for a reference or dashed series. Default `'solid'`. */
177
+ swatch?: 'solid' | 'dashed' | undefined
142
178
  }
143
179
 
144
180
  export type ChartTooltipConfig<T, X extends ChartX = ChartX> = {
@@ -279,6 +279,24 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
279
279
  readonly step?: undefined
280
280
  }
281
281
 
282
+ /**
283
+ * `createLocalStore`'s `use()` read options. `fallback` is a CALL-SITE default for a value that
284
+ * arrives as a prop (a leader, a latest date): it applies only while the field is unset (nothing
285
+ * stored), is applied in render AFTER the store read, and is never persisted — `clear()` unsets
286
+ * back to it. `handle.fallback` and `isDefault` still report the definition's.
287
+ *
288
+ * An object fallback (range, multi) must be memoised by the caller: the value returned while unset
289
+ * IS that object, so a fresh literal per render changes identity every render.
290
+ */
291
+ export type FieldReadOptions<F extends AnyField> = { fallback?: FieldValue<F> }
292
+
293
+ /** A `createLocalStore` field: the shared handle, plus `use({ fallback })`. */
294
+ export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
295
+ use(
296
+ read?: FieldReadOptions<F>,
297
+ ): readonly [FieldValue<F>, (next: FieldValue<F>, opts?: FieldSetOptions) => void]
298
+ }
299
+
282
300
  /**
283
301
  * The binding every basalt control takes instead of `value`/`onChange` (C2). One field, both lanes,
284
302
  * one resolution law. Handed out by `createSearchStore().field.<name>` /
@@ -771,7 +789,7 @@ type StoreRecord = Record<string, unknown>
771
789
  export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
772
790
 
773
791
  /** One field's `use()`, untyped. @internal — see `FieldCodec`. */
774
- export type FieldUse = () => readonly [unknown, FieldWrite]
792
+ export type FieldUse = (read?: FieldReadOptions<AnyField>) => readonly [unknown, FieldWrite]
775
793
 
776
794
  /** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
777
795
  export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
@@ -798,7 +816,12 @@ export type StoreCore = {
798
816
  setLabels(map: Record<string, Record<string, string> | undefined>): void
799
817
  optionsFor(entry: StoreEntry): readonly FieldOption[]
800
818
  /** The resolution law, once: URL ⊳ localStorage ⊳ fallback. `search: null` skips the URL lane. */
801
- resolve(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown
819
+ resolve(
820
+ entry: StoreEntry,
821
+ search: StoreRecord | null,
822
+ record: StoreRecord,
823
+ fallback?: unknown,
824
+ ): unknown
802
825
  writeField(setRecord: (next: StoreRecord) => void, name: string, value: unknown): void
803
826
  /**
804
827
  * The IN-MEMORY lane's `use()` for one field — a field with neither the URL nor the mirror.
@@ -841,14 +864,19 @@ function assignDefined(into: StoreRecord, from: StoreRecord): void {
841
864
  * The resolution law, in one place: URL ⊳ localStorage ⊳ fallback (C4). `search: null` skips the
842
865
  * URL lane — what the local lane and `linkSearch` pass.
843
866
  */
844
- function resolveField(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown {
867
+ function resolveField(
868
+ entry: StoreEntry,
869
+ search: StoreRecord | null,
870
+ record: StoreRecord,
871
+ fallback?: unknown,
872
+ ): unknown {
845
873
  const fromUrl = search === null ? null : entry.codec.fromSearch(search)
846
874
  if (fromUrl !== null) return fromUrl
847
875
  if (entry.codec.lane.persist) {
848
876
  const stored = entry.codec.decode(record[entry.name])
849
877
  if (stored !== null) return stored
850
878
  }
851
- return entry.codec.fallback
879
+ return fallback === undefined ? entry.codec.fallback : fallback
852
880
  }
853
881
 
854
882
  /**
@@ -865,7 +893,7 @@ function resolveField(entry: StoreEntry, search: StoreRecord | null, record: Sto
865
893
  function createMemoryLane(): {
866
894
  subscribe: (cb: () => void) => () => void
867
895
  getVersion: () => number
868
- resolve: (entry: StoreEntry) => unknown
896
+ resolve: (entry: StoreEntry, fallback?: unknown) => unknown
869
897
  write: (name: string, value: unknown) => void
870
898
  remove: (name: string) => void
871
899
  clear: () => void
@@ -889,9 +917,10 @@ function createMemoryLane(): {
889
917
  getVersion: () => version,
890
918
  // `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
891
919
  // as a legal value, so it can never collide with a written one.
892
- resolve: (entry) => {
920
+ resolve: (entry, fallback) => {
893
921
  const stored = values.get(entry.name)
894
- return stored === undefined ? entry.codec.fallback : stored
922
+ if (stored !== undefined) return stored
923
+ return fallback === undefined ? entry.codec.fallback : fallback
895
924
  },
896
925
  write: (name, value) => {
897
926
  values.set(name, value)
@@ -1036,11 +1065,14 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
1036
1065
  },
1037
1066
  readRecord,
1038
1067
  memoryUse(entry) {
1039
- return () => {
1068
+ return (read) => {
1040
1069
  // Subscribing to the VERSION (never to the value) is what re-renders every other mount of
1041
1070
  // this field when one of them writes, with no snapshot-stability trap.
1042
1071
  useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion)
1043
- return [memory.resolve(entry), (next: unknown) => memory.write(entry.name, next)] as const
1072
+ return [
1073
+ memory.resolve(entry, read?.fallback),
1074
+ (next: unknown) => memory.write(entry.name, next),
1075
+ ] as const
1044
1076
  }
1045
1077
  },
1046
1078
  readMemoryValue: (entry) => memory.resolve(entry),
@@ -1074,7 +1106,7 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
1074
1106
 
1075
1107
  /** `Pick<SearchStore<S>, 'field' | 'readStored' | 'labels'>` — the same handles, no router. */
1076
1108
  export type LocalStore<S extends Record<string, AnyField>> = {
1077
- readonly field: { [K in keyof S]: FieldHandle<S[K]> }
1109
+ readonly field: { [K in keyof S]: LocalFieldHandle<S[K]> }
1078
1110
  readStored: () => Partial<StoredValues<S>>
1079
1111
  /**
1080
1112
  * Option labels for enum / multi / range fields — the same chainable contract
@@ -1116,20 +1148,24 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
1116
1148
  }): LocalStore<S> {
1117
1149
  const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version })
1118
1150
 
1119
- const persistedUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
1120
- const [record, setRecord] = core.usePersistedRecord()
1121
- return [
1122
- core.resolve(entry, null, record),
1123
- (next: unknown) => core.writeField(setRecord, entry.name, next),
1124
- ] as const
1125
- }
1151
+ const persistedUse =
1152
+ (entry: StoreEntry): FieldUse =>
1153
+ (read) => {
1154
+ const [record, setRecord] = core.usePersistedRecord()
1155
+ return [
1156
+ // The call-site fallback is applied here, in render, after the store read — never inside
1157
+ // the snapshot — so React's out-of-render snapshot reads cannot disagree with it.
1158
+ core.resolve(entry, null, record, read?.fallback),
1159
+ (next: unknown) => core.writeField(setRecord, entry.name, next),
1160
+ ] as const
1161
+ }
1126
1162
 
1127
- const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
1163
+ const field = {} as { [K in keyof S]: LocalFieldHandle<S[K]> }
1128
1164
  for (const entry of core.entries) {
1129
1165
  const persisted = entry.codec.lane.persist
1130
1166
  const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
1131
1167
  const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
1132
- field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as FieldHandle<
1168
+ field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as LocalFieldHandle<
1133
1169
  S[keyof S]
1134
1170
  >
1135
1171
  }