From c18dffd77a25a8b76ffdc90525f6ab74993d70b0 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 11 May 2026 14:29:26 +0200 Subject: [PATCH 1/8] Add Bootstrap 5.3 color mode (data-bs-theme) support. A new ColorModeHelper provides the two pieces that have been missing for color-mode adoption since the BS5.3 migration: - `script()` emits an inline IIFE that reads the user's stored preference from localStorage (falling back to the OS preference for `auto`), applies `data-bs-theme` to the configured target (default ``), re-resolves on `prefers-color-scheme` changes while in auto mode, and exposes a tiny public API (`window.BootstrapUIColorMode.{get,set}` plus a `bs-theme-changed` DOM event). Placed in `` it prevents the flash of unstyled theme. - `toggle()` renders a Bootstrap button group with one button per configured mode. Clicks call `BootstrapUIColorMode.set()`, update the active state, and announce the change via the event. Configurable: storageKey, default, target, modes, labels (i18n hook via `__()`), ariaLabel, wrapperClass, buttonClass, activeClass. The helper is auto-loaded by `UIViewTrait` as `$this->ColorMode`. README has a new "Color modes" section under Usage. Tests cover default and custom-config rendering for both methods and verify user-supplied labels are HTML-escaped. --- README.md | 50 +++++ src/View/Helper/ColorModeHelper.php | 176 ++++++++++++++++++ src/View/UIView.php | 1 + src/View/UIViewTrait.php | 1 + .../View/Helper/ColorModeHelperTest.php | 124 ++++++++++++ tests/TestCase/View/UIViewTest.php | 15 ++ 6 files changed, 367 insertions(+) create mode 100644 src/View/Helper/ColorModeHelper.php create mode 100644 tests/TestCase/View/Helper/ColorModeHelperTest.php diff --git a/README.md b/README.md index fcd1d02b..0aa6782f 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,55 @@ 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: +ColorMode->script() ?> + +// In your navbar (or wherever you want the switcher): +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 +echo $this->ColorMode->toggle([ + 'modes' => ['light', 'dark'], // hide 'auto' + 'labels' => ['light' => __('Tag'), 'dark' => __('Nacht')], + 'wrapperClass' => 'btn-group', // default is `btn-group btn-group-sm` + 'ariaLabel' => __('Farbschema'), +]); +``` + +Supported config keys: `storageKey`, `default`, `target`, `modes`, `labels`, +`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..c8b05c87 --- /dev/null +++ b/src/View/Helper/ColorModeHelper.php @@ -0,0 +1,176 @@ +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 +{ + public const MODE_LIGHT = 'light'; + public const MODE_DARK = 'dark'; + public const MODE_AUTO = 'auto'; + + /** + * @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. + 'default' => self::MODE_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. + 'modes' => [self::MODE_LIGHT, self::MODE_DARK, self::MODE_AUTO], + // Labels shown on the toggle buttons. Override for i18n. + 'labels' => [ + self::MODE_LIGHT => 'Light', + self::MODE_DARK => 'Dark', + self::MODE_AUTO => 'Auto', + ], + // 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 = (array)$config['modes']; + $labels = (array)$config['labels']; + + $wrapperAttrs = [ + 'class' => $config['wrapperClass'], + 'role' => 'group', + 'aria-label' => $config['ariaLabel'], + 'data-bs-theme-toggle' => '1', + ]; + + $buttons = ''; + foreach ($modes as $mode) { + $label = $labels[$mode] ?? ucfirst((string)$mode); + $buttons .= $this->_button((string)$mode, (string)$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())||' . json_encode($config['default']) . ';' + . 'var ac=' . json_encode($config['activeClass']) . ';' + . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(b){' + . 'b.addEventListener("click",function(){if(window.BootstrapUIColorMode){BootstrapUIColorMode.set(b.getAttribute("data-bs-theme-value"));}' + . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(x){x.classList.toggle(ac,x===b);});});' + . 'if(b.getAttribute("data-bs-theme-value")===active){b.classList.add(ac);b.setAttribute("aria-pressed","true");}' + . 'else{b.setAttribute("aria-pressed","false");}' + . '});' + . '})();'; + + 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 ''; + } +} 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..3337b2a8 --- /dev/null +++ b/tests/TestCase/View/Helper/ColorModeHelperTest.php @@ -0,0 +1,124 @@ +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 testToggleCustomModesAndLabels(): void + { + $html = $this->ColorMode->toggle([ + 'modes' => ['dark', 'light'], + 'labels' => ['dark' => 'Nacht', 'light' => 'Tag'], + 'wrapperClass' => 'btn-group', + 'ariaLabel' => 'Farbschema', + ]); + + $this->assertStringContainsString('aria-label="Farbschema"', $html); + $this->assertStringContainsString('class="btn-group"', $html); + $this->assertStringContainsString('>Nacht<', $html); + $this->assertStringContainsString('>Tag<', $html); + // `auto` is intentionally omitted by config. + $this->assertStringNotContainsString('data-bs-theme-value="auto"', $html); + } + + public function testToggleEscapesUserSuppliedLabel(): void + { + $html = $this->ColorMode->toggle([ + 'modes' => ['light'], + 'labels' => ['light' => 'Light'], + ]); + + $this->assertStringNotContainsString('', $html); + $this->assertStringContainsString('<script>alert(1)</script>', $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, + ); + } } From 42df6c1d2bacd51e4d10ed48a3fb3b2ac27c407a Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 11 May 2026 17:09:20 +0200 Subject: [PATCH 2/8] Use statement spacing fix for the function import. --- src/View/Helper/ColorModeHelper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php index c8b05c87..5cf15944 100644 --- a/src/View/Helper/ColorModeHelper.php +++ b/src/View/Helper/ColorModeHelper.php @@ -4,7 +4,6 @@ namespace BootstrapUI\View\Helper; use Cake\View\Helper; - use function Cake\Core\h; /** From 35c8724e3d6da9f33af68648b6967b72ae826c8f Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 11 May 2026 17:09:47 +0200 Subject: [PATCH 3/8] Fix ColorModeHelper coding style --- src/View/Helper/ColorModeHelper.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php index 5cf15944..0dff7eaa 100644 --- a/src/View/Helper/ColorModeHelper.php +++ b/src/View/Helper/ColorModeHelper.php @@ -125,12 +125,16 @@ public function toggle(array $options = []): string $initJs = '(function(){' . 'var w=document.currentScript&&document.currentScript.previousElementSibling;' . 'if(!w||!w.matches("[data-bs-theme-toggle]")){return;}' - . 'var active=(window.BootstrapUIColorMode&&BootstrapUIColorMode.get())||' . json_encode($config['default']) . ';' + . 'var active=(window.BootstrapUIColorMode&&BootstrapUIColorMode.get())||' + . json_encode($config['default']) + . ';' . 'var ac=' . json_encode($config['activeClass']) . ';' . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(b){' - . 'b.addEventListener("click",function(){if(window.BootstrapUIColorMode){BootstrapUIColorMode.set(b.getAttribute("data-bs-theme-value"));}' + . 'b.addEventListener("click",function(){if(window.BootstrapUIColorMode){' + . 'BootstrapUIColorMode.set(b.getAttribute("data-bs-theme-value"));}' . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(x){x.classList.toggle(ac,x===b);});});' - . 'if(b.getAttribute("data-bs-theme-value")===active){b.classList.add(ac);b.setAttribute("aria-pressed","true");}' + . 'if(b.getAttribute("data-bs-theme-value")===active){' + . 'b.classList.add(ac);b.setAttribute("aria-pressed","true");}' . 'else{b.setAttribute("aria-pressed","false");}' . '});' . '})();'; From 21122316ac0ec40e4687dd3f13d160d39661b1d7 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 11 May 2026 19:31:13 +0200 Subject: [PATCH 4/8] Replace ColorMode constants with a backed-string enum. Per review: extract the three string constants (MODE_LIGHT, MODE_DARK, MODE_AUTO) into a BootstrapUI\View\Helper\ColorMode enum. The helper's `default` and `modes` config keys now accept either enum cases or plain strings interchangeably, so existing apps that pass `'light'` / `'dark'` / `'auto'` keep working while new code can use the typed API. A small `_modeValue()` helper coerces a value to its string form for places that render or serialize. Updates the README example to use the enum and notes that strings are still accepted. Adds tests covering enum usage on both `modes` and `default`. --- README.md | 8 +++- src/View/Helper/ColorMode.php | 18 ++++++++ src/View/Helper/ColorModeHelper.php | 45 ++++++++++++------- .../View/Helper/ColorModeHelperTest.php | 31 +++++++++++++ 4 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 src/View/Helper/ColorMode.php diff --git a/README.md b/README.md index 0aa6782f..77135b42 100644 --- a/README.md +++ b/README.md @@ -1357,14 +1357,20 @@ The helper accepts overrides either at construction (via `loadHelper()`) or per call: ```php +use BootstrapUI\View\Helper\ColorMode; + echo $this->ColorMode->toggle([ - 'modes' => ['light', 'dark'], // hide 'auto' + 'modes' => [ColorMode::Light, ColorMode::Dark], // hide 'auto' 'labels' => ['light' => __('Tag'), 'dark' => __('Nacht')], '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. + Supported config keys: `storageKey`, `default`, `target`, `modes`, `labels`, `ariaLabel`, `wrapperClass`, `buttonClass`, `activeClass`. diff --git a/src/View/Helper/ColorMode.php b/src/View/Helper/ColorMode.php new file mode 100644 index 00000000..d5be2f82 --- /dev/null +++ b/src/View/Helper/ColorMode.php @@ -0,0 +1,18 @@ + */ protected array $_defaultConfig = [ // localStorage key under which the user's choice is persisted. 'storageKey' => 'bs-theme', - // Mode used when nothing is stored yet. - 'default' => self::MODE_AUTO, + // 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. - 'modes' => [self::MODE_LIGHT, self::MODE_DARK, self::MODE_AUTO], - // Labels shown on the toggle buttons. Override for i18n. + // Modes (and their order) shown in the toggle. Each entry may be a + // ColorMode case or a string. + 'modes' => [ColorMode::Light, ColorMode::Dark, ColorMode::Auto], + // Labels shown on the toggle buttons. Keyed by the mode's string + // value. Override for i18n. 'labels' => [ - self::MODE_LIGHT => 'Light', - self::MODE_DARK => 'Dark', - self::MODE_AUTO => 'Auto', + 'light' => 'Light', + 'dark' => 'Dark', + 'auto' => 'Auto', ], // ARIA label for the toggle group. 'ariaLabel' => 'Color mode', @@ -72,7 +71,7 @@ public function script(array $options = []): string $config = $options + $this->getConfig(); $storageKey = json_encode($config['storageKey']); - $default = json_encode($config['default']); + $default = json_encode($this->_modeValue($config['default'])); $target = json_encode($config['target']); // Inline IIFE; ASCII-only so it survives any reasonable CSP/serializer. @@ -118,15 +117,16 @@ public function toggle(array $options = []): string $buttons = ''; foreach ($modes as $mode) { - $label = $labels[$mode] ?? ucfirst((string)$mode); - $buttons .= $this->_button((string)$mode, (string)$label, (string)$config['buttonClass']); + $value = $this->_modeValue($mode); + $label = $labels[$value] ?? ucfirst($value); + $buttons .= $this->_button($value, (string)$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())||' - . json_encode($config['default']) + . json_encode($this->_modeValue($config['default'])) . ';' . 'var ac=' . json_encode($config['activeClass']) . ';' . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(b){' @@ -176,4 +176,17 @@ protected function _button(string $value, string $label, string $buttonClass): s . h($label) . ''; } + + /** + * 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\ColorMode|string $mode Mode value. + * @return string Lower-case string identifier (e.g. `'light'`). + */ + protected function _modeValue(ColorMode|string $mode): string + { + return $mode instanceof ColorMode ? $mode->value : $mode; + } } diff --git a/tests/TestCase/View/Helper/ColorModeHelperTest.php b/tests/TestCase/View/Helper/ColorModeHelperTest.php index 3337b2a8..89e263b1 100644 --- a/tests/TestCase/View/Helper/ColorModeHelperTest.php +++ b/tests/TestCase/View/Helper/ColorModeHelperTest.php @@ -3,6 +3,7 @@ namespace BootstrapUI\Test\TestCase\View\Helper; +use BootstrapUI\View\Helper\ColorMode; use BootstrapUI\View\Helper\ColorModeHelper; use Cake\TestSuite\TestCase; use Cake\View\View; @@ -121,4 +122,34 @@ public function testToggleEscapesUserSuppliedLabel(): void $this->assertStringNotContainsString('', $html); $this->assertStringContainsString('<script>alert(1)</script>', $html); } + + /** + * 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); + } } From bb4413195c4db035d14af7031ba1c8f2a0b018ba Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 11 May 2026 19:41:23 +0200 Subject: [PATCH 5/8] Move ColorMode enum to Helper/Enum/ subfolder per convention. --- README.md | 2 +- src/View/Helper/ColorModeHelper.php | 3 ++- src/View/Helper/{ => Enum}/ColorMode.php | 2 +- tests/TestCase/View/Helper/ColorModeHelperTest.php | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) rename src/View/Helper/{ => Enum}/ColorMode.php (90%) diff --git a/README.md b/README.md index 77135b42..043f3bf0 100644 --- a/README.md +++ b/README.md @@ -1357,7 +1357,7 @@ The helper accepts overrides either at construction (via `loadHelper()`) or per call: ```php -use BootstrapUI\View\Helper\ColorMode; +use BootstrapUI\View\Helper\Enum\ColorMode; echo $this->ColorMode->toggle([ 'modes' => [ColorMode::Light, ColorMode::Dark], // hide 'auto' diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php index 7cf4213b..f7c12e70 100644 --- a/src/View/Helper/ColorModeHelper.php +++ b/src/View/Helper/ColorModeHelper.php @@ -3,6 +3,7 @@ namespace BootstrapUI\View\Helper; +use BootstrapUI\View\Helper\Enum\ColorMode; use Cake\View\Helper; use function Cake\Core\h; @@ -182,7 +183,7 @@ protected function _button(string $value, string $label, string $buttonClass): s * raw string ("light", "dark", "auto", or a custom string for apps that * register additional themes via the `modes` config). * - * @param \BootstrapUI\View\Helper\ColorMode|string $mode Mode value. + * @param \BootstrapUI\View\Helper\Enum\ColorMode|string $mode Mode value. * @return string Lower-case string identifier (e.g. `'light'`). */ protected function _modeValue(ColorMode|string $mode): string diff --git a/src/View/Helper/ColorMode.php b/src/View/Helper/Enum/ColorMode.php similarity index 90% rename from src/View/Helper/ColorMode.php rename to src/View/Helper/Enum/ColorMode.php index d5be2f82..66b07efb 100644 --- a/src/View/Helper/ColorMode.php +++ b/src/View/Helper/Enum/ColorMode.php @@ -1,7 +1,7 @@ Date: Mon, 11 May 2026 23:44:45 +0200 Subject: [PATCH 6/8] Use EnumLabelInterface for ColorMode labels. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ColorMode now implements Cake's EnumLabelInterface and provides its own translated label() — matches FormHelper's enum-label convention and removes the now-redundant 'labels' config on the helper. Custom string modes still fall back to ucfirst(). --- README.md | 10 ++++-- src/View/Helper/ColorModeHelper.php | 34 +++++++++++++------ src/View/Helper/Enum/ColorMode.php | 21 +++++++++++- .../View/Helper/ColorModeHelperTest.php | 31 ++++++++++++----- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 043f3bf0..84df17ad 100644 --- a/README.md +++ b/README.md @@ -1361,7 +1361,6 @@ use BootstrapUI\View\Helper\Enum\ColorMode; echo $this->ColorMode->toggle([ 'modes' => [ColorMode::Light, ColorMode::Dark], // hide 'auto' - 'labels' => ['light' => __('Tag'), 'dark' => __('Nacht')], 'wrapperClass' => 'btn-group', // default is `btn-group btn-group-sm` 'ariaLabel' => __('Farbschema'), ]); @@ -1371,7 +1370,14 @@ echo $this->ColorMode->toggle([ `ColorMode::Dark`, `ColorMode::Auto`) or the equivalent strings (`'light'`, `'dark'`, `'auto'`). The rendered markup uses the string value either way. -Supported config keys: `storageKey`, `default`, `target`, `modes`, `labels`, +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 diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php index f7c12e70..8711da37 100644 --- a/src/View/Helper/ColorModeHelper.php +++ b/src/View/Helper/ColorModeHelper.php @@ -33,15 +33,10 @@ class ColorModeHelper extends Helper // Default `html` matches the BS5.3 documentation pattern. 'target' => 'html', // Modes (and their order) shown in the toggle. Each entry may be a - // ColorMode case or a string. + // ColorMode case or a string. ColorMode cases provide their label + // via `EnumLabelInterface`; raw strings fall back to `ucfirst($value)`. + // Override labels via the standard i18n catalog. 'modes' => [ColorMode::Light, ColorMode::Dark, ColorMode::Auto], - // Labels shown on the toggle buttons. Keyed by the mode's string - // value. Override for i18n. - 'labels' => [ - 'light' => 'Light', - 'dark' => 'Dark', - 'auto' => 'Auto', - ], // ARIA label for the toggle group. 'ariaLabel' => 'Color mode', // Default CSS classes for the toggle wrapper (BS5 btn-group). @@ -107,7 +102,6 @@ public function toggle(array $options = []): string { $config = $options + $this->getConfig(); $modes = (array)$config['modes']; - $labels = (array)$config['labels']; $wrapperAttrs = [ 'class' => $config['wrapperClass'], @@ -119,8 +113,8 @@ public function toggle(array $options = []): string $buttons = ''; foreach ($modes as $mode) { $value = $this->_modeValue($mode); - $label = $labels[$value] ?? ucfirst($value); - $buttons .= $this->_button($value, (string)$label, (string)$config['buttonClass']); + $label = $this->_modeLabel($mode); + $buttons .= $this->_button($value, $label, (string)$config['buttonClass']); } $initJs = '(function(){' @@ -190,4 +184,22 @@ protected function _modeValue(ColorMode|string $mode): string { return $mode instanceof ColorMode ? $mode->value : $mode; } + + /** + * 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 index 66b07efb..6e6e45df 100644 --- a/src/View/Helper/Enum/ColorMode.php +++ b/src/View/Helper/Enum/ColorMode.php @@ -3,16 +3,35 @@ namespace BootstrapUI\View\Helper\Enum; +use Cake\Database\Type\EnumLabelInterface; +use function Cake\I18n\__; + /** * Bootstrap 5.3 color mode values. * * Used by `ColorModeHelper` for the `default` config key, the `modes` list, * and the persisted `data-bs-theme` attribute. String-backed so existing * string-based config still works ("light", "dark", "auto"). + * + * Implements `EnumLabelInterface` so the rendered label can be translated + * via the standard `__()` catalog and reused by `FormHelper` when the enum + * is bound to a form control. */ -enum ColorMode: string +enum ColorMode: string implements EnumLabelInterface { case Light = 'light'; case Dark = 'dark'; case Auto = 'auto'; + + /** + * @return string + */ + public function label(): string + { + return match ($this) { + self::Light => __('Light'), + self::Dark => __('Dark'), + self::Auto => __('Auto'), + }; + } } diff --git a/tests/TestCase/View/Helper/ColorModeHelperTest.php b/tests/TestCase/View/Helper/ColorModeHelperTest.php index 96aa07d6..526b4b71 100644 --- a/tests/TestCase/View/Helper/ColorModeHelperTest.php +++ b/tests/TestCase/View/Helper/ColorModeHelperTest.php @@ -95,34 +95,49 @@ public function testToggleDefaults(): void $this->assertStringContainsString('BootstrapUIColorMode.set', $html); } - public function testToggleCustomModesAndLabels(): void + public function testToggleCustomModesAndAria(): void { $html = $this->ColorMode->toggle([ 'modes' => ['dark', 'light'], - 'labels' => ['dark' => 'Nacht', 'light' => 'Tag'], 'wrapperClass' => 'btn-group', 'ariaLabel' => 'Farbschema', ]); $this->assertStringContainsString('aria-label="Farbschema"', $html); $this->assertStringContainsString('class="btn-group"', $html); - $this->assertStringContainsString('>Nacht<', $html); - $this->assertStringContainsString('>Tag<', $html); - // `auto` is intentionally omitted by config. + // 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); } - public function testToggleEscapesUserSuppliedLabel(): void + /** + * 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'], - 'labels' => ['light' => 'Light'], + '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. From 41a77557daa7554919a64603bdc28217c7ecdce4 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 17:45:33 +0200 Subject: [PATCH 7/8] Default modes to null, resolve to ColorMode::cases() at render time. The previous default literally duplicated ColorMode::cases(). Use null as the sentinel for "all cases in declaration order" and keep the config key for apps that want to reorder, subset, or add custom theme strings. --- src/View/Helper/ColorModeHelper.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php index 8711da37..d43e79b3 100644 --- a/src/View/Helper/ColorModeHelper.php +++ b/src/View/Helper/ColorModeHelper.php @@ -32,11 +32,13 @@ class ColorModeHelper extends Helper // 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. Each entry may be a - // ColorMode case or a string. ColorMode cases provide their label - // via `EnumLabelInterface`; raw strings fall back to `ucfirst($value)`. - // Override labels via the standard i18n catalog. - 'modes' => [ColorMode::Light, ColorMode::Dark, ColorMode::Auto], + // 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). @@ -101,7 +103,7 @@ public function script(array $options = []): string public function toggle(array $options = []): string { $config = $options + $this->getConfig(); - $modes = (array)$config['modes']; + $modes = $config['modes'] ?? ColorMode::cases(); $wrapperAttrs = [ 'class' => $config['wrapperClass'], From 0f5129b08eef40df2b722ee3ea53157dc890de9c Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 18:16:35 +0200 Subject: [PATCH 8/8] Harden ColorMode inline scripts: aria-pressed sync, JSON_HEX_TAG, localStorage guards. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toggle click handler now syncs aria-pressed alongside the active class via a shared sync() helper, so assistive tech sees the same state as visual users. - Config values inlined into in storageKey/target/activeClass cannot terminate the script tag. - localStorage.getItem/setItem calls wrapped in try/catch — when storage is blocked (private mode, security policy) the script still applies the theme rather than throwing and leaving an unstyled flash. - _modeValue() docblock no longer overpromises lower-casing; raw strings keep their casing so custom-theme registrations are preserved. --- src/View/Helper/ColorModeHelper.php | 50 ++++++++++---- .../View/Helper/ColorModeHelperTest.php | 68 +++++++++++++++++++ 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/src/View/Helper/ColorModeHelper.php b/src/View/Helper/ColorModeHelper.php index d43e79b3..bb3a3f93 100644 --- a/src/View/Helper/ColorModeHelper.php +++ b/src/View/Helper/ColorModeHelper.php @@ -68,23 +68,26 @@ public function script(array $options = []): string { $config = $options + $this->getConfig(); - $storageKey = json_encode($config['storageKey']); - $default = json_encode($this->_modeValue($config['default'])); - $target = json_encode($config['target']); + $storageKey = $this->_jsonValue($config['storageKey']); + $default = $this->_jsonValue($this->_modeValue($config['default'])); + $target = $this->_jsonValue($config['target']); // Inline IIFE; ASCII-only so it survives any reasonable CSP/serializer. + // localStorage access is wrapped in try/catch — when storage is blocked + // (private mode, security policy) the script must still apply the + // default theme rather than throw and leave a flash of unstyled theme. $js = '(function(){' . 'var k=' . $storageKey . ',d=' . $default . ',t=' . $target . ';' . 'var el=document.querySelector(t)||document.documentElement;' . 'var media=window.matchMedia("(prefers-color-scheme: dark)");' - . 'function stored(){return localStorage.getItem(k);}' + . 'function stored(){try{return localStorage.getItem(k);}catch(e){return null;}}' . 'function resolve(m){return m==="auto"?(media.matches?"dark":"light"):m;}' . 'function apply(m){var r=resolve(m);el.setAttribute("data-bs-theme",r);' . 'document.dispatchEvent(new CustomEvent("bs-theme-changed",{detail:{mode:m,resolved:r}}));}' . 'apply(stored()||d);' . 'media.addEventListener("change",function(){if((stored()||d)==="auto"){apply("auto");}});' . 'window.BootstrapUIColorMode={get:function(){return stored()||d;},' - . 'set:function(m){localStorage.setItem(k,m);apply(m);}};' + . 'set:function(m){try{localStorage.setItem(k,m);}catch(e){}apply(m);}};' . '})();'; return ''; @@ -123,17 +126,16 @@ public function toggle(array $options = []): string . 'var w=document.currentScript&&document.currentScript.previousElementSibling;' . 'if(!w||!w.matches("[data-bs-theme-toggle]")){return;}' . 'var active=(window.BootstrapUIColorMode&&BootstrapUIColorMode.get())||' - . json_encode($this->_modeValue($config['default'])) + . $this->_jsonValue($this->_modeValue($config['default'])) . ';' - . 'var ac=' . json_encode($config['activeClass']) . ';' + . '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(){if(window.BootstrapUIColorMode){' - . 'BootstrapUIColorMode.set(b.getAttribute("data-bs-theme-value"));}' - . 'w.querySelectorAll("[data-bs-theme-value]").forEach(function(x){x.classList.toggle(ac,x===b);});});' - . 'if(b.getAttribute("data-bs-theme-value")===active){' - . 'b.classList.add(ac);b.setAttribute("aria-pressed","true");}' - . 'else{b.setAttribute("aria-pressed","false");}' - . '});' + . '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) . ''; @@ -180,13 +182,31 @@ protected function _button(string $value, string $label, string $buttonClass): s * register additional themes via the `modes` config). * * @param \BootstrapUI\View\Helper\Enum\ColorMode|string $mode Mode value. - * @return string Lower-case string identifier (e.g. `'light'`). + * @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 diff --git a/tests/TestCase/View/Helper/ColorModeHelperTest.php b/tests/TestCase/View/Helper/ColorModeHelperTest.php index 526b4b71..5d5b4b15 100644 --- a/tests/TestCase/View/Helper/ColorModeHelperTest.php +++ b/tests/TestCase/View/Helper/ColorModeHelperTest.php @@ -167,4 +167,72 @@ public function testScriptDefaultAcceptsEnumCase(): void $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); + } }