@fluentui-react-native/storybook-desktop-runtime 0.3.3 → 0.3.5

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.
@@ -2,66 +2,158 @@ import * as React from 'react';
2
2
  import { Pressable, StyleSheet, Text, View } from 'react-native';
3
3
 
4
4
  import { createDefaultTheme } from '@fluentui-react-native/default-theme';
5
- import { ThemedRoot, themedStyleSheetFactory, useThemeState } from '@fluentui-react-native/design';
6
- import type { ThemeReference } from '@fluentui-react-native/design/theming';
5
+ import { FlexThemeReference, ThemedRoot, ThemeProvider, themedStyleSheetFactory, useThemeState } from '@fluentui-react-native/design';
6
+ import type { ColorTone, ThemeAppearanceOptions, ThemeSource } from '@fluentui-react-native/design';
7
7
 
8
8
  import { useDesktopStorybookTestID } from './DesktopStorybookConfig';
9
9
 
10
- type ThemeChoice = {
11
- label: string;
12
- theme?: ThemeReference;
10
+ /** The theme configuration selected in the Storybook toolbar. */
11
+ export type StorybookThemeSelection = {
12
+ /** The story theme: the package's Flex defaults or the legacy Fluent default theme. The chrome always uses Flex. */
13
+ source: 'flex' | 'fluent';
14
+ colorScheme: 'light' | 'dark' | 'system';
15
+ contrast: 'standard' | 'highContrast';
16
+ /** Flex palette and interaction tone. Legacy Fluent themes have no tone. */
17
+ colorTone: ColorTone;
13
18
  };
14
19
 
15
- const themeChoiceNames = ['none', 'light', 'dark', 'highContrast'] as const;
16
- type ThemeChoiceName = (typeof themeChoiceNames)[number];
20
+ type ThemeAxisKey = keyof StorybookThemeSelection;
17
21
 
18
- const themeChoices: Record<ThemeChoiceName, ThemeChoice> = {
19
- none: { label: 'Default Flex' },
20
- light: { label: 'Light', theme: createDefaultTheme({ appearance: 'light' }) },
21
- dark: { label: 'Dark', theme: createDefaultTheme({ appearance: 'dark' }) },
22
- highContrast: { label: 'High contrast', theme: createDefaultTheme({ appearance: 'highContrast' }) },
22
+ type ThemeAxis = {
23
+ readonly key: ThemeAxisKey;
24
+ /** Stable test-ID segment for the axis and its options. */
25
+ readonly id: string;
26
+ readonly label: string;
27
+ readonly options: readonly { readonly value: string; readonly label: string }[];
23
28
  };
24
29
 
30
+ /** A deterministic default that does not depend on the host appearance. */
31
+ export const defaultStorybookThemeSelection: Readonly<StorybookThemeSelection> = Object.freeze({
32
+ source: 'flex',
33
+ colorScheme: 'light',
34
+ contrast: 'standard',
35
+ colorTone: 'cool',
36
+ });
37
+
38
+ function themeAxis<Key extends ThemeAxisKey>(
39
+ key: Key,
40
+ id: string,
41
+ label: string,
42
+ options: readonly { value: StorybookThemeSelection[Key]; label: string }[],
43
+ ): ThemeAxis {
44
+ return { key, id, label, options };
45
+ }
46
+
47
+ const themeAxes: readonly ThemeAxis[] = [
48
+ themeAxis('source', 'source', 'Theme', [
49
+ { value: 'flex', label: 'Flex' },
50
+ { value: 'fluent', label: 'Fluent' },
51
+ ]),
52
+ themeAxis('colorScheme', 'scheme', 'Scheme', [
53
+ { value: 'light', label: 'Light' },
54
+ { value: 'dark', label: 'Dark' },
55
+ { value: 'system', label: 'System' },
56
+ ]),
57
+ themeAxis('contrast', 'contrast', 'Contrast', [
58
+ { value: 'standard', label: 'Standard' },
59
+ { value: 'highContrast', label: 'High' },
60
+ ]),
61
+ themeAxis('colorTone', 'tone', 'Tone', [
62
+ { value: 'cool', label: 'Cool' },
63
+ { value: 'warm', label: 'Warm' },
64
+ ]),
65
+ ];
66
+
67
+ // Each source resolves every appearance itself, so selection changes only the appearance request.
68
+ const themeSources: Record<StorybookThemeSelection['source'], ThemeSource> = {
69
+ flex: new FlexThemeReference(),
70
+ fluent: createDefaultTheme({ appearance: 'dynamic' }),
71
+ };
72
+
73
+ function appearanceFromSelection({ source, colorScheme, contrast, colorTone }: StorybookThemeSelection): ThemeAppearanceOptions {
74
+ // Omitting tone for Fluent keeps the resolved appearance at its cool default.
75
+ return source === 'flex' ? { colorScheme, contrast, colorTone } : { colorScheme, contrast };
76
+ }
77
+
78
+ function isAxisDisabled(key: ThemeAxisKey, selection: StorybookThemeSelection): boolean {
79
+ return key === 'colorTone' && selection.source !== 'flex';
80
+ }
81
+
82
+ type StoryTheme = {
83
+ readonly theme: ThemeSource;
84
+ readonly appearance: ThemeAppearanceOptions;
85
+ };
86
+
87
+ const StoryThemeContext = React.createContext<StoryTheme | undefined>(undefined);
88
+
25
89
  export function StorybookThemeHost({ children }: React.PropsWithChildren) {
26
- const [selectedName, setSelectedName] = React.useState<ThemeChoiceName>('none');
90
+ const [selection, setSelection] = React.useState<StorybookThemeSelection>(defaultStorybookThemeSelection);
91
+ const storyTheme = React.useMemo<StoryTheme>(
92
+ () => ({ theme: themeSources[selection.source], appearance: appearanceFromSelection(selection) }),
93
+ [selection],
94
+ );
27
95
  const testID = useDesktopStorybookTestID('app-root');
28
96
 
97
+ // The chrome always uses Flex at the selected appearance; only story content follows the selected source.
29
98
  return (
30
- <ThemedRoot style={layoutStyles.root} testID={testID} theme={themeChoices[selectedName].theme}>
31
- <StorybookThemeContent selectedName={selectedName} onSelect={setSelectedName}>
32
- {children}
33
- </StorybookThemeContent>
99
+ <ThemedRoot appearance={storyTheme.appearance} style={layoutStyles.root} testID={testID} theme={themeSources.flex}>
100
+ <StoryThemeContext.Provider value={storyTheme}>
101
+ <StorybookThemeContent selection={selection} onSelectionChange={setSelection}>
102
+ {children}
103
+ </StorybookThemeContent>
104
+ </StoryThemeContext.Provider>
34
105
  </ThemedRoot>
35
106
  );
36
107
  }
37
108
 
109
+ /** Applies the selected story theme inside the Flex chrome without adding a view or a second input root. */
110
+ export function StorybookStoryTheme({ children }: React.PropsWithChildren) {
111
+ const storyTheme = React.useContext(StoryThemeContext);
112
+ if (!storyTheme) {
113
+ return <>{children}</>;
114
+ }
115
+ return (
116
+ <ThemeProvider appearance={storyTheme.appearance} theme={storyTheme.theme}>
117
+ {children}
118
+ </ThemeProvider>
119
+ );
120
+ }
121
+
38
122
  function StorybookThemeContent({
39
123
  children,
40
- selectedName,
41
- onSelect,
42
- }: React.PropsWithChildren<{ selectedName: ThemeChoiceName; onSelect: (name: ThemeChoiceName) => void }>) {
124
+ selection,
125
+ onSelectionChange,
126
+ }: React.PropsWithChildren<{
127
+ selection: StorybookThemeSelection;
128
+ onSelectionChange: React.Dispatch<React.SetStateAction<StorybookThemeSelection>>;
129
+ }>) {
43
130
  const styles = getStyles(useThemeState());
44
131
  const toolbarTestID = useDesktopStorybookTestID('theme-toolbar');
45
- const optionTestID = useDesktopStorybookTestID('theme');
132
+ const axisTestID = useDesktopStorybookTestID('theme');
133
+ const [openAxis, setOpenAxis] = React.useState<ThemeAxisKey>();
134
+
135
+ const select = (key: ThemeAxisKey, value: string) => {
136
+ onSelectionChange((current) => (current[key] === value ? current : ({ ...current, [key]: value } as StorybookThemeSelection)));
137
+ setOpenAxis(undefined);
138
+ };
46
139
 
47
140
  return (
48
141
  <View style={styles.root}>
49
142
  <View accessibilityRole="toolbar" style={styles.header} testID={toolbarTestID}>
50
- <Text style={styles.label}>Theme</Text>
51
- {themeChoiceNames.map((name) => {
52
- const choice = themeChoices[name];
53
- const selected = name === selectedName;
143
+ {themeAxes.map((axis) => {
144
+ const disabled = isAxisDisabled(axis.key, selection);
54
145
  return (
55
- <Pressable
56
- accessibilityRole="button"
57
- accessibilityState={{ selected }}
58
- key={name}
59
- onPress={() => onSelect(name)}
60
- style={({ pressed }) => [styles.option, selected && styles.selectedOption, pressed && styles.pressedOption]}
61
- testID={`${optionTestID}-${name}`}
62
- >
63
- <Text style={[styles.optionText, selected && styles.selectedOptionText]}>{choice.label}</Text>
64
- </Pressable>
146
+ <ThemeAxisDropdown
147
+ axis={axis}
148
+ disabled={disabled}
149
+ key={axis.key}
150
+ open={!disabled && openAxis === axis.key}
151
+ styles={styles}
152
+ testID={`${axisTestID}-${axis.id}`}
153
+ value={disabled ? defaultStorybookThemeSelection[axis.key] : selection[axis.key]}
154
+ onSelect={(value) => select(axis.key, value)}
155
+ onToggle={() => setOpenAxis((current) => (current === axis.key ? undefined : axis.key))}
156
+ />
65
157
  );
66
158
  })}
67
159
  </View>
@@ -70,6 +162,65 @@ function StorybookThemeContent({
70
162
  );
71
163
  }
72
164
 
165
+ type ThemeAxisDropdownProps = {
166
+ axis: ThemeAxis;
167
+ disabled: boolean;
168
+ open: boolean;
169
+ styles: ReturnType<typeof getStyles>;
170
+ testID: string;
171
+ value: string;
172
+ onSelect(value: string): void;
173
+ onToggle(): void;
174
+ };
175
+
176
+ function ThemeAxisDropdown({ axis, disabled, open, styles, testID, value, onSelect, onToggle }: ThemeAxisDropdownProps) {
177
+ const selectedLabel = axis.options.find((option) => option.value === value)?.label ?? value;
178
+
179
+ return (
180
+ <View style={styles.field}>
181
+ <Text style={[styles.label, disabled && styles.disabledText]}>{axis.label}</Text>
182
+ <View>
183
+ <Pressable
184
+ accessibilityLabel={axis.label}
185
+ accessibilityRole="combobox"
186
+ accessibilityState={{ disabled, expanded: open }}
187
+ accessibilityValue={{ text: selectedLabel }}
188
+ disabled={disabled}
189
+ onPress={onToggle}
190
+ style={({ pressed }) => [styles.option, styles.trigger, pressed && styles.pressedOption, disabled && styles.disabledOption]}
191
+ testID={testID}
192
+ >
193
+ <Text style={[styles.optionText, disabled && styles.disabledText]}>{selectedLabel}</Text>
194
+ <Text style={[styles.chevron, disabled && styles.disabledText]}>{open ? '\u25b4' : '\u25be'}</Text>
195
+ </Pressable>
196
+ {open ? (
197
+ <View accessibilityRole="list" style={styles.menu} testID={`${testID}-menu`}>
198
+ {axis.options.map((option) => {
199
+ const selected = option.value === value;
200
+ return (
201
+ <Pressable
202
+ accessibilityRole="button"
203
+ accessibilityState={{ selected }}
204
+ key={option.value}
205
+ onPress={() => onSelect(option.value)}
206
+ style={({ pressed }) => [
207
+ styles.menuOption,
208
+ selected && styles.selectedOption,
209
+ pressed && (selected ? styles.pressedSelectedOption : styles.pressedMenuOption),
210
+ ]}
211
+ testID={`${testID}-${option.value}`}
212
+ >
213
+ <Text style={[styles.optionText, selected && styles.selectedOptionText]}>{option.label}</Text>
214
+ </Pressable>
215
+ );
216
+ })}
217
+ </View>
218
+ ) : null}
219
+ </View>
220
+ </View>
221
+ );
222
+ }
223
+
73
224
  const layoutStyles = StyleSheet.create({
74
225
  root: {
75
226
  flex: 1,
@@ -80,48 +231,91 @@ const getStyles = themedStyleSheetFactory('StorybookTheme', ({ tokens }) =>
80
231
  StyleSheet.create({
81
232
  root: {
82
233
  flex: 1,
83
- backgroundColor: tokens.color.surfaceNeutralFar,
234
+ backgroundColor: tokens.color.surfaceNeutralFar.rest,
84
235
  },
85
236
  header: {
86
- alignItems: 'center',
87
- backgroundColor: tokens.color.backgroundNeutralSubtle,
88
- borderBottomColor: tokens.color.strokeNeutralSubtle,
237
+ alignItems: 'flex-start',
238
+ backgroundColor: tokens.color.backgroundNeutralSubtle.rest,
239
+ borderBottomColor: tokens.color.strokeNeutralSubtle.rest,
89
240
  borderBottomWidth: 1,
90
241
  flexDirection: 'row',
91
242
  flexWrap: 'wrap',
92
- gap: 8,
243
+ gap: 16,
93
244
  minHeight: 44,
94
245
  paddingHorizontal: 12,
95
- paddingVertical: 6,
246
+ paddingVertical: 8,
247
+ },
248
+ field: {
249
+ alignItems: 'flex-start',
250
+ flexDirection: 'row',
251
+ gap: 6,
96
252
  },
97
253
  label: {
98
- color: tokens.color.foregroundNeutralPrimary,
254
+ color: tokens.color.foregroundNeutralPrimary.rest,
99
255
  fontSize: 14,
100
256
  fontWeight: '600',
101
- marginEnd: 4,
257
+ paddingVertical: 5,
102
258
  },
103
259
  option: {
104
- backgroundColor: tokens.color.backgroundNeutralLoud,
105
- borderColor: tokens.color.strokeNeutralSoft,
260
+ backgroundColor: tokens.color.surfaceNeutralNearer.rest,
261
+ borderColor: tokens.color.strokeNeutralLoud.rest,
106
262
  borderRadius: 4,
107
263
  borderWidth: 1,
108
264
  paddingHorizontal: 10,
109
265
  paddingVertical: 5,
110
266
  },
111
- selectedOption: {
112
- backgroundColor: tokens.color.backgroundBrandHeavy,
113
- borderColor: tokens.color.strokeBrandLoud,
267
+ trigger: {
268
+ alignItems: 'center',
269
+ flexDirection: 'row',
270
+ gap: 8,
271
+ justifyContent: 'space-between',
272
+ minWidth: 96,
114
273
  },
115
274
  pressedOption: {
116
- opacity: 0.75,
275
+ backgroundColor: tokens.color.surfaceNeutralNearer.pressed,
276
+ borderColor: tokens.color.strokeNeutralLoud.pressed,
277
+ },
278
+ disabledOption: {
279
+ backgroundColor: tokens.color.backgroundNeutralDisabled.rest,
280
+ borderColor: tokens.color.strokeNeutralDisabled.rest,
281
+ },
282
+ menu: {
283
+ backgroundColor: tokens.color.surfaceNeutralNearer.rest,
284
+ borderColor: tokens.color.strokeNeutralSubtle.rest,
285
+ borderRadius: 4,
286
+ borderWidth: 1,
287
+ gap: 2,
288
+ marginTop: 4,
289
+ padding: 2,
290
+ },
291
+ menuOption: {
292
+ backgroundColor: tokens.color.backgroundNeutralTransparent.rest,
293
+ borderRadius: 3,
294
+ paddingHorizontal: 10,
295
+ paddingVertical: 5,
296
+ },
297
+ pressedMenuOption: {
298
+ backgroundColor: tokens.color.backgroundNeutralTransparent.pressed,
299
+ },
300
+ selectedOption: {
301
+ backgroundColor: tokens.color.backgroundNeutralSoft.rest,
302
+ },
303
+ pressedSelectedOption: {
304
+ backgroundColor: tokens.color.backgroundNeutralSoft.pressed,
117
305
  },
118
306
  optionText: {
119
- color: tokens.color.foregroundNeutralPrimary,
307
+ color: tokens.color.foregroundNeutralPrimary.rest,
308
+ fontSize: 12,
309
+ },
310
+ chevron: {
311
+ color: tokens.color.foregroundNeutralSecondary.rest,
120
312
  fontSize: 12,
121
313
  },
122
314
  selectedOptionText: {
123
- color: tokens.color.foregroundBrandOnloud,
124
315
  fontWeight: '600',
125
316
  },
317
+ disabledText: {
318
+ color: tokens.color.foregroundNeutralDisabled.rest,
319
+ },
126
320
  }),
127
321
  );
@@ -1,9 +1,14 @@
1
1
  import type { SBUI } from '@storybook/react-native-ui-common';
2
2
 
3
+ import { StorybookStoryTheme } from './StorybookTheme';
3
4
  import { StorybookUIComponent } from './StorybookUI';
4
5
  import { useStorybookTheme } from './useStorybookTheme';
5
6
 
6
- export const ThemedStorybookUI: SBUI = (props) => {
7
+ export const ThemedStorybookUI: SBUI = ({ children, ...props }) => {
7
8
  const theme = useStorybookTheme();
8
- return <StorybookUIComponent {...props} theme={theme} />;
9
+ return (
10
+ <StorybookUIComponent {...props} theme={theme}>
11
+ <StorybookStoryTheme>{children}</StorybookStoryTheme>
12
+ </StorybookUIComponent>
13
+ );
9
14
  };
@@ -47,7 +47,7 @@ describe('Storybook theme palette', () => {
47
47
  expect(themes[themes.length - 1].background.content).toBe('#654321');
48
48
  expect(themes[themes.length - 1].background.preview).toBe('#654321');
49
49
  expect(themes[themes.length - 1]).not.toBe(themes[0]);
50
- expect(themes[themes.length - 1].appBorderColor).toBe(states[states.length - 1].tokens.color.strokeNeutralSubtle);
50
+ expect(themes[themes.length - 1].appBorderColor).toBe(states[states.length - 1].tokens.color.strokeNeutralSubtle.rest);
51
51
  });
52
52
 
53
53
  it('warns and substitutes a contrasting theme color for native opaque colors', async () => {
@@ -60,7 +60,7 @@ describe('Storybook theme palette', () => {
60
60
  <Probe themes={themes} states={states} />
61
61
  </ThemedRoot>,
62
62
  );
63
- expect(themes[0].color.primary).toBe(states[0].tokens.color.fixedBlack);
63
+ expect(themes[0].color.primary).toBe(states[0].tokens.color.fixedBlack.rest);
64
64
  expect(warn).toHaveBeenCalledTimes(1);
65
65
  expect(warn).toHaveBeenCalledWith(expect.stringContaining('foregroundBrandPrimary'));
66
66
  });
@@ -84,8 +84,8 @@ describe('Storybook theme palette', () => {
84
84
  </ThemedRoot>,
85
85
  );
86
86
 
87
- expect(themes[0].background.content).toBe(states[0].tokens.color[fallback]);
88
- expect(themes[0].background.preview).toBe(states[0].tokens.color[fallback]);
87
+ expect(themes[0].background.content).toBe(states[0].tokens.color[fallback].rest);
88
+ expect(themes[0].background.preview).toBe(states[0].tokens.color[fallback].rest);
89
89
  expect(warn).toHaveBeenCalledTimes(1);
90
90
  expect(warn).toHaveBeenCalledWith(expect.stringContaining('surfaceNeutralFar'));
91
91
  });
@@ -24,14 +24,14 @@ function storybookThemeFromThemeState({ appearance, highContrast, tokens }: Them
24
24
  const background = base.base === 'dark' ? 'fixedBlack' : 'fixedWhite';
25
25
  const nativeColors = new Set<keyof SemanticColorTokenValues>();
26
26
  const color = (name: keyof SemanticColorTokenValues, role: 'foreground' | 'background' = 'foreground'): string => {
27
- const value = tokens.color[name];
27
+ const value = tokens.color[name].rest;
28
28
  // Storybook also passes these colors to JS color utilities, not just native styles.
29
29
  if (typeof value === 'string') {
30
30
  return value;
31
31
  }
32
32
  nativeColors.add(name);
33
33
  const fallbackName = role === 'foreground' ? foreground : background;
34
- const fallback = tokens.color[fallbackName];
34
+ const fallback = tokens.color[fallbackName].rest;
35
35
  if (typeof fallback !== 'string') {
36
36
  throw new Error(`Storybook chrome requires a literal "${fallbackName}" fallback for the "${name}" theme token.`);
37
37
  }