@ankhorage/zora 4.4.0 → 4.5.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 (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/components/card/meta.d.ts +1 -1
  4. package/dist/components/radio/RadioGroup.d.ts +1 -3
  5. package/dist/components/radio/RadioGroup.d.ts.map +1 -1
  6. package/dist/components/radio/RadioGroup.js +52 -40
  7. package/dist/components/radio/RadioGroup.js.map +1 -1
  8. package/dist/components/radio/RadioOptionControl.d.ts +15 -0
  9. package/dist/components/radio/RadioOptionControl.d.ts.map +1 -0
  10. package/dist/components/radio/RadioOptionControl.js +105 -0
  11. package/dist/components/radio/RadioOptionControl.js.map +1 -0
  12. package/dist/components/radio/handleRadioOptionKeyDown.d.ts +13 -0
  13. package/dist/components/radio/handleRadioOptionKeyDown.d.ts.map +1 -0
  14. package/dist/components/radio/handleRadioOptionKeyDown.js +22 -0
  15. package/dist/components/radio/handleRadioOptionKeyDown.js.map +1 -0
  16. package/dist/components/radio/meta.d.ts +38 -3
  17. package/dist/components/radio/meta.d.ts.map +1 -1
  18. package/dist/components/radio/meta.js +28 -3
  19. package/dist/components/radio/meta.js.map +1 -1
  20. package/dist/components/radio/resolveRadioOptionColors.d.ts +10 -0
  21. package/dist/components/radio/resolveRadioOptionColors.d.ts.map +1 -0
  22. package/dist/components/radio/resolveRadioOptionColors.js +27 -0
  23. package/dist/components/radio/resolveRadioOptionColors.js.map +1 -0
  24. package/dist/components/radio/types.d.ts +12 -2
  25. package/dist/components/radio/types.d.ts.map +1 -1
  26. package/dist/components/radio/types.js.map +1 -1
  27. package/dist/constants/keyboard.d.ts +3 -0
  28. package/dist/constants/keyboard.d.ts.map +1 -0
  29. package/dist/constants/keyboard.js +8 -0
  30. package/dist/constants/keyboard.js.map +1 -0
  31. package/dist/foundation/meta.d.ts +4 -4
  32. package/dist/layout/screen/meta.d.ts +1 -1
  33. package/dist/layout/screen-section/meta.d.ts +1 -1
  34. package/dist/metadata/allowedChildren.d.ts +3 -3
  35. package/dist/metadata/allowedChildren.d.ts.map +1 -1
  36. package/dist/metadata/allowedChildren.js +1 -0
  37. package/dist/metadata/allowedChildren.js.map +1 -1
  38. package/dist/metadata/bindableComponentMeta.d.ts +2 -2
  39. package/dist/patterns/message-bubble/meta.d.ts +1 -1
  40. package/dist/patterns/notice/meta.d.ts +1 -1
  41. package/dist/patterns/panel/meta.d.ts +1 -1
  42. package/dist/patterns/post-card/meta.d.ts +1 -1
  43. package/dist/patterns/reader/meta.d.ts +2 -2
  44. package/dist/patterns/scanner/meta.d.ts +2 -2
  45. package/package.json +1 -1
  46. package/src/components/radio/RadioGroup.test.ts +13 -9
  47. package/src/components/radio/RadioGroup.tsx +85 -111
  48. package/src/components/radio/RadioOptionControl.tsx +192 -0
  49. package/src/components/radio/handleRadioOptionKeyDown.test.ts +47 -0
  50. package/src/components/radio/handleRadioOptionKeyDown.ts +37 -0
  51. package/src/components/radio/meta.ts +28 -3
  52. package/src/components/radio/resolveRadioOptionColors.test.ts +29 -0
  53. package/src/components/radio/resolveRadioOptionColors.ts +37 -0
  54. package/src/components/radio/types.ts +10 -2
  55. package/src/constants/keyboard.ts +7 -0
  56. package/src/metadata/allowedChildren.ts +1 -0
  57. package/src/metadata/componentMeta.test.ts +1 -0
  58. package/src/metadata/radioGroupReachability.test.ts +20 -0
@@ -1,145 +1,117 @@
1
- import { Radio, useTheme } from '@ankhorage/surface';
1
+ import { Radio } from '@ankhorage/surface';
2
2
  import React from 'react';
3
- import { View } from 'react-native';
3
+ import { StyleSheet, View } from 'react-native';
4
4
 
5
5
  import { Stack } from '../../foundation';
6
6
  import { useZoraThemeRecipe } from '../../theme/useZoraThemeRecipe';
7
7
  import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
8
8
  import { Text } from '../text';
9
+ import { RadioOptionControl } from './RadioOptionControl';
9
10
  import { resolveRadioGroupThemeRecipe } from './resolveRadioGroupThemeRecipe';
10
- import type { RadioGroupOption, RadioGroupProps } from './types';
11
+ import type { RadioGroupProps } from './types';
11
12
 
12
- /***
13
- * Resolves the themed radio-group presentation within the active ZORA scope.
14
- */
13
+ /*** Present one controlled single-choice group as inline radios or icon radio options. */
14
+ export const RadioGroup = withZoraThemeScope(RadioGroupInner);
15
+
16
+ /*** Resolve shared presentation once and preserve a single interaction boundary per option. */
15
17
  function RadioGroupInner<TValue extends string>({
16
18
  themeId: _themeId,
17
19
  mode: _mode,
18
- value,
19
- onValueChange,
20
- options,
21
- orientation = 'vertical',
22
- gap,
23
- presentation = 'inline',
24
- color,
25
- size,
26
- invalid = false,
27
- readOnly = false,
28
- disabled = false,
29
- testID,
30
- interactionPolicy,
20
+ ...props
31
21
  }: RadioGroupProps<TValue>) {
32
- const isHorizontal = orientation === 'horizontal';
22
+ const [localValue, setLocalValue] = React.useState(props.defaultValue);
23
+ const onValueChange = (next: TValue) => {
24
+ if (props.value === undefined) setLocalValue(next);
25
+ props.onValueChange?.(next);
26
+ };
27
+ const selectedProps = { ...props, value: props.value ?? localValue, onValueChange };
33
28
  const themeFields = useZoraThemeRecipe('RadioGroup');
34
- const recipe = resolveRadioGroupThemeRecipe({ gap, color, size, themeFields });
29
+ const recipe = resolveRadioGroupThemeRecipe({ ...props, themeFields });
30
+ if (props.presentation !== 'card') return <RadioGroupInline {...selectedProps} {...recipe} />;
31
+ const columns =
32
+ props.columns ?? (props.orientation === 'horizontal' ? Math.max(1, props.options.length) : 1);
33
+ const rows = Array.from({ length: Math.ceil(props.options.length / columns) }, (_, index) =>
34
+ props.options.slice(index * columns, (index + 1) * columns),
35
+ );
36
+ return (
37
+ <View testID={props.testID} accessibilityRole="radiogroup">
38
+ <Stack gap={recipe.gap}>
39
+ {rows.map((options, index) => (
40
+ <Stack key={index} direction="row" gap={recipe.gap}>
41
+ {options.map((option) => (
42
+ <View key={option.value} style={styles.option}>
43
+ <RadioGroupItem {...selectedProps} {...recipe} option={option} />
44
+ </View>
45
+ ))}
46
+ {Array.from({ length: columns - options.length }, (_, slot) => (
47
+ <View key={`empty-${slot}`} style={styles.option} />
48
+ ))}
49
+ </Stack>
50
+ ))}
51
+ </Stack>
52
+ </View>
53
+ );
54
+ }
35
55
 
56
+ /*** Preserve ordinary radio wrapping independently of the icon-option grid. */
57
+ function RadioGroupInline<TValue extends string>(props: RadioGroupProps<TValue>) {
36
58
  return (
37
- <View
38
- testID={testID}
39
- accessibilityRole="radiogroup"
40
- style={{
41
- flexDirection: isHorizontal ? 'row' : 'column',
42
- flexWrap: isHorizontal ? 'wrap' : 'nowrap',
43
- }}
44
- >
59
+ <View testID={props.testID} accessibilityRole="radiogroup">
45
60
  <Stack
46
- direction={isHorizontal ? 'row' : 'column'}
47
- gap={recipe.gap}
48
- wrap={isHorizontal ? 'wrap' : 'nowrap'}
61
+ direction={props.orientation === 'horizontal' ? 'row' : 'column'}
62
+ gap={props.gap}
63
+ wrap="wrap"
49
64
  >
50
- {options.map((option) => (
51
- <RadioGroupItem
52
- key={option.value}
53
- option={option}
54
- checked={value === option.value}
55
- disabled={disabled || option.disabled === true}
56
- invalid={invalid}
57
- readOnly={readOnly}
58
- size={recipe.size}
59
- color={recipe.color}
60
- orientation={orientation}
61
- presentation={presentation}
62
- onSelect={onValueChange}
63
- interactionPolicy={interactionPolicy}
64
- />
65
+ {props.options.map((option) => (
66
+ <RadioGroupItem key={option.value} {...props} option={option} />
65
67
  ))}
66
68
  </Stack>
67
69
  </View>
68
70
  );
69
71
  }
70
72
 
71
- /***
72
- * Renders a group of radio options for selecting a single value.
73
- */
74
- export const RadioGroup = withZoraThemeScope(RadioGroupInner);
75
-
76
- /***
77
- * Renders one option through the single Surface Radio interaction boundary.
78
- */
79
- function RadioGroupItem<TValue extends string>({
80
- option,
81
- checked,
82
- disabled,
83
- invalid,
84
- readOnly,
85
- size,
86
- color,
87
- orientation,
88
- presentation,
89
- onSelect,
90
- interactionPolicy,
91
- }: {
92
- option: RadioGroupOption<TValue>;
93
- checked: boolean;
94
- disabled: boolean;
95
- invalid: boolean;
96
- readOnly: boolean;
97
- size: NonNullable<RadioGroupProps<TValue>['size']>;
98
- color: NonNullable<RadioGroupProps<TValue>['color']>;
99
- orientation: NonNullable<RadioGroupProps<TValue>['orientation']>;
100
- presentation: NonNullable<RadioGroupProps<TValue>['presentation']>;
101
- onSelect: (value: TValue) => void;
102
- interactionPolicy: RadioGroupProps<TValue>['interactionPolicy'];
103
- }) {
104
- const { theme } = useTheme();
105
- const passive = interactionPolicy === 'passive';
106
- const isCard = presentation === 'card';
107
-
73
+ /*** Delegate icon options to their composed radio boundary and ordinary options to Surface Radio. */
74
+ function RadioGroupItem<TValue extends string>(
75
+ props: RadioGroupProps<TValue> & {
76
+ option: RadioGroupProps<TValue>['options'][number];
77
+ },
78
+ ) {
79
+ const { option, value, onValueChange, interactionPolicy, color, size } = props;
80
+ const checked = value === option.value;
81
+ const inactive = props.disabled === true || option.disabled === true;
82
+ const readOnly = props.readOnly === true;
83
+ const invalid = props.invalid === true;
84
+ const onSelect = () => {
85
+ if (!inactive && !readOnly && interactionPolicy !== 'passive' && !checked)
86
+ onValueChange?.(option.value);
87
+ };
88
+ if (props.presentation === 'card') {
89
+ return (
90
+ <RadioOptionControl
91
+ {...props}
92
+ checked={checked}
93
+ disabled={inactive}
94
+ readOnly={readOnly}
95
+ invalid={invalid}
96
+ layout={props.contentOrientation ?? 'horizontal'}
97
+ onSelect={onSelect}
98
+ />
99
+ );
100
+ }
108
101
  return (
109
102
  <Radio
110
- interactionPolicy={interactionPolicy}
111
103
  checked={checked}
112
- disabled={disabled}
113
- invalid={invalid}
104
+ disabled={inactive}
114
105
  readOnly={readOnly}
115
- size={size}
106
+ invalid={invalid}
107
+ interactionPolicy={interactionPolicy}
116
108
  color={color}
109
+ size={size}
117
110
  testID={option.testID}
118
- bg={
119
- isCard
120
- ? checked
121
- ? theme.semantics.selection.background
122
- : theme.semantics.surface.default
123
- : undefined
124
- }
125
- borderColor={
126
- isCard
127
- ? checked
128
- ? theme.semantics.selection.border
129
- : theme.semantics.border.default
130
- : undefined
131
- }
132
- borderWidth={isCard ? 1 : undefined}
133
- p={isCard ? 'm' : undefined}
134
- radius={isCard ? 'l' : undefined}
135
- width={isCard && orientation === 'vertical' ? '100%' : undefined}
136
- onCheckedChange={(nextChecked) => {
137
- if (passive) return;
138
- if (nextChecked) onSelect(option.value);
139
- }}
111
+ onCheckedChange={onSelect}
140
112
  >
141
113
  <Stack gap="xs">
142
- <Text weight={isCard ? 'semiBold' : undefined}>{option.label}</Text>
114
+ <Text>{option.label}</Text>
143
115
  {option.description ? (
144
116
  <Text emphasis="muted" variant="caption">
145
117
  {option.description}
@@ -149,3 +121,5 @@ function RadioGroupItem<TValue extends string>({
149
121
  </Radio>
150
122
  );
151
123
  }
124
+
125
+ const styles = StyleSheet.create({ option: { flex: 1, minWidth: 0 } });
@@ -0,0 +1,192 @@
1
+ import { useTheme } from '@ankhorage/surface';
2
+ import React from 'react';
3
+ import { Platform, Pressable, StyleSheet, View } from 'react-native';
4
+
5
+ import { Icon } from '../icon';
6
+ import { Text } from '../text';
7
+ import {
8
+ handleRadioOptionKeyDown,
9
+ type RadioOptionKeyboardTarget,
10
+ } from './handleRadioOptionKeyDown';
11
+ import { resolveRadioOptionColors } from './resolveRadioOptionColors';
12
+ import type { RadioGroupProps } from './types';
13
+
14
+ /*** Render a selectable icon radio option with one accessible radio target and a trailing check. */
15
+ export function RadioOptionControl<TValue extends string>(props: RadioOptionControlProps<TValue>) {
16
+ const { theme } = useTheme();
17
+ const { option, checked, disabled, readOnly, interactionPolicy, onSelect } = props;
18
+ const colors = resolveRadioOptionColors(theme, props);
19
+ const [focused, setFocused] = React.useState(false);
20
+ const keyboardProps =
21
+ Platform.OS === 'web' && !readOnly && !disabled && interactionPolicy !== 'passive'
22
+ ? {
23
+ onKeyDown: (event: React.KeyboardEvent<RadioOptionKeyboardTarget>) =>
24
+ handleRadioOptionKeyDown(event, onSelect),
25
+ }
26
+ : {};
27
+ const controlStyle = {
28
+ flex: 1,
29
+ width: '100%' as const,
30
+ minHeight: props.size === 'l' ? 84 : 44,
31
+ borderWidth: checked || focused ? 2 : 1,
32
+ borderColor: focused ? theme.semantics.border.focus : checked ? colors.accent : colors.border,
33
+ borderRadius: theme.radii.l,
34
+ backgroundColor: colors.surface,
35
+ };
36
+ return (
37
+ <Pressable
38
+ {...keyboardProps}
39
+ accessibilityRole="radio"
40
+ accessibilityState={{ checked, disabled }}
41
+ aria-checked={checked}
42
+ aria-disabled={disabled}
43
+ aria-readonly={readOnly}
44
+ disabled={disabled}
45
+ onPress={readOnly || interactionPolicy === 'passive' ? undefined : onSelect}
46
+ onFocus={() => setFocused(true)}
47
+ onBlur={() => setFocused(false)}
48
+ testID={option.testID}
49
+ style={controlStyle}
50
+ >
51
+ {(state) => <RadioOptionControlBody {...props} pressed={state.pressed} />}
52
+ </Pressable>
53
+ );
54
+ }
55
+
56
+ /*** Arrange the icon, label and passive indicator within the native radio press target. */
57
+ function RadioOptionControlBody<TValue extends string>(
58
+ props: RadioOptionControlProps<TValue> & { pressed: boolean },
59
+ ) {
60
+ const { theme } = useTheme();
61
+ const colors = resolveRadioOptionColors(theme, props);
62
+ const contentStyle = {
63
+ padding: props.size === 's' ? theme.spacing.s : props.size === 'm' ? 12 : theme.spacing.m,
64
+ gap:
65
+ props.layout === 'vertical' || !props.option.iconSource ? theme.spacing.s : theme.spacing.m,
66
+ ...(!props.option.iconSource ? { flexDirection: 'row-reverse' as const } : {}),
67
+ opacity: props.disabled ? 0.5 : props.pressed ? 0.7 : 1,
68
+ };
69
+ return (
70
+ <View
71
+ style={[
72
+ styles.content,
73
+ props.layout === 'vertical' ? styles.vertical : styles.horizontal,
74
+ contentStyle,
75
+ ]}
76
+ >
77
+ <RadioOptionControlContent {...props} />
78
+ {props.option.iconSource || props.checked ? (
79
+ <View style={props.layout === 'vertical' ? styles.corner : undefined}>
80
+ <RadioOptionControlIndicator
81
+ checked={props.checked}
82
+ accent={colors.accent}
83
+ onAccent={colors.onAccent}
84
+ />
85
+ </View>
86
+ ) : null}
87
+ </View>
88
+ );
89
+ }
90
+
91
+ interface RadioOptionControlProps<TValue extends string> extends Pick<
92
+ RadioGroupProps<TValue>,
93
+ 'color' | 'size' | 'interactionPolicy'
94
+ > {
95
+ option: RadioGroupProps<TValue>['options'][number];
96
+ checked: boolean;
97
+ disabled: boolean;
98
+ readOnly: boolean;
99
+ invalid: boolean;
100
+ layout: 'horizontal' | 'vertical';
101
+ onSelect: () => void;
102
+ }
103
+
104
+ /*** Keep decoration passive while allowing labels to wrap at narrow widths and large text sizes. */
105
+ function RadioOptionControlContent<TValue extends string>({
106
+ option,
107
+ layout,
108
+ size,
109
+ color,
110
+ invalid,
111
+ disabled,
112
+ }: RadioOptionControlProps<TValue>) {
113
+ const { theme } = useTheme();
114
+ const colors = resolveRadioOptionColors(theme, { color, invalid, disabled });
115
+ return (
116
+ <>
117
+ {option.iconSource ? (
118
+ <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
119
+ <Icon source={option.iconSource} size={size === 'l' ? 36 : 28} color={colors.accent} />
120
+ </View>
121
+ ) : null}
122
+ <View style={layout === 'horizontal' ? styles.label : styles.centerLabel}>
123
+ <Text
124
+ variant={size === 'l' ? 'body' : 'bodySmall'}
125
+ weight="semiBold"
126
+ align={layout === 'vertical' || !option.iconSource ? 'center' : 'left'}
127
+ >
128
+ {option.label}
129
+ </Text>
130
+ {option.description ? (
131
+ <Text
132
+ emphasis="muted"
133
+ variant="bodySmall"
134
+ align={layout === 'vertical' || !option.iconSource ? 'center' : 'left'}
135
+ >
136
+ {option.description}
137
+ </Text>
138
+ ) : null}
139
+ </View>
140
+ </>
141
+ );
142
+ }
143
+
144
+ /*** Draw a passive check using themed geometry without requiring an application media asset. */
145
+ function RadioOptionControlIndicator({
146
+ checked,
147
+ accent,
148
+ onAccent,
149
+ }: {
150
+ checked: boolean;
151
+ accent: string;
152
+ onAccent: string;
153
+ }) {
154
+ const indicatorStyle = { backgroundColor: checked ? accent : 'transparent' };
155
+ return (
156
+ <View
157
+ accessibilityElementsHidden
158
+ importantForAccessibility="no-hide-descendants"
159
+ style={[styles.indicator, indicatorStyle]}
160
+ >
161
+ {checked ? <View style={[styles.check, { borderColor: onAccent }]} /> : null}
162
+ </View>
163
+ );
164
+ }
165
+
166
+ const styles = StyleSheet.create({
167
+ content: { gap: 16, flex: 1, minWidth: 0 },
168
+ horizontal: { flexDirection: 'row', alignItems: 'center' },
169
+ vertical: {
170
+ flexDirection: 'column',
171
+ alignItems: 'center',
172
+ justifyContent: 'center',
173
+ paddingTop: 24,
174
+ },
175
+ label: { flex: 1, minWidth: 0, gap: 4 },
176
+ centerLabel: { alignSelf: 'stretch', gap: 4 },
177
+ corner: { position: 'absolute', right: 8, top: 8 },
178
+ indicator: {
179
+ width: 24,
180
+ height: 24,
181
+ borderRadius: 12,
182
+ alignItems: 'center',
183
+ justifyContent: 'center',
184
+ },
185
+ check: {
186
+ width: 7,
187
+ height: 12,
188
+ borderRightWidth: 2.5,
189
+ borderBottomWidth: 2.5,
190
+ transform: [{ rotate: '45deg' }, { translateY: -2 }],
191
+ },
192
+ });
@@ -0,0 +1,47 @@
1
+ import { expect, test } from 'bun:test';
2
+ import type { KeyboardEvent } from 'react';
3
+
4
+ import { DIRECTION_MODIFIERS } from '../../constants/keyboard';
5
+ import {
6
+ handleRadioOptionKeyDown,
7
+ type RadioOptionKeyboardTarget,
8
+ } from './handleRadioOptionKeyDown';
9
+
10
+ test('Space selects the focused radio and prevents page scrolling', () => {
11
+ let selected = 0;
12
+ let prevented = 0;
13
+ handleRadioOptionKeyDown(
14
+ { key: ' ', preventDefault: () => prevented++ } as KeyboardEvent<RadioOptionKeyboardTarget>,
15
+ () => selected++,
16
+ );
17
+ expect(selected).toBe(1);
18
+ expect(prevented).toBe(1);
19
+ });
20
+
21
+ for (const [key, direction] of DIRECTION_MODIFIERS) {
22
+ test(`${key} wraps and skips disabled radio options`, () => {
23
+ const activated: number[] = [];
24
+ const focused: number[] = [];
25
+ let prevented = 0;
26
+ const options: RadioOptionKeyboardTarget[] = [0, 1, 2, 3].map((index) => ({
27
+ closest: () => ({ querySelectorAll: () => options }),
28
+ getAttribute: () => (index === 1 ? 'true' : 'false'),
29
+ focus: () => focused.push(index),
30
+ click: () => activated.push(index),
31
+ }));
32
+ for (const currentTarget of [options[0], options[2], options[3]]) {
33
+ handleRadioOptionKeyDown(
34
+ {
35
+ key,
36
+ currentTarget,
37
+ preventDefault: () => prevented++,
38
+ } as KeyboardEvent<RadioOptionKeyboardTarget>,
39
+ () => undefined,
40
+ );
41
+ }
42
+ const expected = direction === 1 ? [2, 3, 0] : [3, 0, 2];
43
+ expect(focused).toEqual(expected);
44
+ expect(activated).toEqual(expected);
45
+ expect(prevented).toBe(3);
46
+ });
47
+ }
@@ -0,0 +1,37 @@
1
+ import type { KeyboardEvent } from 'react';
2
+
3
+ import { DIRECTION_MODIFIERS } from '../../constants/keyboard';
4
+
5
+ /*** Support Space and directional navigation for web radio options without affecting native presses. */
6
+ export function handleRadioOptionKeyDown(
7
+ event: KeyboardEvent<RadioOptionKeyboardTarget>,
8
+ select: () => void,
9
+ ): void {
10
+ if (event.key === ' ') {
11
+ event.preventDefault();
12
+ select();
13
+ return;
14
+ }
15
+ const direction = DIRECTION_MODIFIERS.get(event.key);
16
+ if (direction === undefined) return;
17
+ const group = event.currentTarget.closest('[role="radiogroup"]');
18
+ const options = Array.from(group?.querySelectorAll('[role="radio"]') ?? []).filter(
19
+ (option) => option.getAttribute('aria-disabled') !== 'true',
20
+ );
21
+ const index = options.indexOf(event.currentTarget);
22
+ if (index < 0 || options.length === 0) return;
23
+ event.preventDefault();
24
+ const next = options.at((index + direction + options.length) % options.length);
25
+ next?.focus();
26
+ next?.click();
27
+ }
28
+
29
+ /** Minimal web target contract keeps the native build independent of DOM ambient libraries. */
30
+ export interface RadioOptionKeyboardTarget {
31
+ closest(
32
+ selector: string,
33
+ ): { querySelectorAll(selector: string): ArrayLike<RadioOptionKeyboardTarget> } | null;
34
+ getAttribute(name: string): string | null;
35
+ focus(): void;
36
+ click(): void;
37
+ }
@@ -24,16 +24,16 @@ export const radioGroupMeta = {
24
24
  directManifestNode: true,
25
25
  allowedChildren: [],
26
26
  blueprint: {
27
- label: 'Radio group',
27
+ label: 'Radio button group',
28
28
  defaultProps: {
29
- value: 'option-b',
29
+ defaultValue: 'option-b',
30
30
  options: [
31
31
  { value: 'option-a', label: 'First option', description: 'Supporting detail' },
32
32
  { value: 'option-b', label: 'Second option', description: 'Supporting detail' },
33
33
  { value: 'option-c', label: 'Third option', description: 'Supporting detail' },
34
34
  ],
35
35
  orientation: 'vertical',
36
- presentation: 'inline',
36
+ presentation: 'card',
37
37
  invalid: false,
38
38
  readOnly: false,
39
39
  disabled: false,
@@ -76,6 +76,12 @@ export const radioGroupMeta = {
76
76
  },
77
77
  },
78
78
  props: {
79
+ defaultValue: {
80
+ type: 'string',
81
+ category: 'State',
82
+ label: 'Initial value',
83
+ authoring: { authority: 'instance' },
84
+ },
79
85
  value: {
80
86
  type: 'string',
81
87
  category: 'State',
@@ -111,6 +117,10 @@ export const radioGroupMeta = {
111
117
  label: 'Description',
112
118
  },
113
119
  },
120
+ {
121
+ key: 'iconSource',
122
+ schema: { type: 'media', category: 'Content', label: 'Icon', mediaKinds: ['image'] },
123
+ },
114
124
  {
115
125
  key: 'disabled',
116
126
  schema: {
@@ -146,6 +156,21 @@ export const radioGroupMeta = {
146
156
  default: 'inline',
147
157
  authoring: { authority: 'instance' },
148
158
  },
159
+ contentOrientation: {
160
+ type: 'enum',
161
+ category: 'Layout',
162
+ label: 'Option content orientation',
163
+ enum: ['horizontal', 'vertical'],
164
+ default: 'horizontal',
165
+ authoring: { authority: 'instance' },
166
+ },
167
+ columns: {
168
+ type: 'enum',
169
+ category: 'Layout',
170
+ label: 'Option columns',
171
+ enum: [1, 2, 3, 4],
172
+ authoring: { authority: 'instance' },
173
+ },
149
174
  color: {
150
175
  type: 'enum',
151
176
  category: 'Style',
@@ -0,0 +1,29 @@
1
+ import { createTheme } from '@ankhorage/surface/theme';
2
+ import { expect, test } from 'bun:test';
3
+
4
+ import { resolveRadioOptionColors } from './resolveRadioOptionColors';
5
+
6
+ const source = {
7
+ id: 'radio',
8
+ name: 'Radio',
9
+ light: { primaryColor: '#0060FF', harmony: 'monochromatic' as const },
10
+ dark: { primaryColor: '#0060FF', harmony: 'monochromatic' as const },
11
+ };
12
+
13
+ for (const mode of ['light', 'dark'] as const) {
14
+ test(`radio selection preserves role contrast and field states in ${mode} mode`, () => {
15
+ const theme = createTheme(source, mode);
16
+ const selected = resolveRadioOptionColors(theme, { color: 'secondary' });
17
+ expect(selected.accent).toBe(theme.semantics.secondary.base);
18
+ expect(selected.onAccent).toBe(theme.semantics.secondary.onSolidText);
19
+ const invalid = resolveRadioOptionColors(theme, { color: 'secondary', invalid: true });
20
+ expect(invalid.accent).toBe(theme.semantics.error.base);
21
+ const disabled = resolveRadioOptionColors(theme, {
22
+ color: 'secondary',
23
+ invalid: true,
24
+ disabled: true,
25
+ });
26
+ expect(disabled.accent).toBe(theme.semantics.content.disabled);
27
+ expect(disabled.surface).toBe(theme.semantics.surface.disabled);
28
+ });
29
+ }
@@ -0,0 +1,37 @@
1
+ import type { RoleSemantics, SurfaceTheme } from '@ankhorage/surface';
2
+
3
+ import type { RadioGroupProps } from './types';
4
+
5
+ /*** Resolve selection-card decoration from the requested semantic role and field state. */
6
+ export function resolveRadioOptionColors(
7
+ theme: SurfaceTheme,
8
+ {
9
+ color = 'primary',
10
+ invalid,
11
+ disabled,
12
+ }: Pick<RadioGroupProps<string>, 'color' | 'invalid' | 'disabled'>,
13
+ ) {
14
+ const roles = new Map<string, RoleSemantics>([
15
+ ['primary', theme.semantics.action.primary],
16
+ ['secondary', theme.semantics.secondary],
17
+ ['tertiary', theme.semantics.accent],
18
+ ['quaternary', theme.semantics.highlight],
19
+ ['neutral', theme.semantics.action.neutral],
20
+ ['danger', theme.semantics.action.danger],
21
+ ['error', theme.semantics.error],
22
+ ['success', theme.semantics.success],
23
+ ['warning', theme.semantics.warning],
24
+ ['info', theme.semantics.info],
25
+ ]);
26
+ const role = roles.get(invalid ? 'error' : color) ?? theme.semantics.action.primary;
27
+ return {
28
+ accent: disabled ? theme.semantics.content.disabled : role.base,
29
+ onAccent: disabled ? theme.semantics.surface.disabled : role.onSolidText,
30
+ border: disabled
31
+ ? theme.semantics.border.subtle
32
+ : invalid
33
+ ? role.outline
34
+ : theme.semantics.border.default,
35
+ surface: disabled ? theme.semantics.surface.disabled : theme.semantics.surface.default,
36
+ };
37
+ }
@@ -2,6 +2,7 @@ import type { RadioProps as SurfaceRadioProps } from '@ankhorage/surface';
2
2
  import type React from 'react';
3
3
 
4
4
  import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
5
+ import type { IconProps } from '../icon';
5
6
 
6
7
  export interface RadioProps extends ZoraBaseProps, Omit<SurfaceRadioProps, 'mode' | 'themeId'> {}
7
8
 
@@ -9,6 +10,8 @@ export interface RadioGroupOption<TValue extends string> {
9
10
  value: TValue;
10
11
  label: React.ReactNode;
11
12
  description?: React.ReactNode;
13
+ /** SVG media is resolved by the consuming runtime before rendering. */
14
+ iconSource?: Extract<IconProps, { source: unknown }>['source'];
12
15
  disabled?: boolean;
13
16
  testID?: string;
14
17
  }
@@ -20,10 +23,15 @@ export interface RadioGroupProps<TValue extends string>
20
23
  Omit<SurfaceRadioProps, 'mode' | 'themeId'>,
21
24
  'color' | 'size' | 'invalid' | 'readOnly' | 'disabled'
22
25
  > {
23
- value: TValue;
24
- onValueChange: (value: TValue) => void;
26
+ value?: TValue;
27
+ defaultValue?: TValue;
28
+ onValueChange?: (value: TValue) => void;
25
29
  options: readonly RadioGroupOption<TValue>[];
26
30
  orientation?: 'horizontal' | 'vertical';
27
31
  gap?: 'xs' | 's' | 'm' | 'l';
28
32
  presentation?: 'inline' | 'card';
33
+ /** Arrangement inside a selection card, independent of the group direction. */
34
+ contentOrientation?: 'horizontal' | 'vertical';
35
+ /** Equal-width card columns; omitted columns follow orientation. */
36
+ columns?: 1 | 2 | 3 | 4;
29
37
  }