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: +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 +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, + ); + } }