panelui-native 0.79.1 → 0.80.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.
Files changed (30) hide show
  1. package/lib/module/components/bottom-sheet/index.js +9 -2
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/panelside/index.js +527 -19
  4. package/lib/module/components/panelside/index.js.map +1 -1
  5. package/lib/module/components/qr-code/index.js +84 -15
  6. package/lib/module/components/qr-code/index.js.map +1 -1
  7. package/lib/module/components/qr-code/qr-shapes.js +202 -0
  8. package/lib/module/components/qr-code/qr-shapes.js.map +1 -0
  9. package/lib/module/components/timeline/index.js +145 -18
  10. package/lib/module/components/timeline/index.js.map +1 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/typescript/src/components/bottom-sheet/index.d.ts +16 -0
  13. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  14. package/lib/typescript/src/components/panelside/index.d.ts +242 -5
  15. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  16. package/lib/typescript/src/components/qr-code/index.d.ts +42 -3
  17. package/lib/typescript/src/components/qr-code/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/qr-code/qr-shapes.d.ts +64 -0
  19. package/lib/typescript/src/components/qr-code/qr-shapes.d.ts.map +1 -0
  20. package/lib/typescript/src/components/timeline/index.d.ts +33 -0
  21. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  22. package/lib/typescript/src/index.d.ts +1 -1
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/bottom-sheet/index.tsx +9 -2
  26. package/src/components/panelside/index.tsx +750 -28
  27. package/src/components/qr-code/index.tsx +124 -18
  28. package/src/components/qr-code/qr-shapes.ts +234 -0
  29. package/src/components/timeline/index.tsx +165 -14
  30. package/src/index.ts +3 -0
@@ -16,17 +16,33 @@
16
16
  *
17
17
  * ## What it draws
18
18
  *
19
- * Every dark module is one subpath of a single `<Path>`, not a `<Rect>` of its
19
+ * Three `<Path>`s: the body, the three corner rings, and the three corner
20
+ * centres. Every module is a subpath of one of them, not a `<Rect>` of its
20
21
  * own. A version 10 code is 3,481 modules; half of them dark is seventeen
21
22
  * hundred native views for a picture that never changes, and the difference
22
- * between that and one node is the difference between a code that appears and
23
- * one that appears eventually.
23
+ * between that and three nodes is the difference between a code that appears
24
+ * and one that appears eventually.
25
+ *
26
+ * Three rather than one because they are the three things worth colouring and
27
+ * shaping separately. A code is found by its corner eyes before a single
28
+ * module is read, so they are the part a design touches first — and the part
29
+ * it is most dangerous to touch badly.
24
30
  *
25
31
  * The colours come from the theme, so a code drawn on a card is legible on
26
32
  * every one of them — and light-on-dark is drawn the way scanners expect it,
27
33
  * with the quiet zone painted rather than left transparent. A code with
28
34
  * nothing behind it reads at about half the distance.
29
35
  *
36
+ * ## Shaping it
37
+ *
38
+ * `moduleShape`, `eyeFrameShape` and `eyeBallShape` on `QRCode.Canvas` change
39
+ * the geometry; `color`, `eyeFrameColor`, `eyeBallColor` and `backgroundColor`
40
+ * change the ink. A scanner samples the centre of each cell, so every shape
41
+ * here reads exactly as a square does — what changes is how much of the cell
42
+ * is covered. `dot` fills about two thirds of it and `diamond` half, which is
43
+ * read distance spent on a look. Raise `errorCorrection` to pay for it, and
44
+ * check a printed code rather than one on a screen.
45
+ *
30
46
  * ## The hole in the middle
31
47
  *
32
48
  * `QRCode.Logo` clears a square of modules and puts its children there.
@@ -52,8 +68,19 @@ import { Text } from '../../primitives/text';
52
68
  import { cn } from '../../utils/cn';
53
69
  import { Popover, type PopoverContentProps } from '../popover';
