@duro-app/ui 5.14.0 → 5.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.14.0",
3
+ "version": "5.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,11 +91,13 @@
91
91
  },
92
92
  "dependencies": {
93
93
  "@tanstack/react-virtual": "^3.14.6",
94
- "@duro-app/tokens": "^5.14.0"
94
+ "qrcode": "^1.5.4",
95
+ "@duro-app/tokens": "^5.16.0"
95
96
  },
96
97
  "devDependencies": {
97
98
  "@babel/preset-typescript": "^7.28.0",
98
99
  "@hookform/resolvers": "^5.0.0",
100
+ "@types/qrcode": "^1.5.6",
99
101
  "@types/react": "^19.0.0",
100
102
  "@types/react-dom": "^19.0.0",
101
103
  "@vitejs/plugin-react": "^4.0.0",
@@ -0,0 +1,18 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A QR code for another device to scan: dark modules on a fixed light square, as wide as its container, announced by its label as one image. The same on web and native.',
6
+ whenToUse: [
7
+ 'Pairing or sign-in by scanning from a second device',
8
+ 'Handing a URL or a short secret to a phone camera',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Decoration or branding: a QR code is a machine-readable payload, not an illustration',
12
+ 'The only way to reach something: also give the text or a link beside it',
13
+ ],
14
+ example: `<Stack gap="sm">
15
+ <QrCode value="https://example.com/pair?key=abc" label="Scan to sign in on this device" />
16
+ <Text>Scan with a signed-in phone</Text>
17
+ </Stack>`,
18
+ }
@@ -0,0 +1,31 @@
1
+ import {useMemo} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import Svg, {Path} from 'react-native-svg'
4
+ import {useResolvedColors} from '../ThemeProvider/ThemeProvider'
5
+ import {qrGeometry} from './geometry'
6
+ import {styles} from './styles.css'
7
+ import type {QrCodeProps} from './types'
8
+
9
+ export type {QrCodeProps} from './types'
10
+
11
+ /**
12
+ * Native QrCode: the web's path, drawn with react-native-svg (RSD has no `svg`). The colour is the
13
+ * fixed dark token resolved through the nearest ThemeProvider, as the native Icon resolves its own.
14
+ */
15
+ export function QrCode({value, label}: QrCodeProps) {
16
+ const {size, d} = useMemo(() => qrGeometry(value), [value])
17
+ const dark = useResolvedColors().fixedDark
18
+ return (
19
+ <html.div role="img" aria-label={label} style={styles.frame}>
20
+ <Svg
21
+ viewBox={`0 0 ${size} ${size}`}
22
+ width="100%"
23
+ height="100%"
24
+ accessibilityElementsHidden
25
+ importantForAccessibility="no-hide-descendants"
26
+ >
27
+ <Path d={d} fill={dark} />
28
+ </Svg>
29
+ </html.div>
30
+ )
31
+ }
@@ -0,0 +1,44 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
5
+ import {QrCode} from './QrCode'
6
+
7
+ const styles = css.create({
8
+ column: {width: sizes.dialogSm},
9
+ })
10
+
11
+ const meta: Meta<typeof QrCode> = {
12
+ title: 'Components/QrCode',
13
+ component: QrCode,
14
+ args: {
15
+ value: 'https://example.com/pair?key=0123456789abcdef',
16
+ label: 'Scan to sign in on this device',
17
+ },
18
+ decorators: [
19
+ (Story) => (
20
+ <html.div style={styles.column}>
21
+ <Story />
22
+ </html.div>
23
+ ),
24
+ ],
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof QrCode>
29
+
30
+ export const Default: Story = {
31
+ play: async ({canvas}) => {
32
+ const code = canvas.getByRole('img', {name: 'Scan to sign in on this device'})
33
+ await expect(code).toBeInTheDocument()
34
+ const box = code.getBoundingClientRect()
35
+ await expect(Math.round(box.width)).toBe(Math.round(box.height))
36
+ },
37
+ }
38
+
39
+ export const LongValue: Story = {
40
+ args: {
41
+ value: 'happier://account?key=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdEfGhIjKlMnOpQrStUvWxYz',
42
+ label: 'Scan to connect',
43
+ },
44
+ }
@@ -0,0 +1,30 @@
1
+ import {useMemo} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
4
+ import {qrGeometry} from './geometry'
5
+ import {styles} from './styles.css'
6
+ import type {QrCodeProps} from './types'
7
+
8
+ export type {QrCodeProps} from './types'
9
+
10
+ /**
11
+ * A QR code for another device to scan: one SVG path of the dark modules, drawn as an image with
12
+ * a label. Native draws the same path with react-native-svg (QrCode.native.tsx).
13
+ */
14
+ export function QrCode({value, label}: QrCodeProps) {
15
+ const {size, d} = useMemo(() => qrGeometry(value), [value])
16
+ return (
17
+ <html.div role="img" aria-label={label} style={styles.frame}>
18
+ <svg
19
+ viewBox={`0 0 ${size} ${size}`}
20
+ width="100%"
21
+ height="100%"
22
+ fill="currentColor"
23
+ style={{color: colors.fixedDark, display: 'block'}}
24
+ aria-hidden="true"
25
+ >
26
+ <path d={d} />
27
+ </svg>
28
+ </html.div>
29
+ )
30
+ }
@@ -0,0 +1,34 @@
1
+ import {create} from 'qrcode'
2
+
3
+ /** Light modules around the code, part of the symbol: scanners need it to find the edges. */
4
+ export const QUIET_ZONE = 2
5
+
6
+ export interface QrGeometry {
7
+ /** Side of the square viewBox, in modules, quiet zone included. */
8
+ readonly size: number
9
+ /** Every dark module as one SVG path, one horizontal run per subpath. */
10
+ readonly d: string
11
+ }
12
+
13
+ /**
14
+ * The code as one path, drawn identically by the web `<svg>` and by react-native-svg on native.
15
+ * Error correction M (15 %) matches what Happier's pairing codes used before this component.
16
+ */
17
+ export function qrGeometry(value: string): QrGeometry {
18
+ const {modules} = create(value, {errorCorrectionLevel: 'M'})
19
+ const count = modules.size
20
+ const runs: string[] = []
21
+ for (let row = 0; row < count; row++) {
22
+ let col = 0
23
+ while (col < count) {
24
+ if (!modules.get(row, col)) {
25
+ col++
26
+ continue
27
+ }
28
+ const start = col
29
+ while (col < count && modules.get(row, col)) col++
30
+ runs.push(`M${start + QUIET_ZONE} ${row + QUIET_ZONE}h${col - start}v1h-${col - start}z`)
31
+ }
32
+ }
33
+ return {size: count + QUIET_ZONE * 2, d: runs.join('')}
34
+ }
@@ -0,0 +1,12 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+
4
+ export const styles = css.create({
5
+ // Square, as wide as its container: the caller sizes the column it sits in. Dark modules on the
6
+ // fixed light, in every theme: an inverted code does not scan on every camera.
7
+ frame: {
8
+ width: '100%',
9
+ aspectRatio: 1,
10
+ backgroundColor: colors.fixedLight,
11
+ },
12
+ })
@@ -0,0 +1,6 @@
1
+ export interface QrCodeProps {
2
+ /** The text the code carries: a URL, a pairing link. */
3
+ value: string
4
+ /** What the code is for, read by assistive tech in place of the image. */
5
+ label: string
6
+ }
@@ -36,6 +36,7 @@ export {Callout, type CalloutVariant} from './components/Callout/Callout'
36
36
  export {EmptyState} from './components/EmptyState/EmptyState'
37
37
  export {Icon, type IconName} from './components/Icon'
38
38
  export {Meter, type MeterProps, type MeterTone} from './components/Meter/Meter'
39
+ export {QrCode, type QrCodeProps} from './components/QrCode/QrCode'
39
40
  export {Spinner, type SpinnerSize} from './components/Spinner/Spinner'
40
41
  export {
41
42
  StatusIcon,
package/src/index.ts CHANGED
@@ -83,6 +83,7 @@ export {TextLink, type TextLinkVariant} from './components/TextLink/TextLink'
83
83
  export type {ListboxAnchorProps, ListboxMaxHeight} from './components/Listbox/Listbox'
84
84
  export {Select} from './components/Select/Select'
85
85
  export {SideNav} from './components/SideNav/SideNav'
86
+ export {QrCode, type QrCodeProps} from './components/QrCode/QrCode'
86
87
  export {Spinner, type SpinnerSize} from './components/Spinner/Spinner'
87
88
  export {
88
89
  ToastProvider,