Skip to content

Commit 13a5643

Browse files
authored
ui: Add Mithril context (#5521)
Add a React-like context but for Mithril.js. This can be very useful for injecting props into deeply nested components with out having to drill those props through every layer in between. Example usage: ```ts // Create a 'theme' context with a default value of 'light' const {ThemeProvider, ThemeConsumer} = createContext('light'); // This component expects a context to be injected, otherwise it falls back to the default const ThemedComponent = { view: () => m(ThemeConsumer, (theme) => m('div', `Current theme: ${theme}`), ), }; // We can inject a theme into the component above like this - it doesn't matter how // nested it is, it'll still receive the theme as long as it has a provider as one of its // ancestors. m(ThemeProvider, {theme: 'dark'}, m(ThemedComponent), ) ```
1 parent 4153213 commit 13a5643

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

ui/src/base/mithril_utils.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,119 @@ export function isEmptyVnodes(children: m.Children): boolean {
161161
}
162162
return false;
163163
}
164+
165+
/**
166+
* Creates a React-style context for passing data through the component tree
167+
* without having to drill props manually through every level.
168+
*
169+
* Wrap content that needs access to context values in a Consumer vnode. The
170+
* Consumer takes a function that receives the current context value and returns
171+
* mithril vnodes.
172+
*
173+
* @template T The type of value stored in the context
174+
* @param initialValue Optional default value when no Provider is present
175+
* @returns An object with Provider and Consumer components
176+
*
177+
* @example
178+
* // Basic usage - create a context and use it
179+
* const ThemeContext = createContext('light');
180+
*
181+
* // Provider sets the context value for its children
182+
* m(ThemeContext.Provider, {value: 'dark'}, [
183+
* m(Header),
184+
* m(Content),
185+
* ]);
186+
*
187+
* // Consumer wraps content that needs the context value.
188+
* m(ThemeContext.Consumer, (theme) => {
189+
* return m('.pf-button', {
190+
* class: theme === 'dark' ? 'pf-dark' : 'pf-light',
191+
* }, 'Click me');
192+
* });
193+
*
194+
* @example
195+
* // Using Consumer within a component
196+
* class Button implements m.ClassComponent {
197+
* view() {
198+
* return m(ThemeContext.Consumer, (theme) => {
199+
* return m('.pf-button', {
200+
* class: theme === 'dark' ? 'pf-dark' : 'pf-light',
201+
* }, 'Click me');
202+
* });
203+
* }
204+
* }
205+
*
206+
* @example
207+
* // Using Consumer inline within a vnode tree
208+
* m('.page', [
209+
* m('h1', 'My App'),
210+
* m(ThemeContext.Consumer, (theme) =>
211+
* m('.content', {class: theme}, 'Content')
212+
* ),
213+
* m(Footer),
214+
* ]);
215+
*
216+
* @example
217+
* // Context without a default value
218+
* interface User {
219+
* name: string;
220+
* id: number;
221+
* }
222+
* const UserContext = createContext<User>();
223+
*
224+
* // Consumer receives undefined when no Provider is present
225+
* m(UserContext.Consumer, (user) => {
226+
* if (!user) return m('.pf-greeting', 'Not logged in');
227+
* return m('.pf-greeting', `Welcome ${user.name}`);
228+
* });
229+
*
230+
* @example
231+
* // Nesting Providers creates scoped context values
232+
* m(ThemeContext.Provider, {value: 'light'}, [
233+
* m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'light'
234+
* m(ThemeContext.Provider, {value: 'dark'}, [
235+
* m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'dark'
236+
* ]),
237+
* m(ThemeContext.Consumer, (theme) => m('div', theme)), // 'light' again
238+
* ]);
239+
*/
240+
export function createContext<T>(initialValue: T): {
241+
Provider: m.Component<{value: T}>;
242+
Consumer: m.Component<(value: T) => m.Children | void>;
243+
};
244+
export function createContext<T>(): {
245+
Provider: m.Component<{value: T}>;
246+
Consumer: m.Component<(value: T | undefined) => m.Children | void>;
247+
};
248+
export function createContext<T>(initialValue?: T): {
249+
Provider: m.Component<{value: T}>;
250+
Consumer: m.Component<(value: T | undefined) => m.Children | void>;
251+
} {
252+
let currentContext: T | undefined = initialValue;
253+
254+
return {
255+
Provider: {
256+
view({attrs, children}: m.Vnode<{value: T}>): m.Children {
257+
const previousContext = currentContext;
258+
currentContext = attrs.value;
259+
return [
260+
children,
261+
m({
262+
view() {
263+
currentContext = previousContext;
264+
},
265+
}),
266+
];
267+
},
268+
},
269+
Consumer: {
270+
view({children}: m.Vnode<(value: T | undefined) => m.Children | void>) {
271+
const viewFn: (context: T | undefined) => m.Children | void =
272+
Array.isArray(children) && typeof children[0] === 'function'
273+
? children[0]
274+
: () => null;
275+
return viewFn(currentContext);
276+
},
277+
},
278+
};
279+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright (C) 2025 The Android Open Source Project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import m from 'mithril';
16+
import {createContext} from './mithril_utils';
17+
18+
describe('createContext', () => {
19+
test('provides default value to consumers', () => {
20+
const {Consumer} = createContext('default');
21+
22+
let receivedValue: string | undefined;
23+
const TestComponent = {
24+
view: () =>
25+
m(Consumer, (value) => {
26+
receivedValue = value;
27+
}),
28+
};
29+
30+
m.render(document.body, m(TestComponent));
31+
expect(receivedValue).toBe('default');
32+
});
33+
34+
test('provides undefined when no default value', () => {
35+
const {Consumer} = createContext<string>();
36+
37+
let receivedValue: string | undefined = 'sentinel';
38+
const TestComponent = {
39+
view: () =>
40+
m(Consumer, (value) => {
41+
receivedValue = value;
42+
}),
43+
};
44+
45+
m.render(document.body, m(TestComponent));
46+
expect(receivedValue).toBeUndefined();
47+
});
48+
49+
test('provider overrides default value', () => {
50+
const {Provider, Consumer} = createContext('default');
51+
52+
let receivedValue: string | undefined;
53+
const TestComponent = {
54+
view: () =>
55+
m(
56+
Provider,
57+
{value: 'custom'},
58+
m(Consumer, (value) => {
59+
receivedValue = value;
60+
}),
61+
),
62+
};
63+
64+
m.render(document.body, m(TestComponent));
65+
expect(receivedValue).toBe('custom');
66+
});
67+
68+
test('nested providers use innermost value', () => {
69+
const {Provider, Consumer} = createContext('default');
70+
71+
const receivedValues: string[] = [];
72+
const TestComponent = {
73+
view: () =>
74+
m(Provider, {value: 'outer'}, [
75+
m(Consumer, (value) => {
76+
receivedValues.push(value);
77+
}),
78+
m(
79+
Provider,
80+
{value: 'inner'},
81+
m(Consumer, (value) => {
82+
receivedValues.push(value);
83+
}),
84+
),
85+
m(Consumer, (value) => {
86+
receivedValues.push(value);
87+
}),
88+
]),
89+
};
90+
91+
m.render(document.body, m(TestComponent));
92+
expect(receivedValues).toEqual(['outer', 'inner', 'outer']);
93+
});
94+
95+
test('multiple providers in parallel', () => {
96+
const {Provider, Consumer} = createContext('default');
97+
98+
const receivedValues: string[] = [];
99+
const TestComponent = {
100+
view: () => [
101+
m(Consumer, (value) => {
102+
receivedValues.push(value);
103+
}),
104+
m(
105+
Provider,
106+
{value: 'foo'},
107+
m(Consumer, (value) => {
108+
receivedValues.push(value);
109+
}),
110+
),
111+
m(
112+
Provider,
113+
{value: 'bar'},
114+
m(Consumer, (value) => {
115+
receivedValues.push(value);
116+
}),
117+
),
118+
m(Consumer, (value) => {
119+
receivedValues.push(value);
120+
}),
121+
],
122+
};
123+
124+
m.render(document.body, m(TestComponent));
125+
expect(receivedValues).toEqual(['default', 'foo', 'bar', 'default']);
126+
});
127+
128+
test('different contexts are independent', () => {
129+
const Context1 = createContext('default1');
130+
const Context2 = createContext('default2');
131+
132+
const receivedValues: string[] = [];
133+
const TestComponent = {
134+
view: () =>
135+
m(Context1.Provider, {value: 'value1'}, [
136+
m(Context2.Provider, {value: 'value2'}, [
137+
m(Context1.Consumer, (value) => {
138+
receivedValues.push(value);
139+
}),
140+
m(Context2.Consumer, (value) => {
141+
receivedValues.push(value);
142+
}),
143+
]),
144+
]),
145+
};
146+
147+
m.render(document.body, m(TestComponent));
148+
expect(receivedValues).toEqual(['value1', 'value2']);
149+
});
150+
151+
test('works with complex types', () => {
152+
interface User {
153+
name: string;
154+
age: number;
155+
}
156+
157+
const {Provider, Consumer} = createContext<User>({name: 'Default', age: 0});
158+
159+
let receivedValue: User | undefined;
160+
const TestComponent = {
161+
view: () =>
162+
m(
163+
Provider,
164+
{value: {name: 'Alice', age: 30}},
165+
m(Consumer, (value) => {
166+
receivedValue = value;
167+
}),
168+
),
169+
};
170+
171+
m.render(document.body, m(TestComponent));
172+
expect(receivedValue).toEqual({name: 'Alice', age: 30});
173+
});
174+
175+
test('handles null values', () => {
176+
const {Provider, Consumer} = createContext<string | null>('default');
177+
178+
let receivedValue: string | null = 'sentinel';
179+
const TestComponent = {
180+
view: () =>
181+
m(
182+
Provider,
183+
{value: null},
184+
m(Consumer, (value) => {
185+
receivedValue = value;
186+
}),
187+
),
188+
};
189+
190+
m.render(document.body, m(TestComponent));
191+
expect(receivedValue).toBeNull();
192+
});
193+
});

0 commit comments

Comments
 (0)