@a4ui/core 0.35.1 → 0.36.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.
@@ -18,6 +18,18 @@ export interface DataGridProps {
18
18
  pageSize?: number;
19
19
  /** Show the global text filter above the table. Defaults to true. */
20
20
  filterable?: boolean;
21
+ /**
22
+ * Copy overrides, for apps that do not run in English. Every user-visible
23
+ * string this component renders on its own is here; without them a Spanish
24
+ * or French page shows "Filter…" and "12 rows" in the middle of its own
25
+ * table with no way to change it.
26
+ * @default 'Filter…'
27
+ */
28
+ filterPlaceholder?: string;
29
+ /** Accessible name for the filter box. @default 'Filter rows' */
30
+ filterLabel?: string;
31
+ /** Builds the row-count summary. @default (n) => `${n} rows` */
32
+ rowsSummary?: (count: number) => string;
21
33
  class?: string;
22
34
  }
23
35
  /**
@@ -9,6 +9,15 @@ export interface LocationPickerProps {
9
9
  value?: LocationValue;
10
10
  /** Fires with the full value whenever the label, the pin, or geolocation changes it. */
11
11
  onChange?: (v: LocationValue) => void;
12
+ /**
13
+ * Placeholder for the free-text label field. Overridable because this was
14
+ * hardcoded English inside an otherwise fully translatable component — a
15
+ * Spanish form showed "Location label" in the middle of its own wizard.
16
+ * @default 'Location label'
17
+ */
18
+ labelPlaceholder?: string;
19
+ /** Accessible name for the "use my location" button. @default 'Use my location' */
20
+ geolocateLabel?: string;
12
21
  class?: string;
13
22
  }
14
23
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.35.1",
3
+ "version": "0.36.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // import '@a4ui/core/styles.css'
9
9
  // import { Button, Card, Modal } from '@a4ui/core'
10
10
 
11
- export const A4UI_VERSION = '0.35.1'
11
+ export const A4UI_VERSION = '0.36.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -31,6 +31,18 @@ export interface DataGridProps {
31
31
  pageSize?: number
32
32
  /** Show the global text filter above the table. Defaults to true. */
33
33
  filterable?: boolean
34
+ /**
35
+ * Copy overrides, for apps that do not run in English. Every user-visible
36
+ * string this component renders on its own is here; without them a Spanish
37
+ * or French page shows "Filter…" and "12 rows" in the middle of its own
38
+ * table with no way to change it.
39
+ * @default 'Filter…'
40
+ */
41
+ filterPlaceholder?: string
42
+ /** Accessible name for the filter box. @default 'Filter rows' */
43
+ filterLabel?: string
44
+ /** Builds the row-count summary. @default (n) => `${n} rows` */
45
+ rowsSummary?: (count: number) => string
34
46
  class?: string
35
47
  }
36
48
 
@@ -126,8 +138,8 @@ export function DataGrid(props: DataGridProps): JSX.Element {
126
138
  setQuery(v)
127
139
  setPage(1)
128
140
  }}
129
- placeholder="Filter…"
130
- aria-label="Filter rows"
141
+ placeholder={props.filterPlaceholder ?? 'Filter…'}
142
+ aria-label={props.filterLabel ?? 'Filter rows'}
131
143
  />
132
144
  </div>
133
145
  )}
@@ -170,7 +182,7 @@ export function DataGrid(props: DataGridProps): JSX.Element {
170
182
  page={Math.min(page(), totalPages())}
171
183
  totalPages={totalPages()}
172
184
  onChange={setPage}
173
- summary={`${sorted().length} rows`}
185
+ summary={props.rowsSummary?.(sorted().length) ?? `${sorted().length} rows`}
174
186
  />
175
187
  </div>
176
188
  )
@@ -51,10 +51,21 @@ interface WorldPixel {
51
51
  y: number
52
52
  }
53
53
 
54
+ /**
55
+ * A tile's IDENTITY, with no position in it.
56
+ *
57
+ * `id` and `src` are both pure functions of z/x/y, so a given tile's
58
+ * descriptor never changes while it is on screen — only where it sits does.
59
+ * Keeping position out of this type is what lets the memo below hand `<For>`
60
+ * the same object across a pan, so the `<img>` survives instead of being torn
61
+ * down and rebuilt.
62
+ */
54
63
  interface TileDescriptor {
55
64
  id: string
56
- left: number
57
- top: number
65
+ /** Tile column, unwrapped — multiplied by TILE_SIZE to place the tile. */
66
+ x: number
67
+ /** Tile row. */
68
+ y: number
58
69
  src: string
59
70
  }
60
71
 
@@ -174,9 +185,33 @@ export function InteractiveMap(props: InteractiveMapProps): JSX.Element {
174
185
  return { x: c.x - w / 2, y: c.y - h / 2 }
175
186
  })
176
187
 
