@duro-app/ui 5.13.0 → 5.15.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/README.md +49 -0
- package/dist/{Table-Bms2ftt3.js → Table-DnaoH-h5.js} +747 -635
- package/dist/Table-DnaoH-h5.js.map +1 -0
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.d.ts +2 -1
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
- package/dist/components/ColorMode/storage.d.ts +4 -0
- package/dist/components/ColorMode/storage.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +4 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
- package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
- package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +7 -2
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/comboboxState.d.ts +29 -0
- package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
- package/dist/components/Combobox/native.css.d.ts +27 -0
- package/dist/components/Combobox/native.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts +5 -2
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +2 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -1
- package/dist/components/Drawer/native.css.d.ts +4 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +4 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Form/FormSubmitContext.d.ts +8 -0
- package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
- package/dist/components/Listbox/native.css.d.ts +84 -0
- package/dist/components/Listbox/native.css.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.d.ts +8 -0
- package/dist/components/QrCode/QrCode.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.meta.d.ts +3 -0
- package/dist/components/QrCode/QrCode.meta.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.stories.d.ts +8 -0
- package/dist/components/QrCode/QrCode.stories.d.ts.map +1 -0
- package/dist/components/QrCode/geometry.d.ts +14 -0
- package/dist/components/QrCode/geometry.d.ts.map +1 -0
- package/dist/components/QrCode/styles.css.d.ts +9 -0
- package/dist/components/QrCode/styles.css.d.ts.map +1 -0
- package/dist/components/QrCode/types.d.ts +7 -0
- package/dist/components/QrCode/types.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +9 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +11 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/native.css.d.ts +11 -0
- package/dist/components/Select/native.css.d.ts.map +1 -0
- package/dist/components/Select/selectState.d.ts +21 -0
- package/dist/components/Select/selectState.d.ts.map +1 -0
- package/dist/components/Select/styles.css.d.ts +10 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts +6 -8
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
- package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
- package/dist/components/ThemeProvider/overrides.d.ts +16 -0
- package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6230 -4656
- package/dist/index.js.map +1 -1
- package/dist/shared/textOf.d.ts +7 -0
- package/dist/shared/textOf.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +5 -2
- package/src/components/Button/Button.tsx +12 -1
- package/src/components/ColorMode/ColorMode.meta.ts +1 -1
- package/src/components/ColorMode/ColorMode.tsx +16 -37
- package/src/components/ColorMode/storage.native.ts +8 -0
- package/src/components/ColorMode/storage.ts +19 -0
- package/src/components/ColorMode/styles.css.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.ts +19 -0
- package/src/components/Combobox/Combobox.meta.ts +1 -1
- package/src/components/Combobox/Combobox.native.tsx +295 -0
- package/src/components/Combobox/Combobox.tsx +14 -8
- package/src/components/Combobox/comboboxState.ts +107 -0
- package/src/components/Combobox/native.css.ts +33 -0
- package/src/components/Combobox/useComboboxRoot.ts +17 -91
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.native.tsx +29 -7
- package/src/components/Drawer/Drawer.tsx +11 -2
- package/src/components/Drawer/DrawerSnap.stories.tsx +22 -2
- package/src/components/Drawer/native.css.ts +4 -0
- package/src/components/Drawer/styles.css.ts +4 -0
- package/src/components/Form/Form.meta.ts +1 -1
- package/src/components/Form/Form.native.tsx +120 -0
- package/src/components/Form/FormSubmitContext.ts +12 -0
- package/src/components/Listbox/Listbox.meta.ts +1 -1
- package/src/components/Listbox/Listbox.native.tsx +153 -0
- package/src/components/Listbox/native.css.ts +91 -0
- package/src/components/QrCode/QrCode.meta.ts +18 -0
- package/src/components/QrCode/QrCode.native.tsx +31 -0
- package/src/components/QrCode/QrCode.stories.tsx +44 -0
- package/src/components/QrCode/QrCode.tsx +30 -0
- package/src/components/QrCode/geometry.ts +34 -0
- package/src/components/QrCode/styles.css.ts +12 -0
- package/src/components/QrCode/types.ts +6 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +224 -0
- package/src/components/Select/Select.stories.tsx +49 -1
- package/src/components/Select/Select.tsx +20 -3
- package/src/components/Select/native.css.ts +13 -0
- package/src/components/Select/selectState.ts +36 -0
- package/src/components/Select/styles.css.ts +11 -0
- package/src/components/Select/useSelectRoot.ts +9 -30
- package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
- package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
- package/src/components/ThemeProvider/overrideRule.ts +39 -0
- package/src/components/ThemeProvider/overrides.ts +27 -0
- package/src/form.native.ts +7 -0
- package/src/index.native.ts +16 -0
- package/src/index.ts +2 -0
- package/src/shared/focusInput.native.ts +18 -0
- package/src/shared/textOf.ts +13 -0
- package/dist/Table-Bms2ftt3.js.map +0 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {createContext, type ReactNode, type Ref, useContext} from 'react'
|
|
2
|
+
import {View} from 'react-native'
|
|
3
|
+
import {Pressable} from 'react-native-gesture-handler'
|
|
4
|
+
import {html} from 'react-strict-dom'
|
|
5
|
+
import {sheetStyles} from './native.css'
|
|
6
|
+
import {textOf} from '../../shared/textOf'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native Listbox (plan N0, Slice C): a plain list of options, laid out where
|
|
10
|
+
* it is rendered — inside a Select or Combobox sheet. Nothing is anchored and
|
|
11
|
+
* nothing is highlighted: native has no arrow keys (Decision 6), so each option
|
|
12
|
+
* is its own screen-reader stop and a press chooses it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ListboxMaxHeight = 'listMaxH' | 'listMaxHSm'
|
|
16
|
+
|
|
17
|
+
/** Default option id: the listbox id plus the value, made safe for an id. */
|
|
18
|
+
export function defaultOptionId(listboxId: string, value: string): string {
|
|
19
|
+
return `${listboxId}-option-${value.replace(/[^\w-]/g, (ch) => `_${ch.charCodeAt(0).toString(16)}`)}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ListboxAnchorProps {
|
|
23
|
+
role?: 'combobox'
|
|
24
|
+
'aria-controls': string | undefined
|
|
25
|
+
'aria-expanded': boolean
|
|
26
|
+
'aria-activedescendant': string | undefined
|
|
27
|
+
'aria-autocomplete': 'list'
|
|
28
|
+
'aria-haspopup': 'listbox'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The web's anchor ARIA, kept for one API: RSD drops these props on native. */
|
|
32
|
+
export function getAnchorProps({
|
|
33
|
+
id,
|
|
34
|
+
open,
|
|
35
|
+
highlightedId,
|
|
36
|
+
}: {
|
|
37
|
+
id: string
|
|
38
|
+
open: boolean
|
|
39
|
+
highlightedId: string | null | undefined
|
|
40
|
+
}): ListboxAnchorProps {
|
|
41
|
+
return {
|
|
42
|
+
'aria-controls': open ? id : undefined,
|
|
43
|
+
'aria-expanded': open,
|
|
44
|
+
'aria-activedescendant': open && highlightedId ? highlightedId : undefined,
|
|
45
|
+
'aria-autocomplete': 'list',
|
|
46
|
+
'aria-haspopup': 'listbox',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ListboxContextValue {
|
|
51
|
+
onSelect?: (value: string) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ListboxContext = createContext<ListboxContextValue | null>(null)
|
|
55
|
+
|
|
56
|
+
function useListbox(part: string) {
|
|
57
|
+
const ctx = useContext(ListboxContext)
|
|
58
|
+
if (!ctx) throw new Error(`Listbox.${part} must be used within Listbox.Root`)
|
|
59
|
+
return ctx
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RootProps {
|
|
63
|
+
id: string
|
|
64
|
+
open: boolean
|
|
65
|
+
/** A press on an option. */
|
|
66
|
+
onSelect?: (value: string) => void
|
|
67
|
+
/** Accessible name of the list. */
|
|
68
|
+
'aria-label'?: string
|
|
69
|
+
children: ReactNode
|
|
70
|
+
// The web's placement and keyboard props; a native list flows in place.
|
|
71
|
+
anchor?: () => unknown
|
|
72
|
+
matchAnchorWidth?: boolean
|
|
73
|
+
highlightedId?: string | null
|
|
74
|
+
onHighlight?: (id: string | null) => void
|
|
75
|
+
getOptionId?: (value: string) => string
|
|
76
|
+
maxHeight?: ListboxMaxHeight
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Root({id, open, onSelect, 'aria-label': ariaLabel, children}: RootProps) {
|
|
80
|
+
if (!open) return null
|
|
81
|
+
return (
|
|
82
|
+
<ListboxContext.Provider value={{onSelect}}>
|
|
83
|
+
<html.div id={id} role="listbox" aria-label={ariaLabel} style={sheetStyles.list}>
|
|
84
|
+
{children}
|
|
85
|
+
</html.div>
|
|
86
|
+
</ListboxContext.Provider>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface OptionProps {
|
|
91
|
+
value: string
|
|
92
|
+
id?: string
|
|
93
|
+
/** The committed choice: filled, with a trailing check. */
|
|
94
|
+
selected?: boolean
|
|
95
|
+
disabled?: boolean
|
|
96
|
+
children: ReactNode
|
|
97
|
+
ref?: Ref<unknown>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Option({value, selected = false, disabled = false, children, ref}: OptionProps) {
|
|
101
|
+
const {onSelect} = useListbox('Option')
|
|
102
|
+
const choose = () => {
|
|
103
|
+
if (!disabled) onSelect?.(value)
|
|
104
|
+
}
|
|
105
|
+
// Two hosts, one job each. The View is what a screen reader reads: a
|
|
106
|
+
// selected button named by its text alone, activated by a double-tap.
|
|
107
|
+
// Gesture Handler's Pressable takes the touch natively, since an RN
|
|
108
|
+
// Pressable loses the first tap with the keyboard up to any ScrollView
|
|
109
|
+
// above the sheet's Modal (keyboardShouldPersistTaps 'never', the default);
|
|
110
|
+
// its native button does not expose a role or state, so it is hidden.
|
|
111
|
+
return (
|
|
112
|
+
<View
|
|
113
|
+
ref={ref as Ref<never>}
|
|
114
|
+
accessible
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityLabel={textOf(children) || undefined}
|
|
117
|
+
accessibilityState={{selected, disabled}}
|
|
118
|
+
accessibilityActions={ACTIVATE}
|
|
119
|
+
onAccessibilityAction={(e) => {
|
|
120
|
+
if (e.nativeEvent.actionName === 'activate') choose()
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Pressable accessible={false} disabled={disabled} onPress={choose}>
|
|
124
|
+
{({pressed}) => (
|
|
125
|
+
<html.div style={[sheetStyles.option, (selected || pressed) && sheetStyles.optionFilled]}>
|
|
126
|
+
<html.span style={[sheetStyles.label, disabled && sheetStyles.labelDisabled]}>
|
|
127
|
+
{children}
|
|
128
|
+
</html.span>
|
|
129
|
+
{selected && (
|
|
130
|
+
<html.span aria-hidden style={sheetStyles.check}>
|
|
131
|
+
✓
|
|
132
|
+
</html.span>
|
|
133
|
+
)}
|
|
134
|
+
</html.div>
|
|
135
|
+
)}
|
|
136
|
+
</Pressable>
|
|
137
|
+
</View>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ACTIVATE = [{name: 'activate'}] as const
|
|
142
|
+
|
|
143
|
+
/** Shown in place of options when there are none, or none match. */
|
|
144
|
+
function Empty({children}: {children: ReactNode}) {
|
|
145
|
+
return <html.div style={sheetStyles.empty}>{children}</html.div>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const Listbox = {
|
|
149
|
+
Root,
|
|
150
|
+
Option,
|
|
151
|
+
Empty,
|
|
152
|
+
getAnchorProps,
|
|
153
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
6
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A picker sheet's rows on native (plan N0, C0 direction A "Check"): a
|
|
10
|
+
* touch-target row per option, the chosen one filled and checked.
|
|
11
|
+
*/
|
|
12
|
+
export const sheetStyles = css.create({
|
|
13
|
+
list: {
|
|
14
|
+
display: 'flex',
|
|
15
|
+
flexDirection: 'column',
|
|
16
|
+
},
|
|
17
|
+
option: {
|
|
18
|
+
display: 'flex',
|
|
19
|
+
flexDirection: 'row',
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
gap: spacing.sm,
|
|
22
|
+
minHeight: sizes.touchTarget,
|
|
23
|
+
paddingLeft: spacing.md,
|
|
24
|
+
paddingRight: spacing.md,
|
|
25
|
+
borderBottomWidth: borders.hairline,
|
|
26
|
+
borderBottomStyle: 'solid',
|
|
27
|
+
borderBottomColor: colors.border,
|
|
28
|
+
},
|
|
29
|
+
// The chosen option, and a row under the finger.
|
|
30
|
+
optionFilled: {
|
|
31
|
+
backgroundColor: colors.bgCardHover,
|
|
32
|
+
},
|
|
33
|
+
label: {
|
|
34
|
+
flex: 1,
|
|
35
|
+
color: colors.text,
|
|
36
|
+
fontFamily: typography.fontFamily,
|
|
37
|
+
fontSize: typography.fontSizeSm,
|
|
38
|
+
},
|
|
39
|
+
labelDisabled: {
|
|
40
|
+
color: colors.textMuted,
|
|
41
|
+
},
|
|
42
|
+
check: {
|
|
43
|
+
color: colors.accent,
|
|
44
|
+
fontFamily: typography.fontFamily,
|
|
45
|
+
fontSize: typography.fontSizeSm,
|
|
46
|
+
fontWeight: typography.fontWeightSemibold,
|
|
47
|
+
},
|
|
48
|
+
empty: {
|
|
49
|
+
display: 'flex',
|
|
50
|
+
flexDirection: 'column',
|
|
51
|
+
alignItems: 'center',
|
|
52
|
+
gap: spacing.xs,
|
|
53
|
+
paddingTop: spacing.xl,
|
|
54
|
+
paddingBottom: spacing.xl,
|
|
55
|
+
paddingLeft: spacing.md,
|
|
56
|
+
paddingRight: spacing.md,
|
|
57
|
+
color: colors.textMuted,
|
|
58
|
+
fontFamily: typography.fontFamily,
|
|
59
|
+
fontSize: typography.fontSizeSm,
|
|
60
|
+
textAlign: 'center',
|
|
61
|
+
},
|
|
62
|
+
// The search field pinned at a Combobox sheet's top, under its header.
|
|
63
|
+
search: {
|
|
64
|
+
display: 'flex',
|
|
65
|
+
flexDirection: 'row',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
gap: spacing.sm,
|
|
68
|
+
minHeight: sizes.touchTarget,
|
|
69
|
+
marginLeft: spacing.md,
|
|
70
|
+
marginRight: spacing.md,
|
|
71
|
+
marginBottom: spacing.sm,
|
|
72
|
+
paddingLeft: spacing.sm,
|
|
73
|
+
paddingRight: spacing.sm,
|
|
74
|
+
borderRadius: radii.sm,
|
|
75
|
+
borderWidth: borders.hairline,
|
|
76
|
+
borderStyle: 'solid',
|
|
77
|
+
borderColor: colors.border,
|
|
78
|
+
backgroundColor: colors.bg,
|
|
79
|
+
},
|
|
80
|
+
searchInput: {
|
|
81
|
+
flex: 1,
|
|
82
|
+
color: colors.text,
|
|
83
|
+
fontFamily: typography.fontFamily,
|
|
84
|
+
fontSize: typography.fontSizeSm,
|
|
85
|
+
backgroundColor: 'transparent',
|
|
86
|
+
borderWidth: 0,
|
|
87
|
+
'::placeholder': {
|
|
88
|
+
color: colors.textMuted,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required. Inside a Drawer, a drag in the viewport scrolls it; the drawer only takes a drag toward its edge from the scroll start.',
|
|
5
|
+
'Custom scrollbar region with draggable thumb. Supports vertical and horizontal scrolling. Compound component — Root is required. Inside a Drawer, a drag in the viewport scrolls it; the drawer only takes a drag toward its edge from the scroll start. On React Native the Viewport is an RN ScrollView with the platform scroll indicator, capped by the same maxHeight token key; Scrollbar and Thumb render nothing.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Custom-styled scrollbars for content overflow areas',
|
|
8
8
|
'Chat logs, code blocks, or long lists with constrained height',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type {ComponentProps, ReactNode} from 'react'
|
|
2
|
+
import {ScrollView, StyleSheet} from 'react-native'
|
|
3
|
+
import {html} from 'react-strict-dom'
|
|
4
|
+
import {styles} from './styles.css'
|
|
5
|
+
import {resolveLength, type Length} from '../../shared/length'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Native ScrollArea (plan N0, Slice C): Viewport is an RN ScrollView with the
|
|
9
|
+
* platform's own scroll indicator, so Scrollbar and Thumb draw nothing. The
|
|
10
|
+
* cap (`maxHeight`, a size-token key) sits on the view around it, where
|
|
11
|
+
* react-strict-dom resolves the token.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function Root({children}: {children: ReactNode}) {
|
|
15
|
+
return <html.div style={styles.root}>{children}</html.div>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ViewportProps {
|
|
19
|
+
children: ReactNode
|
|
20
|
+
maxHeight?: Length
|
|
21
|
+
/** Extra styles for the viewport (e.g. a pointer-aware max height). */
|
|
22
|
+
style?: ComponentProps<typeof html.div>['style']
|
|
23
|
+
/** The web's roving-focus flag; native has no tab stops. */
|
|
24
|
+
rovingOwner?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Viewport({children, maxHeight, style}: ViewportProps) {
|
|
28
|
+
return (
|
|
29
|
+
<html.div
|
|
30
|
+
style={[maxHeight != null && styles.viewportMaxHeight(resolveLength(maxHeight)), style]}
|
|
31
|
+
>
|
|
32
|
+
<ScrollView style={layout.scroll}>{children}</ScrollView>
|
|
33
|
+
</html.div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Content({children}: {children: ReactNode}) {
|
|
38
|
+
return <html.div>{children}</html.div>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ScrollbarOrientation = 'vertical' | 'horizontal'
|
|
42
|
+
|
|
43
|
+
/** The platform draws its own scroll indicator. */
|
|
44
|
+
function Scrollbar(_props: {orientation?: ScrollbarOrientation; children?: ReactNode}) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Thumb(_props: {orientation?: ScrollbarOrientation}) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The ScrollView shrinks to its capped parent instead of growing past it.
|
|
53
|
+
const layout = StyleSheet.create({scroll: {flexGrow: 0, flexShrink: 1}})
|
|
54
|
+
|
|
55
|
+
export const ScrollArea = {
|
|
56
|
+
Root,
|
|
57
|
+
Viewport,
|
|
58
|
+
Content,
|
|
59
|
+
Scrollbar,
|
|
60
|
+
Thumb,
|
|
61
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar.',
|
|
5
|
+
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|