@ankhorage/zora 4.4.1 → 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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/components/radio/RadioGroup.d.ts +1 -3
- package/dist/components/radio/RadioGroup.d.ts.map +1 -1
- package/dist/components/radio/RadioGroup.js +52 -40
- package/dist/components/radio/RadioGroup.js.map +1 -1
- package/dist/components/radio/RadioOptionControl.d.ts +15 -0
- package/dist/components/radio/RadioOptionControl.d.ts.map +1 -0
- package/dist/components/radio/RadioOptionControl.js +105 -0
- package/dist/components/radio/RadioOptionControl.js.map +1 -0
- package/dist/components/radio/handleRadioOptionKeyDown.d.ts +13 -0
- package/dist/components/radio/handleRadioOptionKeyDown.d.ts.map +1 -0
- package/dist/components/radio/handleRadioOptionKeyDown.js +22 -0
- package/dist/components/radio/handleRadioOptionKeyDown.js.map +1 -0
- package/dist/components/radio/meta.d.ts +38 -3
- package/dist/components/radio/meta.d.ts.map +1 -1
- package/dist/components/radio/meta.js +28 -3
- package/dist/components/radio/meta.js.map +1 -1
- package/dist/components/radio/resolveRadioOptionColors.d.ts +10 -0
- package/dist/components/radio/resolveRadioOptionColors.d.ts.map +1 -0
- package/dist/components/radio/resolveRadioOptionColors.js +27 -0
- package/dist/components/radio/resolveRadioOptionColors.js.map +1 -0
- package/dist/components/radio/types.d.ts +12 -2
- package/dist/components/radio/types.d.ts.map +1 -1
- package/dist/components/radio/types.js.map +1 -1
- package/dist/constants/keyboard.d.ts +3 -0
- package/dist/constants/keyboard.d.ts.map +1 -0
- package/dist/constants/keyboard.js +8 -0
- package/dist/constants/keyboard.js.map +1 -0
- package/package.json +1 -1
- package/src/components/radio/RadioGroup.test.ts +13 -9
- package/src/components/radio/RadioGroup.tsx +85 -111
- package/src/components/radio/RadioOptionControl.tsx +192 -0
- package/src/components/radio/handleRadioOptionKeyDown.test.ts +47 -0
- package/src/components/radio/handleRadioOptionKeyDown.ts +37 -0
- package/src/components/radio/meta.ts +28 -3
- package/src/components/radio/resolveRadioOptionColors.test.ts +29 -0
- package/src/components/radio/resolveRadioOptionColors.ts +37 -0
- package/src/components/radio/types.ts +10 -2
- package/src/constants/keyboard.ts +7 -0
- package/src/metadata/componentMeta.test.ts +1 -0
|
@@ -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
|
-
|
|
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: '
|
|
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
|
|
24
|
-
|
|
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
|
}
|