@ankhorage/zora 0.6.3 → 0.7.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 (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +46 -0
  3. package/dist/components/card/Card.d.ts.map +1 -1
  4. package/dist/components/card/Card.js +2 -1
  5. package/dist/components/card/Card.js.map +1 -1
  6. package/dist/components/drawer/Drawer.d.ts.map +1 -1
  7. package/dist/components/drawer/Drawer.js +2 -1
  8. package/dist/components/drawer/Drawer.js.map +1 -1
  9. package/dist/components/heading/Heading.d.ts +4 -0
  10. package/dist/components/heading/Heading.d.ts.map +1 -0
  11. package/dist/components/heading/Heading.js +48 -0
  12. package/dist/components/heading/Heading.js.map +1 -0
  13. package/dist/components/heading/index.d.ts +3 -0
  14. package/dist/components/heading/index.d.ts.map +1 -0
  15. package/dist/components/heading/index.js +2 -0
  16. package/dist/components/heading/index.js.map +1 -0
  17. package/dist/components/heading/resolveHeadingRecipe.d.ts +15 -0
  18. package/dist/components/heading/resolveHeadingRecipe.d.ts.map +1 -0
  19. package/dist/components/heading/resolveHeadingRecipe.js +90 -0
  20. package/dist/components/heading/resolveHeadingRecipe.js.map +1 -0
  21. package/dist/components/heading/types.d.ts +28 -0
  22. package/dist/components/heading/types.d.ts.map +1 -0
  23. package/dist/components/heading/types.js +2 -0
  24. package/dist/components/heading/types.js.map +1 -0
  25. package/dist/components/modal/Modal.d.ts.map +1 -1
  26. package/dist/components/modal/Modal.js +2 -1
  27. package/dist/components/modal/Modal.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/layout/page-header/PageHeader.d.ts.map +1 -1
  33. package/dist/layout/page-header/PageHeader.js +2 -1
  34. package/dist/layout/page-header/PageHeader.js.map +1 -1
  35. package/dist/patterns/section-header/SectionHeader.d.ts.map +1 -1
  36. package/dist/patterns/section-header/SectionHeader.js +2 -1
  37. package/dist/patterns/section-header/SectionHeader.js.map +1 -1
  38. package/dist/patterns/tile-grid/PaletteItem.d.ts.map +1 -1
  39. package/dist/patterns/tile-grid/PaletteItem.js +2 -1
  40. package/dist/patterns/tile-grid/PaletteItem.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/card/Card.tsx +2 -1
  43. package/src/components/drawer/Drawer.tsx +2 -1
  44. package/src/components/heading/Heading.tsx +95 -0
  45. package/src/components/heading/index.ts +9 -0
  46. package/src/components/heading/resolveHeadingRecipe.test.ts +264 -0
  47. package/src/components/heading/resolveHeadingRecipe.ts +130 -0
  48. package/src/components/heading/types.ts +41 -0
  49. package/src/components/modal/Modal.tsx +2 -1
  50. package/src/index.ts +9 -0
  51. package/src/layout/page-header/PageHeader.tsx +2 -1
  52. package/src/patterns/section-header/SectionHeader.tsx +2 -1
  53. package/src/patterns/tile-grid/PaletteItem.tsx +2 -1
@@ -1 +1 @@
1
- {"version":3,"file":"PaletteItem.d.ts","sourceRoot":"","sources":["../../../src/patterns/tile-grid/PaletteItem.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,WAAW,EACX,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,MAAM,GACP,EAAE,gBAAgB,qBAiClB"}
1
+ {"version":3,"file":"PaletteItem.d.ts","sourceRoot":"","sources":["../../../src/patterns/tile-grid/PaletteItem.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,WAAW,EACX,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,MAAM,GACP,EAAE,gBAAgB,qBAiClB"}
@@ -1,6 +1,7 @@
1
- import { Box, Heading, useTheme } from '@ankhorage/surface';
1
+ import { Box, useTheme } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
  import { Card } from '../../components/card';
4
+ import { Heading } from '../../components/heading';
4
5
  import { Text } from '../../components/text';
5
6
  export function PaletteItem({ title, description, icon, badge, selected, disabled, onPress, testID, }) {
6
7
  const { theme } = useTheme();
@@ -1 +1 @@
1
- {"version":3,"file":"PaletteItem.js","sourceRoot":"","sources":["../../../src/patterns/tile-grid/PaletteItem.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC7C,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAG7C,MAAM,UAAU,WAAW,CAAC,EAC1B,KAAK,EACL,WAAW,EACX,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,MAAM,GACW;IACjB,MAAM,EAAE,KAAK,EAAE,GAAG,QAAQ,EAAE,CAAC;IAE7B,OAAO,CACL,CAAC,IAAI,CACH,OAAO,CACP,QAAQ,CAAC,CAAC,QAAQ,CAAC,CACnB,OAAO,CAAC,CAAC,OAAO,CAAC,CACjB,MAAM,CAAC,CAAC,MAAM,CAAC,CACf,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CACtC,KAAK,CAAC,CACJ,QAAQ;YACN,CAAC,CAAC;gBACE,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,OAAO;gBACjC,WAAW,EAAE,CAAC;aACf;YACH,CAAC,CAAC,SACN,CAAC,CAED;MAAA,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAC1C;QAAA,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,oBAAoB,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CACvD;QAAA,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAC/B;UAAA,CAAC,KAAK,CACR;QAAA,EAAE,OAAO,CACT;QAAA,CAAC,WAAW,CAAC,CAAC,CAAC,CACb,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,SAAS,CACjD;YAAA,CAAC,WAAW,CACd;UAAA,EAAE,IAAI,CAAC,CACR,CAAC,CAAC,CAAC,IAAI,CACR;QAAA,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAC5C;MAAA,EAAE,GAAG,CACP;IAAA,EAAE,IAAI,CAAC,CACR,CAAC;AACJ,CAAC","sourcesContent":["import { Box, Heading, useTheme } from '@ankhorage/surface';\nimport React from 'react';\n\nimport { Card } from '../../components/card';\nimport { Text } from '../../components/text';\nimport type { PaletteItemProps } from './types';\n\nexport function PaletteItem({\n title,\n description,\n icon,\n badge,\n selected,\n disabled,\n onPress,\n testID,\n}: PaletteItemProps) {\n const { theme } = useTheme();\n\n return (\n <Card\n compact\n disabled={disabled}\n onPress={onPress}\n testID={testID}\n tone={selected ? 'default' : 'subtle'}\n style={\n selected\n ? {\n borderColor: theme.colors.primary,\n borderWidth: 2,\n }\n : undefined\n }\n >\n <Box p=\"xs\" style={{ alignItems: 'center' }}>\n {icon ? <Box pb=\"s\">{/* Icon spec here */}</Box> : null}\n <Heading level={5} align=\"center\">\n {title}\n </Heading>\n {description ? (\n <Text align=\"center\" tone=\"muted\" variant=\"caption\">\n {description}\n </Text>\n ) : null}\n {badge ? <Box pt=\"xs\">{badge}</Box> : null}\n </Box>\n </Card>\n );\n}\n"]}
1
+ {"version":3,"file":"PaletteItem.js","sourceRoot":"","sources":["../../../src/patterns/tile-grid/PaletteItem.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAG7C,MAAM,UAAU,WAAW,CAAC,EAC1B,KAAK,EACL,WAAW,EACX,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,MAAM,GACW;IACjB,MAAM,EAAE,KAAK,EAAE,GAAG,QAAQ,EAAE,CAAC;IAE7B,OAAO,CACL,CAAC,IAAI,CACH,OAAO,CACP,QAAQ,CAAC,CAAC,QAAQ,CAAC,CACnB,OAAO,CAAC,CAAC,OAAO,CAAC,CACjB,MAAM,CAAC,CAAC,MAAM,CAAC,CACf,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CACtC,KAAK,CAAC,CACJ,QAAQ;YACN,CAAC,CAAC;gBACE,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,OAAO;gBACjC,WAAW,EAAE,CAAC;aACf;YACH,CAAC,CAAC,SACN,CAAC,CAED;MAAA,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAC1C;QAAA,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,oBAAoB,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CACvD;QAAA,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAC/B;UAAA,CAAC,KAAK,CACR;QAAA,EAAE,OAAO,CACT;QAAA,CAAC,WAAW,CAAC,CAAC,CAAC,CACb,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,SAAS,CACjD;YAAA,CAAC,WAAW,CACd;UAAA,EAAE,IAAI,CAAC,CACR,CAAC,CAAC,CAAC,IAAI,CACR;QAAA,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAC5C;MAAA,EAAE,GAAG,CACP;IAAA,EAAE,IAAI,CAAC,CACR,CAAC;AACJ,CAAC","sourcesContent":["import { Box, useTheme } from '@ankhorage/surface';\nimport React from 'react';\n\nimport { Card } from '../../components/card';\nimport { Heading } from '../../components/heading';\nimport { Text } from '../../components/text';\nimport type { PaletteItemProps } from './types';\n\nexport function PaletteItem({\n title,\n description,\n icon,\n badge,\n selected,\n disabled,\n onPress,\n testID,\n}: PaletteItemProps) {\n const { theme } = useTheme();\n\n return (\n <Card\n compact\n disabled={disabled}\n onPress={onPress}\n testID={testID}\n tone={selected ? 'default' : 'subtle'}\n style={\n selected\n ? {\n borderColor: theme.colors.primary,\n borderWidth: 2,\n }\n : undefined\n }\n >\n <Box p=\"xs\" style={{ alignItems: 'center' }}>\n {icon ? <Box pb=\"s\">{/* Icon spec here */}</Box> : null}\n <Heading level={5} align=\"center\">\n {title}\n </Heading>\n {description ? (\n <Text align=\"center\" tone=\"muted\" variant=\"caption\">\n {description}\n </Text>\n ) : null}\n {badge ? <Box pt=\"xs\">{badge}</Box> : null}\n </Box>\n </Card>\n );\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/zora",
3
3
  "type": "module",
4
- "version": "0.6.3",
4
+ "version": "0.7.0",
5
5
  "description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
6
6
  "homepage": "https://github.com/ankhorage/zora#readme",
7
7
  "bugs": {
@@ -1,7 +1,8 @@
1
- import { Box, Card as SurfaceCard, Heading, Stack } from '@ankhorage/surface';
1
+ import { Box, Card as SurfaceCard, Stack } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
 
4
4
  import { resolveCardVariant } from '../../internal/recipes';
5
+ import { Heading } from '../heading';
5
6
  import { Text } from '../text';
6
7
  import type { CardProps } from './types';
7
8
 
@@ -1,6 +1,7 @@
1
- import { Box, Drawer as SurfaceDrawer, Heading, Stack } from '@ankhorage/surface';
1
+ import { Box, Drawer as SurfaceDrawer, Stack } from '@ankhorage/surface';
2
2
  import React, { useCallback, useEffect, useRef } from 'react';
3
3
 
4
+ import { Heading } from '../heading';
4
5
  import { Text } from '../text';
5
6
  import type { DrawerProps } from './types';
6
7
 
@@ -0,0 +1,95 @@
1
+ import { resolveResponsive, useResponsiveRuntime, useTranslationContext } from '@ankhorage/surface';
2
+ import React from 'react';
3
+ import { Text as ReactNativeText } from 'react-native';
4
+
5
+ import { useZoraTheme } from '../../theme/useZoraTheme';
6
+ import { resolveHeadingRecipe, resolveHeadingSizeFromLevel } from './resolveHeadingRecipe';
7
+ import type { HeadingProps } from './types';
8
+
9
+ function resolveHeadingContent({
10
+ children,
11
+ text,
12
+ i18nKey,
13
+ translate,
14
+ }: {
15
+ children: HeadingProps['children'];
16
+ text: HeadingProps['text'];
17
+ i18nKey: HeadingProps['i18nKey'];
18
+ translate: (key: string) => string;
19
+ }): React.ReactNode {
20
+ if (children !== undefined) {
21
+ return children;
22
+ }
23
+
24
+ if (text !== undefined) {
25
+ return text;
26
+ }
27
+
28
+ if (!i18nKey) {
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ const translated = translate(i18nKey);
34
+ return translated && translated !== i18nKey ? translated : i18nKey;
35
+ } catch (error) {
36
+ console.warn('[ZoraHeading] Translation error:', error);
37
+ return i18nKey;
38
+ }
39
+ }
40
+
41
+ export function Heading({
42
+ children,
43
+ text,
44
+ i18nKey,
45
+ level = 2,
46
+ size,
47
+ tone = 'default',
48
+ align,
49
+ weight,
50
+ italic = false,
51
+ numberOfLines,
52
+ ellipsizeMode,
53
+ selectable,
54
+ accessibilityLabel,
55
+ accessibilityHint,
56
+ accessibilityRole = 'header',
57
+ nativeID,
58
+ testID,
59
+ }: HeadingProps) {
60
+ const { theme } = useZoraTheme();
61
+ const { breakpoint } = useResponsiveRuntime();
62
+ const { t } = useTranslationContext();
63
+ const content = resolveHeadingContent({ children, text, i18nKey, translate: t });
64
+ const resolvedSize = resolveResponsive(size, breakpoint) ?? resolveHeadingSizeFromLevel(level);
65
+ const resolvedTone = resolveResponsive(tone, breakpoint) ?? 'default';
66
+ const resolvedAlign = resolveResponsive(align, breakpoint);
67
+ const resolvedWeight = resolveResponsive(weight, breakpoint);
68
+
69
+ if (content === null || content === undefined) {
70
+ return null;
71
+ }
72
+
73
+ return (
74
+ <ReactNativeText
75
+ accessibilityHint={accessibilityHint}
76
+ accessibilityLabel={accessibilityLabel}
77
+ accessibilityRole={accessibilityRole}
78
+ ellipsizeMode={ellipsizeMode}
79
+ nativeID={nativeID}
80
+ numberOfLines={numberOfLines}
81
+ selectable={selectable}
82
+ testID={testID}
83
+ style={resolveHeadingRecipe(theme, {
84
+ align: resolvedAlign,
85
+ italic,
86
+ level,
87
+ size: resolvedSize,
88
+ tone: resolvedTone,
89
+ weight: resolvedWeight,
90
+ })}
91
+ >
92
+ {content}
93
+ </ReactNativeText>
94
+ );
95
+ }
@@ -0,0 +1,9 @@
1
+ export { Heading } from './Heading';
2
+ export type {
3
+ HeadingAlign,
4
+ HeadingLevel,
5
+ HeadingProps,
6
+ HeadingSize,
7
+ HeadingTone,
8
+ HeadingWeight,
9
+ } from './types';
@@ -0,0 +1,264 @@
1
+ import type { AnkhTheme, FontWeight, RoleSemantics } from '@ankhorage/surface';
2
+ import { describe, expect, test } from 'bun:test';
3
+
4
+ import { resolveHeadingRecipe, resolveHeadingSizeFromLevel } from './resolveHeadingRecipe';
5
+
6
+ const emptyFonts: Record<FontWeight, string | undefined> = {
7
+ '100': undefined,
8
+ '200': undefined,
9
+ '300': undefined,
10
+ '400': undefined,
11
+ '500': undefined,
12
+ '600': undefined,
13
+ '700': undefined,
14
+ '800': undefined,
15
+ '900': undefined,
16
+ bold: undefined,
17
+ normal: undefined,
18
+ };
19
+
20
+ function createRole(base: string): RoleSemantics {
21
+ return {
22
+ base,
23
+ hover: `${base}-hover`,
24
+ strong: `${base}-strong`,
25
+ softBg: `${base}-soft-bg`,
26
+ softHover: `${base}-soft-hover`,
27
+ softActive: `${base}-soft-active`,
28
+ outline: `${base}-outline`,
29
+ onSolidText: `${base}-on-solid-text`,
30
+ };
31
+ }
32
+
33
+ function createTestTheme(): AnkhTheme {
34
+ return {
35
+ colors: {
36
+ primary: '#0f766e',
37
+ secondary: '#2563eb',
38
+ accent: '#7c3aed',
39
+ highlight: '#f59e0b',
40
+ background: '#ffffff',
41
+ surface: '#ffffff',
42
+ text: '#111827',
43
+ textSecondary: '#4b5563',
44
+ border: '#d1d5db',
45
+ error: '#dc2626',
46
+ success: '#16a34a',
47
+ warning: '#d97706',
48
+ },
49
+ config: {
50
+ id: 'zora-test',
51
+ name: 'ZORA Test',
52
+ light: {
53
+ primaryColor: '#0f766e',
54
+ harmony: 'analogous',
55
+ systemTone: 'jewel',
56
+ },
57
+ dark: {
58
+ primaryColor: '#2dd4bf',
59
+ harmony: 'analogous',
60
+ systemTone: 'jewel',
61
+ },
62
+ },
63
+ radii: {
64
+ none: 0,
65
+ s: 4,
66
+ m: 8,
67
+ l: 16,
68
+ full: 9999,
69
+ },
70
+ scales: {},
71
+ semantics: {
72
+ neutral: {
73
+ bg: '#ffffff',
74
+ bgSubtle: '#f9fafb',
75
+ surface: '#ffffff',
76
+ surfaceHover: '#f3f4f6',
77
+ surfaceActive: '#e5e7eb',
78
+ border: '#d1d5db',
79
+ borderStrong: '#9ca3af',
80
+ divider: '#e5e7eb',
81
+ text: '#111827',
82
+ textMuted: '#4b5563',
83
+ textSubtle: '#6b7280',
84
+ },
85
+ brand: createRole('#0f766e'),
86
+ secondary: createRole('#2563eb'),
87
+ accent: createRole('#7c3aed'),
88
+ highlight: createRole('#f59e0b'),
89
+ danger: createRole('#dc2626'),
90
+ success: createRole('#16a34a'),
91
+ warning: createRole('#d97706'),
92
+ surface: {
93
+ default: '#ffffff',
94
+ subtle: '#f9fafb',
95
+ raised: '#ffffff',
96
+ },
97
+ content: {
98
+ default: '#111827',
99
+ muted: '#4b5563',
100
+ subtle: '#6b7280',
101
+ inverse: '#ffffff',
102
+ },
103
+ border: {
104
+ default: '#d1d5db',
105
+ strong: '#9ca3af',
106
+ focus: '#0f766e',
107
+ },
108
+ action: {
109
+ primary: createRole('#0f766e'),
110
+ neutral: createRole('#4b5563'),
111
+ danger: createRole('#dc2626'),
112
+ },
113
+ },
114
+ shadows: {
115
+ soft: 2,
116
+ medium: 4,
117
+ hard: 8,
118
+ },
119
+ spacing: {
120
+ none: 0,
121
+ xs: 4,
122
+ s: 8,
123
+ m: 16,
124
+ l: 24,
125
+ xl: 32,
126
+ xxl: 48,
127
+ },
128
+ typography: {
129
+ headings: {
130
+ 1: { size: 32, lineHeight: 40, weight: 'bold' },
131
+ 2: { size: 24, lineHeight: 32, weight: 'bold' },
132
+ 3: { size: 20, lineHeight: 28, weight: 'bold' },
133
+ 4: { size: 18, lineHeight: 24, weight: 'semiBold' },
134
+ 5: { size: 16, lineHeight: 22, weight: 'semiBold' },
135
+ 6: { size: 14, lineHeight: 20, weight: 'semiBold' },
136
+ },
137
+ sizes: {
138
+ xs: 12,
139
+ s: 14,
140
+ m: 16,
141
+ l: 18,
142
+ xl: 20,
143
+ xxl: 24,
144
+ '3xl': 30,
145
+ h1: 32,
146
+ h2: 24,
147
+ h3: 20,
148
+ h4: 18,
149
+ h5: 16,
150
+ h6: 14,
151
+ },
152
+ weights: {
153
+ thin: '100',
154
+ extraLight: '200',
155
+ light: '300',
156
+ regular: '400',
157
+ medium: '500',
158
+ semiBold: '600',
159
+ bold: '700',
160
+ extraBold: '800',
161
+ black: '900',
162
+ },
163
+ fonts: {
164
+ normal: { ...emptyFonts },
165
+ italic: { ...emptyFonts },
166
+ },
167
+ },
168
+ };
169
+ }
170
+
171
+ const theme = createTestTheme();
172
+
173
+ describe('resolveHeadingRecipe', () => {
174
+ test('maps level 1 default size to h1 typography', () => {
175
+ const style = resolveHeadingRecipe(theme, { level: 1 });
176
+
177
+ expect(style.fontSize).toBe(theme.typography.headings[1].size);
178
+ expect(style.lineHeight).toBe(theme.typography.headings[1].lineHeight);
179
+ expect(style.fontWeight).toBe(theme.typography.weights.bold);
180
+ expect(style.color).toBe(theme.semantics.content.default);
181
+ expect(style.elevation).toBe(0);
182
+ });
183
+
184
+ test('maps level 3 default size to h3 typography', () => {
185
+ const style = resolveHeadingRecipe(theme, { level: 3 });
186
+
187
+ expect(style.fontSize).toBe(theme.typography.headings[3].size);
188
+ expect(style.lineHeight).toBe(theme.typography.headings[3].lineHeight);
189
+ expect(style.fontWeight).toBe(theme.typography.weights.bold);
190
+ });
191
+
192
+ test('allows size to override level-derived visual size', () => {
193
+ const style = resolveHeadingRecipe(theme, { level: 1, size: 'h4' });
194
+
195
+ expect(style.fontSize).toBe(theme.typography.headings[4].size);
196
+ expect(style.lineHeight).toBe(theme.typography.headings[4].lineHeight);
197
+ expect(style.fontWeight).toBe(theme.typography.weights.semiBold);
198
+ });
199
+
200
+ test('resolves display size from existing theme typography tokens', () => {
201
+ const style = resolveHeadingRecipe(theme, { level: 1, size: 'display' });
202
+
203
+ expect(style.fontSize).toBe(theme.typography.sizes['3xl']);
204
+ expect(style.lineHeight).toBe(theme.typography.sizes['3xl'] + 8);
205
+ expect(style.fontWeight).toBe(theme.typography.weights.bold);
206
+ });
207
+
208
+ test('maps semantic tones to theme colors', () => {
209
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'primary' }).color).toBe(
210
+ theme.semantics.brand.base,
211
+ );
212
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'muted' }).color).toBe(
213
+ theme.semantics.content.muted,
214
+ );
215
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'subtle' }).color).toBe(
216
+ theme.semantics.content.subtle,
217
+ );
218
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'inverse' }).color).toBe(
219
+ theme.semantics.content.inverse,
220
+ );
221
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'danger' }).color).toBe(
222
+ theme.semantics.danger.base,
223
+ );
224
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'success' }).color).toBe(
225
+ theme.semantics.success.base,
226
+ );
227
+ expect(resolveHeadingRecipe(theme, { level: 2, tone: 'warning' }).color).toBe(
228
+ theme.semantics.warning.base,
229
+ );
230
+ });
231
+
232
+ test('applies alignment', () => {
233
+ const style = resolveHeadingRecipe(theme, { level: 2, align: 'center' });
234
+
235
+ expect(style.textAlign).toBe('center');
236
+ });
237
+
238
+ test('allows explicit weight to override the recipe weight', () => {
239
+ const style = resolveHeadingRecipe(theme, { level: 4, weight: 'medium' });
240
+
241
+ expect(style.fontWeight).toBe(theme.typography.weights.medium);
242
+ });
243
+
244
+ test('uses italic font family when available', () => {
245
+ const fontTheme = createTestTheme();
246
+ fontTheme.typography.fonts.italic[fontTheme.typography.weights.semiBold] = 'ZoraSemiBoldItalic';
247
+
248
+ const style = resolveHeadingRecipe(fontTheme, { level: 4, italic: true });
249
+
250
+ expect(style.fontStyle).toBe('italic');
251
+ expect(style.fontFamily).toBe('ZoraSemiBoldItalic');
252
+ });
253
+ });
254
+
255
+ describe('resolveHeadingSizeFromLevel', () => {
256
+ test('maps semantic levels to default visual sizes', () => {
257
+ expect(resolveHeadingSizeFromLevel(1)).toBe('h1');
258
+ expect(resolveHeadingSizeFromLevel(2)).toBe('h2');
259
+ expect(resolveHeadingSizeFromLevel(3)).toBe('h3');
260
+ expect(resolveHeadingSizeFromLevel(4)).toBe('h4');
261
+ expect(resolveHeadingSizeFromLevel(5)).toBe('h5');
262
+ expect(resolveHeadingSizeFromLevel(6)).toBe('h6');
263
+ });
264
+ });
@@ -0,0 +1,130 @@
1
+ import type { AnkhTheme, FontWeight } from '@ankhorage/surface';
2
+ import type { TextStyle } from 'react-native';
3
+
4
+ import type { HeadingAlign, HeadingLevel, HeadingSize, HeadingTone, HeadingWeight } from './types';
5
+
6
+ interface ResolveHeadingRecipeOptions {
7
+ level: HeadingLevel;
8
+ size?: HeadingSize;
9
+ tone?: HeadingTone;
10
+ align?: HeadingAlign;
11
+ weight?: HeadingWeight;
12
+ italic?: boolean;
13
+ }
14
+
15
+ interface HeadingRecipe {
16
+ fontSize: number;
17
+ lineHeight: number;
18
+ weight: HeadingWeight;
19
+ }
20
+
21
+ export function resolveHeadingSizeFromLevel(level: HeadingLevel): HeadingSize {
22
+ switch (level) {
23
+ case 1:
24
+ return 'h1';
25
+ case 2:
26
+ return 'h2';
27
+ case 3:
28
+ return 'h3';
29
+ case 4:
30
+ return 'h4';
31
+ case 5:
32
+ return 'h5';
33
+ case 6:
34
+ return 'h6';
35
+ }
36
+ }
37
+
38
+ function resolveHeadingLevelFromSize(size: Exclude<HeadingSize, 'display'>): HeadingLevel {
39
+ switch (size) {
40
+ case 'h1':
41
+ return 1;
42
+ case 'h2':
43
+ return 2;
44
+ case 'h3':
45
+ return 3;
46
+ case 'h4':
47
+ return 4;
48
+ case 'h5':
49
+ return 5;
50
+ case 'h6':
51
+ return 6;
52
+ }
53
+ }
54
+
55
+ function resolveSizeRecipe(theme: AnkhTheme, size: HeadingSize): HeadingRecipe {
56
+ if (size === 'display') {
57
+ const fontSize = theme.typography.sizes['3xl'];
58
+
59
+ return {
60
+ fontSize,
61
+ lineHeight: fontSize + 8,
62
+ weight: 'bold',
63
+ };
64
+ }
65
+
66
+ const heading = theme.typography.headings[resolveHeadingLevelFromSize(size)];
67
+
68
+ return {
69
+ fontSize: heading.size,
70
+ lineHeight: heading.lineHeight,
71
+ weight: heading.weight,
72
+ };
73
+ }
74
+
75
+ function resolveToneColor(theme: AnkhTheme, tone: HeadingTone): string {
76
+ switch (tone) {
77
+ case 'muted':
78
+ return theme.semantics.content.muted;
79
+ case 'subtle':
80
+ return theme.semantics.content.subtle;
81
+ case 'inverse':
82
+ return theme.semantics.content.inverse;
83
+ case 'primary':
84
+ return theme.semantics.brand.base;
85
+ case 'danger':
86
+ return theme.semantics.danger.base;
87
+ case 'success':
88
+ return theme.semantics.success.base;
89
+ case 'warning':
90
+ return theme.semantics.warning.base;
91
+ case 'default':
92
+ default:
93
+ return theme.semantics.content.default;
94
+ }
95
+ }
96
+
97
+ function resolveWeight(theme: AnkhTheme, weight: HeadingWeight): FontWeight {
98
+ return theme.typography.weights[weight];
99
+ }
100
+
101
+ function resolveFontFamily({
102
+ theme,
103
+ weight,
104
+ italic,
105
+ }: {
106
+ theme: AnkhTheme;
107
+ weight: FontWeight;
108
+ italic: boolean;
109
+ }): string | undefined {
110
+ return theme.typography.fonts[italic ? 'italic' : 'normal'][weight];
111
+ }
112
+
113
+ export function resolveHeadingRecipe(
114
+ theme: AnkhTheme,
115
+ { align, italic = false, level, size, tone = 'default', weight }: ResolveHeadingRecipeOptions,
116
+ ): TextStyle {
117
+ const recipe = resolveSizeRecipe(theme, size ?? resolveHeadingSizeFromLevel(level));
118
+ const resolvedWeight = resolveWeight(theme, weight ?? recipe.weight);
119
+
120
+ return {
121
+ color: resolveToneColor(theme, tone),
122
+ elevation: 0,
123
+ fontFamily: resolveFontFamily({ theme, weight: resolvedWeight, italic }),
124
+ fontSize: recipe.fontSize,
125
+ fontStyle: italic ? 'italic' : 'normal',
126
+ fontWeight: resolvedWeight,
127
+ lineHeight: recipe.lineHeight,
128
+ textAlign: align,
129
+ };
130
+ }
@@ -0,0 +1,41 @@
1
+ import type { Responsive } from '@ankhorage/surface';
2
+ import type React from 'react';
3
+ import type { AccessibilityRole, TextStyle } from 'react-native';
4
+
5
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
6
+
7
+ export type HeadingSize = 'display' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
8
+
9
+ export type HeadingTone =
10
+ | 'default'
11
+ | 'muted'
12
+ | 'subtle'
13
+ | 'inverse'
14
+ | 'primary'
15
+ | 'danger'
16
+ | 'success'
17
+ | 'warning';
18
+
19
+ export type HeadingAlign = NonNullable<TextStyle['textAlign']>;
20
+
21
+ export type HeadingWeight = 'regular' | 'medium' | 'semiBold' | 'bold';
22
+
23
+ export interface HeadingProps {
24
+ children?: React.ReactNode;
25
+ text?: string;
26
+ i18nKey?: string;
27
+ level?: HeadingLevel;
28
+ size?: Responsive<HeadingSize>;
29
+ tone?: Responsive<HeadingTone>;
30
+ align?: Responsive<HeadingAlign>;
31
+ weight?: Responsive<HeadingWeight>;
32
+ italic?: boolean;
33
+ numberOfLines?: number;
34
+ ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
35
+ selectable?: boolean;
36
+ accessibilityLabel?: string;
37
+ accessibilityHint?: string;
38
+ accessibilityRole?: AccessibilityRole;
39
+ nativeID?: string;
40
+ testID?: string;
41
+ }
@@ -1,7 +1,8 @@
1
- import { Box, Heading, Modal as SurfaceModal, Stack } from '@ankhorage/surface';
1
+ import { Box, Modal as SurfaceModal, Stack } from '@ankhorage/surface';
2
2
  import React, { useCallback, useEffect, useRef } from 'react';
