achery-ui 0.9.2 → 0.10.2

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": "achery-ui",
3
- "version": "0.9.2",
3
+ "version": "0.10.2",
4
4
  "description": "Achery Workshop design system — autumn alchemy, hard edges, botanical marginalia.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,11 +4,13 @@ import { useTheme } from '../theme/ThemeContext'
4
4
 
5
5
  export type BadgeTone = 'neutral' | 'saved' | 'drafting' | 'stopped' | 'archived'
6
6
  export type BadgeVariant = 'outline' | 'solid'
7
+ export type BadgeSize = 'sm' | 'md' | 'lg'
7
8
 
8
9
  export interface BadgeProps {
9
10
  tone?: BadgeTone
10
11
  variant?: BadgeVariant
11
12
  dot?: boolean
13
+ size?: BadgeSize
12
14
  children: string
13
15
  style?: ViewStyle
14
16
  }
@@ -24,7 +26,9 @@ function toneColors(tone: BadgeTone, variant: BadgeVariant, isDark: boolean) {
24
26
  return map[tone]
25
27
  }
26
28
 
27
- export function Badge({ tone = 'neutral', variant = 'outline', dot = false, children, style }: BadgeProps) {
29
+ const badgeFontSize: Record<BadgeSize, number> = { sm: 10, md: 11, lg: 12 }
30
+
31
+ export function Badge({ tone = 'neutral', variant = 'outline', dot = false, size = 'md', children, style }: BadgeProps) {
28
32
  const { tokens, dark } = useTheme()
29
33
  const colors = toneColors(tone, variant, dark)
30
34
 
@@ -51,7 +55,7 @@ export function Badge({ tone = 'neutral', variant = 'outline', dot = false, chil
51
55
  {dot && (
52
56
  <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: colors.fg }} />
53
57
  )}
54
- <Text style={{ color: fg, fontSize: 11, fontWeight: fontWeights.semibold.toString() as any, letterSpacing: 0.5 }}>
58
+ <Text style={{ color: fg, fontSize: badgeFontSize[size], fontWeight: fontWeights.semibold.toString() as any, letterSpacing: 0.5 }}>
55
59
  {children}
56
60
  </Text>
57
61
  </View>
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from 'react'
1
2
  import { TouchableOpacity, View, Text, StyleSheet, type ViewStyle } from 'react-native'
2
3
  import { spacing, fontWeights } from 'achery-ui/tokens'
3
4
  import { useTheme } from '../theme/ThemeContext'
@@ -9,7 +10,7 @@ export type ButtonSize = 'sm' | 'md' | 'lg'
9
10
  export interface ButtonProps {
10
11
  variant?: ButtonVariant
11
12
  size?: ButtonSize
12
- children: string
13
+ children: ReactNode
13
14
  onPress?: () => void
14
15
  disabled?: boolean
15
16
  style?: ViewStyle
@@ -37,6 +38,14 @@ function makeColors(variant: ButtonVariant, tokens: SemanticTokens) {
37
38
  export function Button({ variant = 'secondary', size = 'md', children, onPress, disabled, style }: ButtonProps) {
38
39
  const { tokens } = useTheme()
39
40
  const colors = makeColors(variant, tokens)
41
+ const textStyle = {
42
+ color: disabled ? tokens.fgMute : colors.label,
43
+ fontSize: fontSize[size],
44
+ fontWeight: fontWeights.semibold.toString() as any,
45
+ letterSpacing: 0.01 * fontSize[size],
46
+ }
47
+
48
+ const isString = typeof children === 'string'
40
49
 
41
50
  return (
42
51
  <TouchableOpacity
@@ -52,20 +61,18 @@ export function Button({ variant = 'secondary', size = 'md', children, onPress,
52
61
  paddingHorizontal: paddingH[size],
53
62
  alignSelf: 'flex-start',
54
63
  opacity: disabled ? 0.5 : 1,
64
+ flexDirection: 'row',
65
+ alignItems: 'center',
66
+ gap: spacing.sp2,
55
67
  },
56
68
  style,
57
69
  ]}
58
70
  >
59
- <Text
60
- style={{
61
- color: disabled ? tokens.fgMute : colors.label,
62
- fontSize: fontSize[size],
63
- fontWeight: fontWeights.semibold.toString() as any,
64
- letterSpacing: 0.01 * fontSize[size],
65
- }}
66
- >
67
- {children}
68
- </Text>
71
+ {isString ? (
72
+ <Text style={textStyle}>{children}</Text>
73
+ ) : (
74
+ children
75
+ )}
69
76
  </TouchableOpacity>
70
77
  )
71
78
  }
@@ -32,7 +32,6 @@ export function Field({ label, hint, error, children, style }: FieldProps) {
32
32
 
33
33
  export interface InputProps extends TextInputProps {
34
34
  error?: boolean
35
- style?: ViewStyle
36
35
  }
37
36
 
38
37
  export function Input({ error, style, ...props }: InputProps) {
@@ -56,3 +55,45 @@ export function Input({ error, style, ...props }: InputProps) {
56
55
  />
57
56
  )
58
57
  }
58
+
59
+ export interface TextareaProps extends TextInputProps {
60
+ error?: boolean
61
+ /** Approximate number of visible lines. @default 4 */
62
+ rows?: number
63
+ }
64
+
65
+ /**
66
+ * Multi-line text input. Passes `multiline` and `textAlignVertical="top"` to
67
+ * the underlying `TextInput`. Wrap in {@link Field} for a label.
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * <Field label="Notes">
72
+ * <Textarea rows={5} placeholder="Notes, checklists, anything…" />
73
+ * </Field>
74
+ * ```
75
+ */
76
+ export function Textarea({ error, rows = 4, style, ...props }: TextareaProps) {
77
+ const { tokens } = useTheme()
78
+ return (
79
+ <TextInput
80
+ multiline
81
+ textAlignVertical="top"
82
+ style={[
83
+ {
84
+ backgroundColor: tokens.bg,
85
+ borderWidth: 1.5,
86
+ borderColor: error ? tokens.danger : tokens.border,
87
+ paddingVertical: spacing.sp3,
88
+ paddingHorizontal: spacing.sp5,
89
+ color: tokens.fg,
90
+ fontSize: 14,
91
+ minHeight: rows * 24,
92
+ },
93
+ style,
94
+ ]}
95
+ placeholderTextColor={tokens.fgMute}
96
+ {...props}
97
+ />
98
+ )
99
+ }
@@ -0,0 +1,111 @@
1
+ import { View, Text, TouchableOpacity } from 'react-native'
2
+ import { useTheme } from '../theme/ThemeContext'
3
+ import { spacing, fontWeights } from 'achery-ui/tokens'
4
+
5
+ export interface ScreenNavProps {
6
+ /** Called when the leading cancel/back button is tapped. */
7
+ onBack: () => void
8
+ /** Label text for the leading button. @default 'cancel' */
9
+ backLabel?: string
10
+ /** Screen title shown centred in the nav bar. */
11
+ title: string
12
+ /** Called when the trailing action button is tapped. */
13
+ onAction?: () => void
14
+ /** Label for the trailing action button. Required when `onAction` is provided. */
15
+ actionLabel?: string
16
+ /** Disables the trailing action button. */
17
+ actionDisabled?: boolean
18
+ }
19
+
20
+ /**
21
+ * Navigation bar for modal push screens (Expo Router `Stack`). Renders a
22
+ * leading cancel/back button, a centred title, and an optional trailing action
23
+ * button. Includes safe-area top padding for iOS.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <ScreenNav
28
+ * onBack={() => router.back()}
29
+ * title="New task"
30
+ * onAction={handleSave}
31
+ * actionLabel="Save"
32
+ * actionDisabled={isPending}
33
+ * />
34
+ * ```
35
+ */
36
+ export const ScreenNav = ({
37
+ onBack,
38
+ backLabel = 'cancel',
39
+ title,
40
+ onAction,
41
+ actionLabel,
42
+ actionDisabled = false,
43
+ }: ScreenNavProps) => {
44
+ const { tokens } = useTheme()
45
+
46
+ return (
47
+ <View
48
+ style={{
49
+ flexDirection: 'row',
50
+ alignItems: 'center',
51
+ justifyContent: 'space-between',
52
+ paddingHorizontal: spacing.sp4,
53
+ paddingTop: 56,
54
+ paddingBottom: spacing.sp3,
55
+ backgroundColor: tokens.surface,
56
+ borderBottomWidth: 1,
57
+ borderBottomColor: tokens.border,
58
+ }}
59
+ >
60
+ <TouchableOpacity
61
+ onPress={onBack}
62
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
63
+ style={{ minWidth: 48 }}
64
+ >
65
+ <Text
66
+ style={{
67
+ color: tokens.fg3,
68
+ fontSize: 12,
69
+ fontWeight: fontWeights.semibold.toString() as any,
70
+ letterSpacing: 0.3,
71
+ }}
72
+ >
73
+ {backLabel}
74
+ </Text>
75
+ </TouchableOpacity>
76
+
77
+ <Text
78
+ style={{
79
+ color: tokens.fgMute,
80
+ fontSize: 11,
81
+ fontWeight: fontWeights.semibold.toString() as any,
82
+ letterSpacing: 1.5,
83
+ textTransform: 'uppercase',
84
+ }}
85
+ >
86
+ {title}
87
+ </Text>
88
+
89
+ {onAction && actionLabel ? (
90
+ <TouchableOpacity
91
+ onPress={actionDisabled ? undefined : onAction}
92
+ disabled={actionDisabled}
93
+ hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
94
+ style={{ minWidth: 48, alignItems: 'flex-end' }}
95
+ >
96
+ <Text
97
+ style={{
98
+ color: actionDisabled ? tokens.fgMute : tokens.accent,
99
+ fontSize: 12,
100
+ fontWeight: fontWeights.bold.toString() as any,
101
+ }}
102
+ >
103
+ {actionLabel}
104
+ </Text>
105
+ </TouchableOpacity>
106
+ ) : (
107
+ <View style={{ minWidth: 48 }} />
108
+ )}
109
+ </View>
110
+ )
111
+ }
@@ -0,0 +1,81 @@
1
+ import { View, TouchableOpacity, Text } from 'react-native'
2
+ import type { ReactNode } from 'react'
3
+ import { useTheme } from '../theme/ThemeContext'
4
+ import { spacing, fontWeights } from 'achery-ui/tokens'
5
+
6
+ /** A single option within a native {@link SegmentedControl}. */
7
+ export interface NativeSegmentOption<T extends string = string> {
8
+ value: T
9
+ label: ReactNode
10
+ }
11
+
12
+ export interface SegmentedControlProps<T extends string = string> {
13
+ options: NativeSegmentOption<T>[]
14
+ value: T
15
+ onChange: (value: T) => void
16
+ disabled?: boolean
17
+ }
18
+
19
+ /**
20
+ * Inline button group where exactly one option is active.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <SegmentedControl
25
+ * options={[{ value: 'hard', label: 'Hard' }, { value: 'soft', label: 'Soft' }]}
26
+ * value={depType}
27
+ * onChange={setDepType}
28
+ * />
29
+ * ```
30
+ */
31
+ export const SegmentedControl = <T extends string>({
32
+ options,
33
+ value,
34
+ onChange,
35
+ disabled = false,
36
+ }: SegmentedControlProps<T>) => {
37
+ const { tokens } = useTheme()
38
+
39
+ return (
40
+ <View style={{ flexDirection: 'row', borderWidth: 1, borderColor: tokens.border }}>
41
+ {options.map((opt, i) => {
42
+ const isActive = opt.value === value
43
+ const isLast = i === options.length - 1
44
+ return (
45
+ <TouchableOpacity
46
+ key={opt.value}
47
+ onPress={() => !disabled && onChange(opt.value)}
48
+ activeOpacity={0.7}
49
+ style={{
50
+ flex: 1,
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ paddingVertical: spacing.sp2,
54
+ paddingHorizontal: spacing.sp3,
55
+ backgroundColor: isActive ? tokens.fg : tokens.surface,
56
+ borderRightWidth: isLast ? 0 : 1,
57
+ borderRightColor: tokens.border,
58
+ opacity: disabled ? 0.4 : 1,
59
+ }}
60
+ >
61
+ {typeof opt.label === 'string' ? (
62
+ <Text
63
+ style={{
64
+ color: isActive ? tokens.bg : tokens.fg3,
65
+ fontSize: 10,
66
+ fontWeight: fontWeights.semibold.toString() as any,
67
+ letterSpacing: 0.8,
68
+ textTransform: 'uppercase',
69
+ }}
70
+ >
71
+ {opt.label}
72
+ </Text>
73
+ ) : (
74
+ opt.label
75
+ )}
76
+ </TouchableOpacity>
77
+ )
78
+ })}
79
+ </View>
80
+ )
81
+ }
@@ -60,7 +60,7 @@ export const Skeleton = ({ lines = 3, width, block = false, height = 80, style }
60
60
  {Array.from({ length: lines }).map((_, i) => (
61
61
  <Animated.View
62
62
  key={i}
63
- style={[baseStyle, { height: LINE_HEIGHT, width: lineWidths[i] ?? '100%' }, { opacity }]}
63
+ style={[baseStyle, { height: LINE_HEIGHT, width: (lineWidths[i] ?? '100%') as any }, { opacity }]}
64
64
  />
65
65
  ))}
66
66
  </View>
@@ -0,0 +1,53 @@
1
+ import { View } from 'react-native'
2
+ import type { BadgeTone } from './Badge'
3
+ import { useTheme } from '../theme/ThemeContext'
4
+ import { palette } from 'achery-ui/tokens'
5
+
6
+ export interface StatusDotProps {
7
+ /** Semantic tone — maps to the same colours as {@link Badge}. */
8
+ tone?: BadgeTone
9
+ /**
10
+ * Diameter in dp.
11
+ * @default 8
12
+ */
13
+ size?: number
14
+ }
15
+
16
+ const TONE_COLOR: Record<BadgeTone, (isDark: boolean) => string> = {
17
+ neutral: (isDark) => isDark ? palette.cream : palette.ink,
18
+ saved: () => palette.success,
19
+ drafting: () => palette.ochre,
20
+ stopped: () => palette.rust,
21
+ archived: (isDark) => isDark ? palette.silver : palette.silverDeep,
22
+ }
23
+
24
+ /**
25
+ * Small filled dot conveying semantic status via the same tone palette as
26
+ * {@link Badge}. Use where a full badge label would be too heavy.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
31
+ * <StatusDot tone="drafting" />
32
+ * <Text>In progress</Text>
33
+ * </View>
34
+ * ```
35
+ */
36
+ export const StatusDot = ({ tone = 'neutral', size = 8 }: StatusDotProps) => {
37
+ const { dark } = useTheme()
38
+ const color = TONE_COLOR[tone](dark)
39
+ const isNeutral = tone === 'neutral'
40
+
41
+ return (
42
+ <View
43
+ style={{
44
+ width: size,
45
+ height: size,
46
+ borderRadius: size / 2,
47
+ backgroundColor: isNeutral ? 'transparent' : color,
48
+ borderWidth: isNeutral ? 1.5 : 0,
49
+ borderColor: color,
50
+ }}
51
+ />
52
+ )
53
+ }
@@ -1,6 +1,6 @@
1
- import { useState } from 'react'
1
+ import { useState, type ReactNode } from 'react'
2
2
  import { ScrollView, TouchableOpacity, View, Text } from 'react-native'
3
- import type { ViewStyle, ReactNode } from 'react-native'
3
+ import type { ViewStyle } from 'react-native'
4
4
  import { spacing, fontWeights } from 'achery-ui/tokens'
5
5
  import { useTheme } from '../theme/ThemeContext'
6
6
 
@@ -10,8 +10,8 @@ export type { CardProps, CardVariant, CardPadding } from './Card'
10
10
  export { Badge } from './Badge'
11
11
  export type { BadgeProps, BadgeTone, BadgeVariant } from './Badge'
12
12
 
13
- export { Field, Input } from './Input'
14
- export type { FieldProps, InputProps } from './Input'
13
+ export { Field, Input, Textarea } from './Input'
14
+ export type { FieldProps, InputProps, TextareaProps } from './Input'
15
15
 
16
16
  export { MaterialCard, MaterialEyebrow } from './MaterialCard'
17
17
  export type { NativeMaterialCardProps, MaterialIntensity } from './MaterialCard'
@@ -39,3 +39,12 @@ export type { TabsProps, TabItem } from './Tabs'
39
39
 
40
40
  export { ToastProvider, useToast } from './Toast'
41
41
  export type { ToastProviderProps, ToastData, ToastOptions } from './Toast'
42
+
43
+ export { StatusDot } from './StatusDot'
44
+ export type { StatusDotProps } from './StatusDot'
45
+
46
+ export { SegmentedControl } from './SegmentedControl'
47
+ export type { SegmentedControlProps, NativeSegmentOption } from './SegmentedControl'
48
+
49
+ export { ScreenNav } from './ScreenNav'
50
+ export type { ScreenNavProps } from './ScreenNav'
@@ -2,17 +2,18 @@ export { NativeThemeProvider, useTheme } from './theme/ThemeContext'
2
2
  export type { NativeThemeProviderProps, NativeThemeContextValue } from './theme/ThemeContext'
3
3
 
4
4
  export {
5
- Text, Button, Card, Badge, Field, Input, MaterialCard, MaterialEyebrow,
5
+ Text, Button, Card, Badge, Field, Input, Textarea, MaterialCard, MaterialEyebrow,
6
6
  Glyph, GlyphPicker,
7
7
  Skeleton, ProgressBar, Checkbox, Toggle, Tabs,
8
8
  ToastProvider, useToast,
9
+ StatusDot, SegmentedControl, ScreenNav,
9
10
  } from './components/index'
10
11
  export type {
11
12
  TextProps,
12
13
  ButtonProps, ButtonVariant, ButtonSize,
13
14
  CardProps, CardVariant, CardPadding,
14
15
  BadgeProps, BadgeTone, BadgeVariant,
15
- FieldProps, InputProps,
16
+ FieldProps, InputProps, TextareaProps,
16
17
  NativeMaterialCardProps, MaterialIntensity,
17
18
  NativeGlyphProps,
18
19
  GlyphPickerProps,
@@ -22,6 +23,9 @@ export type {
22
23
  ToggleProps,
23
24
  TabsProps, TabItem,
24
25
  ToastProviderProps, ToastData, ToastOptions,
26
+ StatusDotProps,
27
+ SegmentedControlProps, NativeSegmentOption,
28
+ ScreenNavProps,
25
29
  } from './components/index'
26
30
 
27
31
  // Glyph utilities — pure TS, work on native