-
Notifications
You must be signed in to change notification settings - Fork 628
[docs] Document the theming of Components #1608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| --- | ||
| title: Theming Components | ||
| tags: | ||
| - react | ||
| - javascript | ||
| - theming | ||
| - theme | ||
| description: How to theme React Components | ||
| --- | ||
|
|
||
| <Since version="5.2" issueNumber="MDL-88507" /> | ||
|
|
||
| Moodle supports the theming of React components using dynamic path-resolution for React components based on the user's active theme. It allows theme developers to fully override React components for both Moodle core, and any Moodle plugin, while providing mechanisms to extend or fall back to original implementations. | ||
|
|
||
| ## How Path Resolution Works | ||
|
|
||
| When a component is requested using the standard naming format (`@moodle/lms/<component>/<path>`), the system dynamically checks the user's **current theme** before falling back to the standard location. | ||
|
|
||
| ### Resolution Logic | ||
|
|
||
| If a user's current theme is set to **`boost`**, a request for `@moodle/lms/core/ExampleComponent` triggers the following lookup sequence: | ||
|
|
||
| 1. Check for a theme-specific override: | ||
|
|
||
| ``` | ||
| public/theme/boost/js/esm/src/overrides/core/ExampleComponent.tsx | ||
| ``` | ||
|
|
||
| 2. Fall back to Core (if no theme overrides exist) | ||
|
|
||
| ``` | ||
| public/lib/js/esm/src/ExampleComponent.tsx | ||
| ``` | ||
|
|
||
| Similarly, if a user's current theme is set to **`classic`**, a request for `@moodle/lms/mod_assign/local/grader/GradingPanel` triggers the following lookup sequence: | ||
|
|
||
| 1. Check for a theme-specific override: | ||
|
|
||
| ``` | ||
| public/theme/classic/js/esm/src/overrides/mod_assign/local/grader/GradingPanel.tsx | ||
| ``` | ||
|
|
||
| 2. Fall back to the plugin (if no theme overrides exist) | ||
|
|
||
| ``` | ||
| public/mod_assign/js/esm/src/local/grader/GradingPanel.tsx | ||
| ``` | ||
|
|
||
| :::danger[Theme parent fallbacks] | ||
|
|
||
| It is worth noting that components **do not** fall back to a parent theme. | ||
|
|
||
| A request for a component in a child theme **does not** check the parent theme automatically. | ||
|
|
||
| ::: | ||
|
|
||
| ### The Override File Structure | ||
|
|
||
| To override a component, the file must be placed within the theme's override directory using the following structure: | ||
|
|
||
| ```text | ||
| public/theme/<themename>/js/esm/src/overrides/<component>/<path> | ||
| ``` | ||
|
|
||
| ## Importing Original or Parent Components | ||
|
|
||
| Theme developers often need to wrap or extend an existing component rather than rewriting it from scratch. To prevent infinite loop resolution errors, specific aliases are provided to target the core or a parent theme explicitly. | ||
|
|
||
| ### Targeting the Core/Original Component | ||
|
|
||
| To pull in the un-themed, baseline version of a component, use the `theme-original` scope modifier: | ||
|
|
||
| ```typescript title="public/theme/boost/js/esm/src/overrides/core/ExampleComponent.ts" | ||
| import {ExampleComponent as OriginalComponent} from '@moodle/lms/theme-original/core/ExampleComponent'; | ||
|
|
||
| export const ExampleComponent = (props) => { | ||
| // Add custom theme wrappers around the original core component | ||
| return ( | ||
| <div className="boost-theme-wrapper"> | ||
| <OriginalComponent {...props}/> | ||
| </div> | ||
| ); | ||
| }; | ||
| ``` | ||
|
|
||
| ### Targeting a Parent Theme Component | ||
|
|
||
| If your theme inherits from a hierarchy of themes, you can choose which overrides to attempt to load. For example, in the given theme structure: | ||
|
|
||
| import FileTree from '@site/src/components/FileTree'; | ||
|
|
||
| <FileTree structure={{ | ||
| "boost": { | ||
| "classic": { | ||
| "retro": "Your theme is the grandchild" | ||
| } | ||
| } | ||
| }} /> | ||
|
|
||
| You can explicitly fetch either: | ||
|
|
||
| - a version for the `classic` theme using `@moodle/lms/theme-classic/mod_myfirstplugin/ExampleComponent`; or | ||
| - a version for the `boost` theme using `@moodle/lms/theme-boost/mod_myfirstplugin/ExampleComponent`; or | ||
| - the version shipped with the plugin using `@moodle/lms/theme-original/mod_myfirstplugin/ExampleComponent`. | ||
|
|
||
| ```typescript title="public/theme/retro/js/esm/src/overrides/mod_myfirstplugin/ExampleComponent.ts" | ||
| import {ExampleComponent as ClassicComponent} from '@moodle/lms/theme-classic/mod_myfirstplugin/ExampleComponent'; | ||
|
|
||
| export const ExampleComponent = (props) => { | ||
| // Extend the classic theme's implementation | ||
| return <ClassicComponent boostEnhanced={true} {...props}/>; | ||
| }; | ||
| ``` | ||
|
|
||
| ## Loading components from an override | ||
|
|
||
| When overriding a component you must be careful not to introduce circular dependencies by loading the themed version of the _same_ component. | ||
|
|
||
| :::critical | ||
|
|
||
| Never use the bare `@moodle/lms/` alias to load the wrapped code inside an override file to reference the component you are overriding. | ||
|
|
||
| ::: | ||
|
|
||
| ```typescript title="public/theme/retro/js/esm/src/overrdies/mod_myfirstplugin/ExampleComponent.ts" | ||
| // We must use the `theme-original` variant to load the original here: | ||
| import { | ||
| ExampleComponent as OriginalComponent, | ||
| } from '@moodle/lms/theme-original/mod_myfirstplugin/ExampleComponent'; | ||
|
|
||
| // But for loading _other_ components, we can use the standard import: | ||
| import {UnrelatedComponent} from '@moodle/lms/mod_myfirstplugin/UnrelatedComponent'; | ||
|
|
||
| export const ExampleComponent = (props) => { | ||
| return ( | ||
| <UnrelatedComponent> | ||
| <OriginalComponent boostEnhanced={true} {...props}/>; | ||
| </UnrelatedComponent> | ||
| ); | ||
| }; | ||
|
|
||
| ``` | ||
|
|
||
| ## Testing theme overrides | ||
|
|
||
| You can write tests for your theme-specific overrides in the same way that tests can be written for other components. | ||
|
|
||
| When importing your component under test, you must use the `theme-<themename>` import, for example: | ||
|
|
||
| ```typescript title="public/theme/boost/js/esm/tests/overrides/core/ExampleComponent.test.ts" | ||
| import {ExampleComponent} from '@moodle/lms/theme-boost/core/ExampleComponent'; | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| /** | ||
| * Copyright (c) Moodle Pty Ltd. | ||
| * | ||
| * Moodle is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * Moodle is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with Moodle. If not, see <http://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| import React from 'react'; | ||
| import CodeBlock from '@theme/CodeBlock'; | ||
|
|
||
| function parseObjectTree(tree: DirectoryStructure, prefix = ''): string { | ||
| const keys = Object.keys(tree); | ||
| let result = ''; | ||
|
|
||
| keys.forEach((key, index) => { | ||
| const isLast = index === keys.length - 1; | ||
| const pointer = isLast ? '└── ' : '├── '; | ||
| const nextPrefix = prefix + (isLast ? ' ' : '│ '); | ||
|
|
||
| const value = tree[key]; | ||
|
|
||
| let comment = ''; | ||
| let children: DirectoryStructure | undefined; | ||
|
|
||
| // Determine the format at runtime | ||
| if (value !== null && typeof value === 'object') { | ||
| if ('_comment' in value || '_children' in value) { | ||
| // It's using the expanded object format | ||
| const expanded = value as ExpandedItem; | ||
| comment = expanded._comment ? ` # ${expanded._comment}` : ''; // eslint-disable-line no-underscore-dangle | ||
| children = expanded._children; // eslint-disable-line no-underscore-dangle | ||
| } else { | ||
| // It's using the simple nested directory format | ||
| children = value as DirectoryStructure; | ||
| } | ||
| } else if (typeof value === 'string') { | ||
| // Inline shorthand string comment support: "filename": "comment string" | ||
| comment = ` # ${value}`; | ||
| } | ||
|
|
||
| // 1. Append the node line | ||
| result += `${prefix}${pointer}${key}${comment}\n`; | ||
|
|
||
| // 2. Recurse into children if they exist | ||
| if (children) { | ||
| result += parseObjectTree(children, nextPrefix); | ||
| } | ||
| }); | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| type FileValue = string | null; | ||
|
|
||
| export interface ExpandedItem { | ||
| _comment?: string; | ||
| _children?: DirectoryStructure; | ||
| } | ||
|
|
||
| // A node can now be a primitive file, a simple nested directory, or an expanded item | ||
| export type TreeItem = FileValue | DirectoryStructure | ExpandedItem; | ||
|
|
||
| export interface DirectoryStructure { | ||
| [nodeName: string]: TreeItem; | ||
| } | ||
|
|
||
| export type FileTreeProps = { | ||
| structure: DirectoryStructure; | ||
| }; | ||
|
|
||
| export default function FileTree({ structure }: FileTreeProps): JSX.Element { | ||
| return ( | ||
| <CodeBlock> | ||
| {parseObjectTree(structure)} | ||
| </CodeBlock> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.