54
70
  import { encodeQr, type ErrorCorrectionLevel, type QrMatrix } from './qr-encode';
71
+ import {
72
+ eyeBallPath,
73
+ eyeFramePath,
74
+ finderOrigins,
75
+ inFinder,
76
+ modulePath,
77
+ type QRCodeEyeBallShape,
78
+ type QRCodeEyeFrameShape,
79
+ type QRCodeModuleShape,
80
+ } from './qr-shapes';
55
81
 
56
82
  export type { ErrorCorrectionLevel };
83
+ export type { QRCodeModuleShape, QRCodeEyeFrameShape, QRCodeEyeBallShape };
57
84
 
58
85
  const qrCodeVariants = tv({
59
86
  slots: {
@@ -260,6 +287,27 @@ export interface QRCodeCanvasProps extends Omit<ViewProps, 'children'> {
260
287
  color?: string;
261
288
  /** The plate the modules sit on. See the note below. */
262
289
  backgroundColor?: string;
290
+ /**
291
+ * How a data module is drawn.
292
+ *
293
+ * `rounded` and `classy` join to their neighbours — a corner is rounded only
294
+ * where both cells touching it are light — so a run reads as one stroke
295
+ * rather than as a string of beads with a light seam through it.
296
+ *
297
+ * `dot` and `diamond` do not tile, and cost read distance for it: `dot`
298
+ * covers about two thirds of its cell and `diamond` exactly half, so the
299
+ * same code is a fainter code at the same size. Raise `errorCorrection` with
300
+ * them, and check a printed one rather than a screen.
301
+ */
302
+ moduleShape?: QRCodeModuleShape;
303
+ /** How the ring around each of the three corner eyes is drawn. */
304
+ eyeFrameShape?: QRCodeEyeFrameShape;
305
+ /** How the square inside each corner eye is drawn. */
306
+ eyeBallShape?: QRCodeEyeBallShape;
307
+ /** The three corner rings. Defaults to `color`. */
308
+ eyeFrameColor?: string;
309
+ /** The three corner centres. Defaults to `eyeFrameColor`, then `color`. */
310
+ eyeBallColor?: string;
263
311
  }
264
312
 
265
313
  /**
@@ -282,38 +330,88 @@ const PLATE = '#ffffff';
282
330
  const MODULE = '#111111';
283
331
 
284
332
  const QRCodeCanvas = forwardRef<View, QRCodeCanvasProps>(
285
- ({ className, pixelSize, color, backgroundColor, ...props }, ref) => {
333
+ (
334
+ {
335
+ className,
336
+ pixelSize,
337
+ color,
338
+ backgroundColor,
339
+ moduleShape = 'square',
340
+ eyeFrameShape = 'square',
341
+ eyeBallShape = 'square',
342
+ eyeFrameColor,
343
+ eyeBallColor,
344
+ ...props
345
+ },
346
+ ref
347
+ ) => {
286
348
  const { matrix, error, value, size, logoFraction } = useQRCode('QRCode.Canvas');
287
349
 
288
350
  const side = pixelSize ?? CANVAS_SIZE[size];
289
351
  const dark = color ?? MODULE;
290
352
  const light = backgroundColor ?? PLATE;
353
+ const frameInk = eyeFrameColor ?? dark;
354
+ const ballInk = eyeBallColor ?? frameInk;
291
355
 
292
356
  /*
293
- * One path for every dark module.
357
+ * Three paths for the whole code: the body, the three corner rings, and
358
+ * the three corner centres.
359
+ *
360
+ * Three rather than one because they are three different things a reader
361
+ * can colour and shape separately — and still three rather than one node
362
+ * per module, which is what a version 10 code would cost: 3,481 cells,
363
+ * half of them dark, seventeen hundred native views for a picture that
364
+ * never changes.
294
365
  *
295
- * The viewBox is in module units, so the path is written once and scales
296
- * to whatever `side` is — no arithmetic per module, and no rounding gaps
366
+ * The viewBox is in module units, so a shape is written once and scales to
367
+ * whatever `side` is — no arithmetic per module, and no rounding gaps
297
368
  * between neighbours at fractional sizes.
298
369
  */
299
- const path = useMemo(() => {
300
- if (!matrix) return '';
370
+ const paths = useMemo(() => {
371
+ if (!matrix) return { body: '', frames: '', balls: '' };
301
372
 
302
373
  const hole = logoFraction ? holeBounds(matrix.size, logoFraction) : null;
303
374
 
375
+ /*
376
+ * The body, as its own matrix rather than as a filter inside the loop.
377
+ * The rounded shapes ask their neighbours whether a corner is free, and
378
+ * the answer has to be about what is *drawn* — a module beside the logo
379
+ * hole or beside an eye has nothing there to join to, and joining to it
380
+ * leaves a squared-off corner facing a gap.
381
+ */
382
+ const body: boolean[][] = [];
383
+ for (let y = 0; y < matrix.size; y++) {
384
+ const row: boolean[] = [];
385
+ for (let x = 0; x < matrix.size; x++) {
386
+ const lit =
387
+ !!matrix.modules[y]![x] &&
388
+ !inFinder(x, y, matrix.size) &&
389
+ // Modules under the logo are not drawn at all — drawing them and
390
+ // covering them leaves a dark edge wherever the logo is smaller
391
+ // than the square it cleared.
392
+ !(hole && x >= hole.from && x < hole.to && y >= hole.from && y < hole.to);
393
+ row.push(lit);
394
+ }
395
+ body.push(row);
396
+ }
397
+
304
398
  let d = '';
305
399
  for (let y = 0; y < matrix.size; y++) {
306
400
  for (let x = 0; x < matrix.size; x++) {
307
- if (!matrix.modules[y]![x]) continue;
308
- // Modules under the logo are not drawn at all — drawing them and
309
- // covering them leaves a dark edge wherever the logo is smaller
310
- // than the square it cleared.
311
- if (hole && x >= hole.from && x < hole.to && y >= hole.from && y < hole.to) continue;
312
- d += `M${x + QUIET_ZONE} ${y + QUIET_ZONE}h1v1h-1z`;
401
+ if (!body[y]![x]) continue;
402
+ d += modulePath(moduleShape, body, x, y, matrix.size, QUIET_ZONE);
313
403
  }
314
404
  }
315
- return d;
316
- }, [matrix, logoFraction]);
405
+
406
+ let frames = '';
407
+ let balls = '';
408
+ for (const [x, y, corner] of finderOrigins(matrix.size)) {
409
+ frames += eyeFramePath(eyeFrameShape, x, y, QUIET_ZONE, corner);
410
+ balls += eyeBallPath(eyeBallShape, x, y, QUIET_ZONE);
411
+ }
412
+
413
+ return { body: d, frames, balls };
414
+ }, [matrix, logoFraction, moduleShape, eyeFrameShape, eyeBallShape]);
317
415
 
318
416
  if (error) {
319
417
  // A box the size the code would have been, so the layout does not jump
@@ -349,7 +447,15 @@ const QRCodeCanvas = forwardRef<View, QRCodeCanvasProps>(
349
447
  Rounded, because at this point it is an object on the card rather
350
448
  than a rectangle of paper. */}
351
449
  <Rect x={0} y={0} width={grid} height={grid} rx={2.5} fill={light} />
352
- <Path d={path} fill={dark} />
450
+ <Path d={paths.body} fill={dark} />
451
+ {/* `evenodd` is what makes the ring a ring: the 5×5 subpath inside
452
+ the 7×7 one is a hole rather than a second filled square. A
453
+ stroke would be centred on its path and put the eye's outer edge
454
+ half a module outside the seven the specification allows, and the
455
+ width of that square is the one measurement a scanner takes
456
+ before it reads anything. */}
457
+ <Path d={paths.frames} fill={frameInk} fillRule="evenodd" />
458
+ <Path d={paths.balls} fill={ballInk} />
353
459
  </Svg>
354
460
  </View>
355
461
  );
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The geometry a styled QR code is drawn from.
3
+ *
4
+ * A code is three different things wearing one colour: the body — every module
5
+ * that carries data — and the three corner eyes, each of which is a square
6
+ * ring with a square inside it. Scanners find a code by those three eyes
7
+ * before they read a single module, which is why they are the part worth
8
+ * shaping separately and the part it is most dangerous to shape badly.
9
+ *
10
+ * Everything here works in **module units**: one module is 1×1, and the paths
11
+ * are placed into a viewBox that is the matrix plus its quiet zone. So a shape
12
+ * is written once and scales to whatever the code is drawn at, with no
13
+ * arithmetic per module and no rounding gaps between neighbours at fractional
14
+ * sizes.
15
+ *
16
+ * ## What is safe to change and what is not
17
+ *
18
+ * A scanner locates a module by sampling the centre of its cell, so a shape
19
+ * that stays inside its own cell and keeps that centre dark reads exactly as a
20
+ * square does. All of these do. What actually costs read distance is *area*:
21
+ * `dot` covers about two thirds of its cell and `diamond` exactly half, so a
22
+ * code in either is a fainter code at the same size, and one in `diamond` at a
23
+ * low error-correction level is a code that works on a screen and not on
24
+ * paper.
25
+ *
26
+ * The rounded shapes join to their neighbours rather than rounding every
27
+ * corner. A corner is rounded only where both cells touching it are light —
28
+ * otherwise a run of modules would be a string of beads with a light seam
29
+ * through it, and the seam is what the sampler sees.
30
+ */
31
+
32
+ /** How a data module is drawn. */
33
+ export type QRCodeModuleShape = 'square' | 'rounded' | 'dot' | 'classy' | 'diamond';
34
+ /** How the ring around each of the three corner eyes is drawn. */
35
+ export type QRCodeEyeFrameShape = 'square' | 'rounded' | 'circle' | 'leaf' | 'shield';
36
+ /** How the square inside each corner eye is drawn. */
37
+ export type QRCodeEyeBallShape = 'square' | 'rounded' | 'dot' | 'diamond' | 'leaf';
38
+
39
+ /** A finder pattern is seven modules on a side, in three of the four corners. */
40
+ export const FINDER_SIZE = 7;
41
+
42
+ /**
43
+ * How much smaller than its cell a free-standing shape is drawn.
44
+ *
45
+ * Circles and diamonds do not tile, so nothing is gained by having them touch:
46
+ * a hair of space is what makes a field of dots read as dots rather than as a
47
+ * grid that has gone soft. Squares and the rounded shapes keep the full cell,
48
+ * because they *do* tile and any gap there is a light seam through a run.
49
+ */
50
+ const INSET = 0.05;
51
+
52
+ /** Corner radii, clockwise from the top-left, in module units. */
53
+ type Corners = [number, number, number, number];
54
+
55
+ /**
56
+ * A rectangle with a radius per corner.
57
+ *
58
+ * `A` with a zero radius is a straight line by the SVG specification, so a
59
+ * sharp corner needs no branch of its own.
60
+ */
61
+ function roundedRect(x: number, y: number, w: number, h: number, r: Corners): string {
62
+ const [tl, tr, br, bl] = r;
63
+ return (
64
+ `M${x + tl} ${y}` +
65
+ `H${x + w - tr}A${tr} ${tr} 0 0 1 ${x + w} ${y + tr}` +
66
+ `V${y + h - br}A${br} ${br} 0 0 1 ${x + w - br} ${y + h}` +
67
+ `H${x + bl}A${bl} ${bl} 0 0 1 ${x} ${y + h - bl}` +
68
+ `V${y + tl}A${tl} ${tl} 0 0 1 ${x + tl} ${y}` +
69
+ 'Z'
70
+ );
71
+ }
72
+
73
+ /** A circle, as two half-arcs — the only way to write one in a path. */
74
+ function circle(cx: number, cy: number, r: number): string {
75
+ return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0Z`;
76
+ }
77
+
78
+ /** A square stood on its point, inscribed in the cell. */
79
+ function diamond(cx: number, cy: number, r: number): string {
80
+ return `M${cx} ${cy - r}L${cx + r} ${cy}L${cx} ${cy + r}L${cx - r} ${cy}Z`;
81
+ }
82
+
83
+ /**
84
+ * Which of a module's four sides have a dark module against them.
85
+ *
86
+ * Only the four orthogonal neighbours, not the diagonals. A corner is rounded
87
+ * when both of its sides are free, and a diagonal neighbour touching that
88
+ * corner is a separate run — rounding away from it is what gives a diagonal
89
+ * stripe its scalloped edge instead of a chain of squares.
90
+ */
91
+ function neighbours(modules: boolean[][], x: number, y: number, size: number) {
92
+ const at = (nx: number, ny: number) =>
93
+ nx >= 0 && ny >= 0 && nx < size && ny < size && !!modules[ny]![nx];
94
+ return { up: at(x, y - 1), right: at(x + 1, y), down: at(x, y + 1), left: at(x - 1, y) };
95
+ }
96
+
97
+ /** True where `(x, y)` falls inside one of the three finder patterns. */
98
+ export function inFinder(x: number, y: number, size: number): boolean {
99
+ const far = size - FINDER_SIZE;
100
+ return (
101
+ (x < FINDER_SIZE && y < FINDER_SIZE) ||
102
+ (x >= far && y < FINDER_SIZE) ||
103
+ (x < FINDER_SIZE && y >= far)
104
+ );
105
+ }
106
+
107
+ /** Which corner of the code an eye sits in. */
108
+ export type QRCodeEyeCorner = 'tl' | 'tr' | 'bl';
109
+
110
+ /**
111
+ * Where each finder pattern starts, and which corner it is in.
112
+ *
113
+ * The corner is not decoration: an asymmetric eye shape has to be turned to
114
+ * face outwards, or two of the three point into the middle of the code and
115
+ * read as a mistake rather than as a style.
116
+ */
117
+ export function finderOrigins(size: number): [number, number, QRCodeEyeCorner][] {
118
+ const far = size - FINDER_SIZE;
119
+ return [
120
+ [0, 0, 'tl'],
121
+ [far, 0, 'tr'],
122
+ [0, far, 'bl'],
123
+ ];
124
+ }
125
+
126
+ /** One data module, in the requested shape, offset into the quiet zone. */
127
+ export function modulePath(
128
+ shape: QRCodeModuleShape,
129
+ modules: boolean[][],
130
+ x: number,
131
+ y: number,
132
+ size: number,
133
+ offset: number
134
+ ): string {
135
+ const px = x + offset;
136
+ const py = y + offset;
137
+
138
+ if (shape === 'square') return `M${px} ${py}h1v1h-1z`;
139
+ if (shape === 'dot') return circle(px + 0.5, py + 0.5, 0.5 - INSET);
140
+ // No inset on the diamond. It already covers half its cell, which is the
141
+ // least of any shape here, and taking another tenth off the radius costs a
142
+ // fifth of what is left.
143
+ if (shape === 'diamond') return diamond(px + 0.5, py + 0.5, 0.5);
144
+
145
+ const { up, right, down, left } = neighbours(modules, x, y, size);
146
+ // Half a module: a free-standing cell becomes a circle and a run becomes a
147
+ // stadium, which is the shape the corner joining exists to produce.
148
+ const r = 0.5;
149
+ const tl = up || left ? 0 : r;
150
+ const tr = up || right ? 0 : r;
151
+ const br = down || right ? 0 : r;
152
+ const bl = down || left ? 0 : r;
153
+
154
+ if (shape === 'classy') {
155
+ // One diagonal rounded, the other left sharp — the leaf. Still joined, so
156
+ // a run of them is one stroke with two rounded ends rather than a row of
157
+ // leaves with seams between.
158
+ return roundedRect(px, py, 1, 1, [tl, 0, br, 0]);
159
+ }
160
+ return roundedRect(px, py, 1, 1, [tl, tr, br, bl]);
161
+ }
162
+
163
+ /**
164
+ * One eye's ring: a 7×7 outline with a 5×5 hole in it.
165
+ *
166
+ * Two subpaths and `fillRule="evenodd"`, rather than a stroked rectangle. A
167
+ * stroke is centred on its path, so its outer edge lands half a module outside
168
+ * the seven the specification allows — and the eye stops being seven modules
169
+ * wide, which is the one measurement a scanner takes before it reads anything.
170
+ */
171
+ export function eyeFramePath(
172
+ shape: QRCodeEyeFrameShape,
173
+ x: number,
174
+ y: number,
175
+ offset: number,
176
+ corner: QRCodeEyeCorner = 'tl'
177
+ ): string {
178
+ const px = x + offset;
179
+ const py = y + offset;
180
+ const s = FINDER_SIZE;
181
+ const inner = s - 2;
182
+
183
+ if (shape === 'circle') {
184
+ return circle(px + s / 2, py + s / 2, s / 2) + circle(px + s / 2, py + s / 2, inner / 2);
185
+ }
186
+
187
+ /*
188
+ * `shield` keeps one corner square and rounds the other three, and the
189
+ * square one is the corner of the code the eye sits in — the corner a
190
+ * reader's eye already finds the edge of the page by. Turned the same way
191
+ * in all three positions, two of them point their flat corner into the
192
+ * middle of the code and the set reads as a mistake.
193
+ */
194
+ const SHARP: Record<QRCodeEyeCorner, Corners> = {
195
+ tl: [0, 3.5, 3.5, 3.5],
196
+ tr: [3.5, 0, 3.5, 3.5],
197
+ bl: [3.5, 3.5, 3.5, 0],
198
+ };
199
+
200
+ const outer: Record<Exclude<QRCodeEyeFrameShape, 'circle'>, Corners> = {
201
+ square: [0, 0, 0, 0],
202
+ rounded: [2, 2, 2, 2],
203
+ // Two opposite corners, the same diagonal in all three positions: a leaf
204
+ // is symmetric about that diagonal, so there is no outward to face and
205
+ // turning them would only break the set's rhythm.
206
+ leaf: [3.5, 0, 3.5, 0],
207
+ shield: SHARP[corner],
208
+ };
209
+
210
+ const o = outer[shape as Exclude<QRCodeEyeFrameShape, 'circle'>];
211
+ // The hole is concentric, so each radius loses the ring's own thickness —
212
+ // one module. Floored at zero: a square corner stays square.
213
+ const i = o.map((value) => Math.max(value - 1, 0)) as Corners;
214
+
215
+ return roundedRect(px, py, s, s, o) + roundedRect(px + 1, py + 1, inner, inner, i);
216
+ }
217
+
218
+ /** One eye's centre: the 3×3 square two modules inside the ring. */
219
+ export function eyeBallPath(
220
+ shape: QRCodeEyeBallShape,
221
+ x: number,
222
+ y: number,
223
+ offset: number
224
+ ): string {
225
+ const px = x + offset + 2;
226
+ const py = y + offset + 2;
227
+ const s = 3;
228
+
229
+ if (shape === 'square') return `M${px} ${py}h${s}v${s}h${-s}z`;
230
+ if (shape === 'dot') return circle(px + s / 2, py + s / 2, s / 2);
231
+ if (shape === 'diamond') return diamond(px + s / 2, py + s / 2, s / 2);
232
+ if (shape === 'leaf') return roundedRect(px, py, s, s, [1.5, 0, 1.5, 0]);
233
+ return roundedRect(px, py, s, s, [1, 1, 1, 1]);
234
+ }