3
3
 
4
4
  import { resolveDialogWidth } from '../../internal/recipes';
5
+ import { Heading } from '../heading';
5
6
  import { Text } from '../text';
6
7
  import type { ModalProps } from './types';
7
8
 
package/src/index.ts CHANGED
@@ -37,6 +37,15 @@ export {
37
37
  validateFields,
38
38
  validateValue,
39
39
  } from './components/form';
40
+ export type {
41
+ HeadingAlign,
42
+ HeadingLevel,
43
+ HeadingProps,
44
+ HeadingSize,
45
+ HeadingTone,
46
+ HeadingWeight,
47
+ } from './components/heading';
48
+ export { Heading } from './components/heading';
40
49
  export type { IconProps } from './components/icon';
41
50
  export { Icon } from './components/icon';
42
51
  export type { IconButtonProps } from './components/icon-button';
@@ -1,6 +1,7 @@
1
- import { Box, Heading, Stack } from '@ankhorage/surface';
1
+ import { Box, Stack } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
 
4
+ import { Heading } from '../../components/heading';
4
5
  import { Text } from '../../components/text';
5
6
  import type { PageHeaderProps } from './types';
6
7
 
@@ -1,6 +1,7 @@
1
- import { Box, Heading, Stack } from '@ankhorage/surface';
1
+ import { Box, Stack } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
 
4
+ import { Heading } from '../../components/heading';
4
5
  import { Text } from '../../components/text';
5
6
  import type { SectionHeaderProps } from './types';
6
7
 
@@ -1,7 +1,8 @@
1
- import { Box, Heading, useTheme } from '@ankhorage/surface';
1
+ import { Box, useTheme } from '@ankhorage/surface';
2
2
  import React from 'react';
3
3
 
4
4
  import { Card } from '../../components/card';
5
+ import { Heading } from '../../components/heading';
5
6
  import { Text } from '../../components/text';
6
7
  import type { PaletteItemProps } from './types';
7
8