188
+ /**
189
+ * Live tiles, keyed by id so identity survives a pan.
190
+ *
191
+ * `<For>` in Solid keys by REFERENCE. This memo used to build fresh
192
+ * descriptor objects on every recompute, so every pointer move made every
193
+ * tile a "new" item: the whole grid was removed from the DOM and rebuilt,
194
+ * even though the same tiles were still on screen. Measured on a 20-tile
195
+ * map, one ~500 ms drag produced 340 element insertions and 344 removals.
196
+ *
197
+ * The fade-in below turned that churn into a visible strobe. A rebuilt
198
+ * `<img>` starts at `opacity-0` and only becomes visible when its `load`
199
+ * event re-adds `a4ui-tile-loaded`, so during a pan every tile blinked out
200
+ * and back roughly seventeen times a second — the file header has always
201
+ * claimed tiles are "repositioned (not reloaded) as the user pans", and now
202
+ * they are.
203
+ *
204
+ * Reusing entries also means the cache holds only what is on screen: ids
205
+ * that fall out of range are dropped on the same pass that adds new ones.
206
+ */
207
+ const tileCache = new Map<string, TileDescriptor>()
208
+
177
209
  const tiles = createMemo<TileDescriptor[]>(() => {
178
210
  const { w, h } = size()
179
- if (w <= 0 || h <= 0) return []
211
+ if (w <= 0 || h <= 0) {
212
+ tileCache.clear()
213
+ return []
214
+ }
180
215
  const z = zoom()
181
216
  const numTiles = 2 ** z
182
217
  const tl = topLeftPx()
@@ -188,17 +223,29 @@ export function InteractiveMap(props: InteractiveMapProps): JSX.Element {
188
223
  const maxY = Math.min(numTiles - 1, Math.floor((tl.y + h) / TILE_SIZE) + 1)
189
224
 
190
225
  const list: TileDescriptor[] = []
226
+ const live = new Set<string>()
227
+
191
228
  for (let x = minX; x <= maxX; x++) {
192
229
  const wrappedX = ((x % numTiles) + numTiles) % numTiles
193
230
  for (let y = minY; y <= maxY; y++) {
194
- list.push({
195
- id: `${z}/${x}/${y}`,
196
- left: x * TILE_SIZE - tl.x,
197
- top: y * TILE_SIZE - tl.y,
198
- src: buildTileUrl(url, z, wrappedX, y),
199
- })
231
+ const id = `${z}/${x}/${y}`
232
+ live.add(id)
233
+ let tile = tileCache.get(id)
234
+ // The url prop can change at runtime, so a cached tile is only
235
+ // reusable while its src still matches.
236
+ const src = buildTileUrl(url, z, wrappedX, y)
237
+ if (!tile || tile.src !== src) {
238
+ tile = { id, x, y, src }
239
+ tileCache.set(id, tile)
240
+ }
241
+ list.push(tile)
200
242
  }
201
243
  }
244
+
245
+ for (const id of tileCache.keys()) {
246
+ if (!live.has(id)) tileCache.delete(id)
247
+ }
248
+
202
249
  return list
203
250
  })
204
251
 
@@ -309,7 +356,13 @@ export function InteractiveMap(props: InteractiveMapProps): JSX.Element {
309
356
  !motionReduced() &&
310
357
  'opacity-0 transition-opacity duration-150 [&.a4ui-tile-loaded]:opacity-100',
311
358
  )}
312
- style={{ left: `${tile.left}px`, top: `${tile.top}px` }}
359
+ // Read from the reactive origin rather than from the tile, so
360
+ // panning updates two style properties on an element that stays
361
+ // put instead of replacing it.
362
+ style={{
363
+ left: `${tile.x * TILE_SIZE - topLeftPx().x}px`,
364
+ top: `${tile.y * TILE_SIZE - topLeftPx().y}px`,
365
+ }}
313
366
  onLoad={(event) => event.currentTarget.classList.add('a4ui-tile-loaded')}
314
367
  />
315
368
  )}
@@ -20,6 +20,15 @@ export interface LocationPickerProps {
20
20
  value?: LocationValue
21
21
  /** Fires with the full value whenever the label, the pin, or geolocation changes it. */
22
22
  onChange?: (v: LocationValue) => void
23
+ /**
24
+ * Placeholder for the free-text label field. Overridable because this was
25
+ * hardcoded English inside an otherwise fully translatable component — a
26
+ * Spanish form showed "Location label" in the middle of its own wizard.
27
+ * @default 'Location label'
28
+ */
29
+ labelPlaceholder?: string
30
+ /** Accessible name for the "use my location" button. @default 'Use my location' */
31
+ geolocateLabel?: string
23
32
  class?: string
24
33
  }
25
34
 
@@ -77,10 +86,14 @@ export function LocationPicker(props: LocationPickerProps): JSX.Element {
77
86
  type="text"
78
87
  value={current().label ?? ''}
79
88
  onInput={(event) => setLabel(event.currentTarget.value)}
80
- placeholder="Location label"
89
+ placeholder={props.labelPlaceholder ?? 'Location label'}
81
90
  class="h-9 flex-1 rounded-md border border-border bg-input px-3 text-sm text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:outline-none"
82
91
  />
83
- <Button variant="outline" aria-label="Use my location" onClick={useMyLocation}>
92
+ <Button
93
+ variant="outline"
94
+ aria-label={props.geolocateLabel ?? 'Use my location'}
95
+ onClick={useMyLocation}
96
+ >
84
97
  <Crosshair class="h-4 w-4" />
85
98
  </Button>
86
99
  </div>
package/src/ui/Table.tsx CHANGED
@@ -34,7 +34,15 @@ interface TableProps extends ParentProps {
34
34
  export function Table(props: TableProps): JSX.Element {
35
35
  const [local, rest] = splitProps(props, ['class', 'children'])
36
36
  return (
37
- <div class="w-full overflow-x-auto">
37
+ // `relative` is load-bearing, not decoration: `overflow` only clips a
38
+ // descendant whose containing block is inside the scroller. Without a
39
+ // positioned wrapper, an absolutely-positioned descendant (a
40
+ // screen-reader-only input inside a cell control, for instance) resolves
41
+ // against an ancestor further up, escapes the clip at the table's full
42
+ // scroll width, and drags the whole DOCUMENT into horizontal scrolling —
43
+ // measured on a phone: a 356 px table inside a 390 px viewport pushed the
44
+ // page to 413 px.
45
+ <div class="relative w-full overflow-x-auto">
38
46
  <table class={cn('w-full border-collapse text-sm', local.class)} {...rest}>
39
47
  {local.children}
40
48
  </table>