diff --git a/README.md b/README.md
index fcd1d02b..84df17ad 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ For version info see [version map](https://github.com/FriendsOfCake/bootstrap-ui
- BreadcrumbsHelper
- HtmlHelper (components: `badge`, `icon`)
- PaginatorHelper
+- ColorModeHelper (Bootstrap 5.3 `data-bs-theme` light/dark/auto support)
- Widgets (`basic`, `button`, `datetime`, `file`, `select`, `textarea`)
- Example layouts (`cover`, `signin`, `dashboard`)
- Bake templates
@@ -1318,6 +1319,67 @@ This would generate the following HTML:
```
+### Color modes (light/dark/auto)
+
+Bootstrap 5.3 introduced built-in [color modes](https://getbootstrap.com/docs/5.3/customize/color-modes/)
+driven by the `data-bs-theme` attribute. BootstrapUI ships a `ColorMode` helper
+that adds the two missing pieces: an inline script that applies the user's
+stored preference on page load (preventing a flash of unstyled theme) and a
+ready-made toggle UI.
+
+```php
+// In your layout's
, as early as possible:
+= $this->ColorMode->script() ?>
+
+// In your navbar (or wherever you want the switcher):
+= $this->ColorMode->toggle() ?>
+```
+
+`script()` sets `data-bs-theme="light|dark"` on the `` element based on
+either the value stored under the `bs-theme` key in `localStorage` or, if
+there isn't one, the user's OS preference (the default mode is `auto`). It
+also exposes a small public API for app code to read or change the current
+mode:
+
+```js
+BootstrapUIColorMode.get(); // 'light' | 'dark' | 'auto'
+BootstrapUIColorMode.set('dark'); // persists and applies
+document.addEventListener('bs-theme-changed', (e) => {
+ console.log(e.detail.mode, e.detail.resolved);
+});
+```
+
+`toggle()` renders a Bootstrap button group with one button per mode. Clicking
+a button calls `BootstrapUIColorMode.set()`, updates the active state, and
+fires the `bs-theme-changed` event.
+
+The helper accepts overrides either at construction (via `loadHelper()`) or
+per call:
+
+```php
+use BootstrapUI\View\Helper\Enum\ColorMode;
+
+echo $this->ColorMode->toggle([
+ 'modes' => [ColorMode::Light, ColorMode::Dark], // hide 'auto'
+ 'wrapperClass' => 'btn-group', // default is `btn-group btn-group-sm`
+ 'ariaLabel' => __('Farbschema'),
+]);
+```
+
+`modes` and `default` accept either `ColorMode` enum cases (`ColorMode::Light`,
+`ColorMode::Dark`, `ColorMode::Auto`) or the equivalent strings (`'light'`,
+`'dark'`, `'auto'`). The rendered markup uses the string value either way.
+
+Button labels come from the enum: `ColorMode` implements CakePHP's
+`EnumLabelInterface`, so `Light`/`Dark`/`Auto` are translated through the
+standard `__()` catalog (same mechanism `FormHelper` uses for enum-backed
+selects). To localize them, translate the strings `Light`, `Dark`, and `Auto`
+in your app's translation files. Custom theme modes passed as strings render
+their label via `ucfirst()` as a default.
+
+Supported config keys: `storageKey`, `default`, `target`, `modes`,
+`ariaLabel`, `wrapperClass`, `buttonClass`, `activeClass`.
+
### Helper configuration
You can configure each of the helpers by passing in extra parameters when loading them in your `AppView.php`.
diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php
new file mode 100644
index 00000000..bb3a3f93
--- /dev/null
+++ b/src/View/Helper/ColorModeHelper.php
@@ -0,0 +1,227 @@
+ColorMode->script()` near the top of `` and
+ * `$this->ColorMode->toggle()` wherever you want the switcher (e.g. navbar).
+ *
+ * @extends \Cake\View\Helper<\Cake\View\View>
+ */
+class ColorModeHelper extends Helper
+{
+ /**
+ * @var array
+ */
+ protected array $_defaultConfig = [
+ // localStorage key under which the user's choice is persisted.
+ 'storageKey' => 'bs-theme',
+ // Mode used when nothing is stored yet. Accepts either a ColorMode
+ // case or its string value ("light", "dark", "auto").
+ 'default' => ColorMode::Auto,
+ // CSS selector for the element that carries `data-bs-theme`.
+ // Default `html` matches the BS5.3 documentation pattern.
+ 'target' => 'html',
+ // Modes (and their order) shown in the toggle. `null` (the default)
+ // renders all `ColorMode` cases in declaration order. Pass an array to
+ // reorder or subset, e.g. `[ColorMode::Dark, ColorMode::Light]` to drop
+ // `auto`. Each entry may be a ColorMode case or a string; cases provide
+ // their label via `EnumLabelInterface`, raw strings fall back to
+ // `ucfirst($value)`. Override labels via the standard i18n catalog.
+ 'modes' => null,
+ // ARIA label for the toggle group.
+ 'ariaLabel' => 'Color mode',
+ // Default CSS classes for the toggle wrapper (BS5 btn-group).
+ 'wrapperClass' => 'btn-group btn-group-sm',
+ // Default CSS classes applied to each toggle button (without active state).
+ 'buttonClass' => 'btn btn-outline-secondary',
+ // Class added to the currently-selected button. Bootstrap's `.active`
+ // pairs naturally with `btn-outline-*`.
+ 'activeClass' => 'active',
+ ];
+
+ /**
+ * Emit an inline `';
+ }
+
+ /**
+ * Render a Bootstrap button-group color-mode toggle. Clicking a button
+ * calls `BootstrapUIColorMode.set('light'|'dark'|'auto')`, which both
+ * persists the choice and applies it. The selected button is rendered with
+ * the configured active class so screen readers and visual users agree on
+ * the current state.
+ *
+ * @param array $options Per-call overrides for the helper config.
+ * @return string
+ */
+ public function toggle(array $options = []): string
+ {
+ $config = $options + $this->getConfig();
+ $modes = $config['modes'] ?? ColorMode::cases();
+
+ $wrapperAttrs = [
+ 'class' => $config['wrapperClass'],
+ 'role' => 'group',
+ 'aria-label' => $config['ariaLabel'],
+ 'data-bs-theme-toggle' => '1',
+ ];
+
+ $buttons = '';
+ foreach ($modes as $mode) {
+ $value = $this->_modeValue($mode);
+ $label = $this->_modeLabel($mode);
+ $buttons .= $this->_button($value, $label, (string)$config['buttonClass']);
+ }
+
+ $initJs = '(function(){'
+ . 'var w=document.currentScript&&document.currentScript.previousElementSibling;'
+ . 'if(!w||!w.matches("[data-bs-theme-toggle]")){return;}'
+ . 'var active=(window.BootstrapUIColorMode&&BootstrapUIColorMode.get())||'
+ . $this->_jsonValue($this->_modeValue($config['default']))
+ . ';'
+ . 'var ac=' . $this->_jsonValue($config['activeClass']) . ';'
+ . 'function sync(active){w.querySelectorAll("[data-bs-theme-value]").forEach(function(x){'
+ . 'var on=x.getAttribute("data-bs-theme-value")===active;'
+ . 'x.classList.toggle(ac,on);x.setAttribute("aria-pressed",on?"true":"false");});}'
+ . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(b){'
+ . 'b.addEventListener("click",function(){var v=b.getAttribute("data-bs-theme-value");'
+ . 'if(window.BootstrapUIColorMode){BootstrapUIColorMode.set(v);}sync(v);});});'
+ . 'sync(active);'
+ . '})();';
+
+ return $this->_wrap($wrapperAttrs, $buttons) . '';
+ }
+
+ /**
+ * Render the wrapper element for the toggle.
+ *
+ * @param array $attrs Wrapper attributes.
+ * @param string $content Inner HTML.
+ * @return string
+ */
+ protected function _wrap(array $attrs, string $content): string
+ {
+ $rendered = '';
+ foreach ($attrs as $name => $value) {
+ $rendered .= ' ' . $name . '="' . h($value) . '"';
+ }
+
+ return '
' . $content . '
';
+ }
+
+ /**
+ * Render a single toggle button.
+ *
+ * @param string $value The data-bs-theme-value (also the storage value).
+ * @param string $label Visible label.
+ * @param string $buttonClass Button CSS classes (without active state).
+ * @return string
+ */
+ protected function _button(string $value, string $label, string $buttonClass): string
+ {
+ return '';
+ }
+
+ /**
+ * Coerce a mode value to its string form. Accepts a ColorMode case or a
+ * raw string ("light", "dark", "auto", or a custom string for apps that
+ * register additional themes via the `modes` config).
+ *
+ * @param \BootstrapUI\View\Helper\Enum\ColorMode|string $mode Mode value.
+ * @return string String identifier (e.g. `'light'`). Raw strings are
+ * returned as-is so apps registering custom themes keep their casing.
+ */
+ protected function _modeValue(ColorMode|string $mode): string
+ {
+ return $mode instanceof ColorMode ? $mode->value : $mode;
+ }
+
+ /**
+ * Encode a scalar config value for safe inlining inside a `` (accidentally or otherwise).
+ *
+ * @param scalar $value Value to encode.
+ * @return string
+ */
+ protected function _jsonValue(mixed $value): string
+ {
+ return (string)json_encode(
+ $value,
+ JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR,
+ );
+ }
+
+ /**
+ * Resolve the visible label for a mode. ColorMode cases delegate to their
+ * `label()` (translated via the i18n catalog); raw strings fall back to
+ * `ucfirst($value)` so apps registering custom themes still get a sensible
+ * default.
+ *
+ * @param \BootstrapUI\View\Helper\Enum\ColorMode|string $mode Mode value.
+ * @return string
+ */
+ protected function _modeLabel(ColorMode|string $mode): string
+ {
+ if ($mode instanceof ColorMode) {
+ return $mode->label();
+ }
+
+ return ucfirst($mode);
+ }
+}
diff --git a/src/View/Helper/Enum/ColorMode.php b/src/View/Helper/Enum/ColorMode.php
new file mode 100644
index 00000000..6e6e45df
--- /dev/null
+++ b/src/View/Helper/Enum/ColorMode.php
@@ -0,0 +1,37 @@
+ __('Light'),
+ self::Dark => __('Dark'),
+ self::Auto => __('Auto'),
+ };
+ }
+}
diff --git a/src/View/UIView.php b/src/View/UIView.php
index 8fe06515..529a92cc 100644
--- a/src/View/UIView.php
+++ b/src/View/UIView.php
@@ -16,6 +16,7 @@
* @property \BootstrapUI\View\Helper\HtmlHelper $Html
* @property \BootstrapUI\View\Helper\PaginatorHelper $Paginator
* @property \BootstrapUI\View\Helper\BreadcrumbsHelper $Breadcrumbs
+ * @property \BootstrapUI\View\Helper\ColorModeHelper $ColorMode
*/
class UIView extends View
{
diff --git a/src/View/UIViewTrait.php b/src/View/UIViewTrait.php
index 57ac1c9d..fc596b12 100644
--- a/src/View/UIViewTrait.php
+++ b/src/View/UIViewTrait.php
@@ -36,6 +36,7 @@ public function initializeUI(array $options = []): void
'Flash' => ['className' => 'BootstrapUI.Flash'],
'Paginator' => ['className' => 'BootstrapUI.Paginator'],
'Breadcrumbs' => ['className' => 'BootstrapUI.Breadcrumbs'],
+ 'ColorMode' => ['className' => 'BootstrapUI.ColorMode'],
];
$this->helpers = array_merge($helpers, $this->helpers);
diff --git a/tests/TestCase/View/Helper/ColorModeHelperTest.php b/tests/TestCase/View/Helper/ColorModeHelperTest.php
new file mode 100644
index 00000000..5d5b4b15
--- /dev/null
+++ b/tests/TestCase/View/Helper/ColorModeHelperTest.php
@@ -0,0 +1,238 @@
+ColorMode = new ColorModeHelper(new View());
+ }
+
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ unset($this->ColorMode);
+ }
+
+ public function testScriptDefaults(): void
+ {
+ $html = $this->ColorMode->script();
+
+ $this->assertStringStartsWith('', $html);
+ // Default config tokens are baked into the inline JS.
+ $this->assertStringContainsString('"bs-theme"', $html);
+ $this->assertStringContainsString('"auto"', $html);
+ $this->assertStringContainsString('"html"', $html);
+ // Public API surface is exposed.
+ $this->assertStringContainsString('window.BootstrapUIColorMode', $html);
+ // Storage / OS preference wiring is present.
+ $this->assertStringContainsString('localStorage', $html);
+ $this->assertStringContainsString('prefers-color-scheme', $html);
+ // The actual theme attribute is set on the target.
+ $this->assertStringContainsString('data-bs-theme', $html);
+ }
+
+ public function testScriptCustomConfigViaCallArgs(): void
+ {
+ $html = $this->ColorMode->script([
+ 'storageKey' => 'mytheme',
+ 'default' => 'dark',
+ 'target' => 'body',
+ ]);
+
+ $this->assertStringContainsString('"mytheme"', $html);
+ $this->assertStringContainsString('"dark"', $html);
+ $this->assertStringContainsString('"body"', $html);
+ // The shipped defaults should not leak through when overridden.
+ $this->assertStringNotContainsString('"bs-theme"', $html);
+ }
+
+ public function testScriptHonoursHelperSetConfig(): void
+ {
+ $this->ColorMode->setConfig('storageKey', 'persistent-key');
+ $html = $this->ColorMode->script();
+
+ $this->assertStringContainsString('"persistent-key"', $html);
+ }
+
+ public function testToggleDefaults(): void
+ {
+ $html = $this->ColorMode->toggle();
+
+ // Wrapper + ARIA semantics
+ $this->assertStringContainsString('data-bs-theme-toggle', $html);
+ $this->assertStringContainsString('role="group"', $html);
+ $this->assertStringContainsString('aria-label="Color mode"', $html);
+ $this->assertStringContainsString('class="btn-group btn-group-sm"', $html);
+
+ // All three default modes rendered, in order, with default labels.
+ $this->assertMatchesRegularExpression(
+ '/data-bs-theme-value="light"[^>]*>Light<.*'
+ . 'data-bs-theme-value="dark"[^>]*>Dark<.*'
+ . 'data-bs-theme-value="auto"[^>]*>AutoassertSame(3, substr_count($html, 'aria-pressed="false"'));
+
+ // A trailing inline script wires up clicks.
+ $this->assertStringContainsString('BootstrapUIColorMode.set', $html);
+ }
+
+ public function testToggleCustomModesAndAria(): void
+ {
+ $html = $this->ColorMode->toggle([
+ 'modes' => ['dark', 'light'],
+ 'wrapperClass' => 'btn-group',
+ 'ariaLabel' => 'Farbschema',
+ ]);
+
+ $this->assertStringContainsString('aria-label="Farbschema"', $html);
+ $this->assertStringContainsString('class="btn-group"', $html);
+ // Default mode order is overridden; `auto` is intentionally omitted.
+ $this->assertMatchesRegularExpression(
+ '/data-bs-theme-value="dark".*data-bs-theme-value="light"/s',
+ $html,
+ );
+ $this->assertStringNotContainsString('data-bs-theme-value="auto"', $html);
+ }
+
+ /**
+ * Custom theme strings (non-enum modes) are escaped both in the attribute
+ * value and the rendered label (which falls back to `ucfirst($mode)`).
+ */
+ public function testToggleEscapesCustomModeString(): void
+ {
+ $html = $this->ColorMode->toggle([
+ 'modes' => ['light'],
+ ]);
+
+ $this->assertStringNotContainsString('', $html);
+ $this->assertStringContainsString('<script>alert(1)</script>', $html);
+ }
+
+ /**
+ * The enum exposes labels via `EnumLabelInterface`, so the helper picks
+ * them up automatically — no `labels` config involved.
+ */
+ public function testEnumProvidesLabels(): void
+ {
+ $this->assertSame('Light', ColorMode::Light->label());
+ $this->assertSame('Dark', ColorMode::Dark->label());
+ $this->assertSame('Auto', ColorMode::Auto->label());
+ }
+
+ /**
+ * Modes accept ColorMode enum cases interchangeably with their string
+ * values. The rendered markup uses the string value either way.
+ */
+ public function testModesAcceptEnumCases(): void
+ {
+ $html = $this->ColorMode->toggle([
+ 'modes' => [ColorMode::Dark, ColorMode::Light],
+ 'default' => ColorMode::Dark,
+ ]);
+
+ $this->assertStringContainsString('data-bs-theme-value="dark"', $html);
+ $this->assertStringContainsString('data-bs-theme-value="light"', $html);
+ // No `auto` because the config omits it.
+ $this->assertStringNotContainsString('data-bs-theme-value="auto"', $html);
+ }
+
+ /**
+ * The `script()` output is unchanged when the default mode is passed as
+ * an enum case vs the equivalent string.
+ */
+ public function testScriptDefaultAcceptsEnumCase(): void
+ {
+ $fromEnum = $this->ColorMode->script(['default' => ColorMode::Dark]);
+ $fromString = $this->ColorMode->script(['default' => 'dark']);
+
+ $this->assertSame($fromEnum, $fromString);
+ $this->assertStringContainsString('"dark"', $fromEnum);
+ }
+
+ /**
+ * Config values inlined into `` in (e.g.) a custom `storageKey` cannot terminate
+ * the surrounding script element.
+ */
+ public function testScriptEscapesScriptTagInConfigValues(): void
+ {
+ $html = $this->ColorMode->script([
+ 'storageKey' => 'ab',
+ ]);
+
+ // Raw `` from config must not appear unescaped anywhere.
+ $this->assertStringNotContainsString('ab', $html);
+ // The hex-encoded form is what actually lands in the JS string.
+ $expected = json_encode(
+ 'ab',
+ JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
+ );
+ $this->assertIsString($expected);
+ $this->assertStringContainsString(trim($expected, '"'), $html);
+ // Output still terminates with the legitimate closing tag.
+ $this->assertStringEndsWith('', $html);
+ }
+
+ /**
+ * The page-load script must not throw if `localStorage` access is blocked
+ * (private mode, security policy) — otherwise the FOUC prevention itself
+ * becomes the cause of the FOUC.
+ */
+ public function testScriptGuardsLocalStorageAccess(): void
+ {
+ $html = $this->ColorMode->script();
+
+ $this->assertMatchesRegularExpression('/try\{return localStorage\.getItem/', $html);
+ $this->assertMatchesRegularExpression('/try\{localStorage\.setItem/', $html);
+ }
+
+ /**
+ * The toggle's click handler must keep `aria-pressed` in sync with the
+ * active class so assistive tech sees the same state as visual users.
+ */
+ public function testToggleSyncsAriaPressedOnClick(): void
+ {
+ $html = $this->ColorMode->toggle();
+
+ // Both class and aria-pressed are set together via the sync helper.
+ $this->assertStringContainsString('setAttribute("aria-pressed"', $html);
+ $this->assertMatchesRegularExpression('/classList\.toggle\(ac,on\)/', $html);
+ }
+
+ /**
+ * Toggle's inlined config values are also ``-safe.
+ */
+ public function testToggleEscapesScriptTagInActiveClass(): void
+ {
+ $html = $this->ColorMode->toggle([
+ 'activeClass' => 'ab',
+ ]);
+
+ $this->assertStringNotContainsString('ab', $html);
+ $expected = json_encode(
+ 'ab',
+ JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
+ );
+ $this->assertIsString($expected);
+ $this->assertStringContainsString(trim($expected, '"'), $html);
+ }
+}
diff --git a/tests/TestCase/View/UIViewTest.php b/tests/TestCase/View/UIViewTest.php
index 68827088..4b1696be 100644
--- a/tests/TestCase/View/UIViewTest.php
+++ b/tests/TestCase/View/UIViewTest.php
@@ -3,6 +3,7 @@
namespace BootstrapUI\View;
+use BootstrapUI\View\Helper\ColorModeHelper;
use Cake\TestSuite\TestCase;
class UIViewTest extends TestCase
@@ -65,4 +66,18 @@ public function testHelperConfig()
$this->assertEquals('bar', $View->Form->getConfig('foo'));
}
+
+ /**
+ * The new ColorMode helper must be auto-loaded by UIViewTrait so that
+ * `$this->ColorMode->script()` and `->toggle()` are available without
+ * extra app wiring.
+ */
+ public function testColorModeHelperIsAutoLoaded()
+ {
+ $this->View->initialize();
+ $this->assertInstanceOf(
+ ColorModeHelper::class,
+ $this->View->ColorMode,
+ );
+ }
}