diff --git a/apps/dialtone-documentation/docs/.vuepress/baseComponents/FontUtilitiesNotice.vue b/apps/dialtone-documentation/docs/.vuepress/baseComponents/FontUtilitiesNotice.vue
index a51dfdf341..f37e158d79 100644
--- a/apps/dialtone-documentation/docs/.vuepress/baseComponents/FontUtilitiesNotice.vue
+++ b/apps/dialtone-documentation/docs/.vuepress/baseComponents/FontUtilitiesNotice.vue
@@ -3,7 +3,7 @@
kind="warning"
hide-close
class="d-wmx100p d-my16"
- title="Use DtText in favor CSS Utilities"
+ title="Use DtText over CSS Utilities"
>
Reach for the
diff --git a/apps/dialtone-documentation/docs/.vuepress/baseComponents/tokens/TokenTable.vue b/apps/dialtone-documentation/docs/.vuepress/baseComponents/tokens/TokenTable.vue
index 8f29605fb2..357683775f 100644
--- a/apps/dialtone-documentation/docs/.vuepress/baseComponents/tokens/TokenTable.vue
+++ b/apps/dialtone-documentation/docs/.vuepress/baseComponents/tokens/TokenTable.vue
@@ -1,107 +1,105 @@
-
-
-
-
- |
-
- Preview
-
- |
-
-
- Token Name
-
- |
-
-
- {{ tokenList ? "REM" : "Value" }}
-
- |
-
-
- PX
-
- |
-
-
-
-
+
+
+ |
- |
-
- |
-
-
-
-
- {{ name }}
-
-
-
-
-
-
-
-
- {{ description }}
-
-
- |
-
-
-
-
- |
-
-
- {{ remToPixels(tokenValue) }}
+
+ Preview
+
+
+
+
+ Token Name
+
+ |
+
+
+ {{ tokenList ? "REM" : "Value" }}
+
+ |
+
+
+ PX
+
+ |
+ |
+
+
+
+ |
+
+ |
+
+
+
+
+ {{ name }}
-
- |
-
-
-
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
+ |
+
+
+ {{ remToPixels(tokenValue) }}
+
+ |
+
+
+
diff --git a/apps/dialtone-documentation/docs/.vuepress/theme/assets/less/dialtone-syntax.less b/apps/dialtone-documentation/docs/.vuepress/theme/assets/less/dialtone-syntax.less
index bbbce8b258..eccb86b395 100644
--- a/apps/dialtone-documentation/docs/.vuepress/theme/assets/less/dialtone-syntax.less
+++ b/apps/dialtone-documentation/docs/.vuepress/theme/assets/less/dialtone-syntax.less
@@ -3,6 +3,14 @@
* Based on dabblet (http://dabblet.com)
*/
+
+:root {
+ --code-c-text: var(--dt-color-foreground-secondary);
+ --code-c-bg: blue;
+ --code-c-highlight-bg:var(--dt-color-blue-800);
+ --code-c-line-number: gray;
+}
+
div[class*="language-"] {
margin: var(--dt-size-500) 0;
padding: var(--dt-size-500);
diff --git a/apps/dialtone-documentation/docs/.vuepress/views/ColorsCatalog.vue b/apps/dialtone-documentation/docs/.vuepress/views/ColorsCatalog.vue
index a903933337..c5ce402ff9 100644
--- a/apps/dialtone-documentation/docs/.vuepress/views/ColorsCatalog.vue
+++ b/apps/dialtone-documentation/docs/.vuepress/views/ColorsCatalog.vue
@@ -1,6 +1,6 @@
-
@@ -14,7 +14,7 @@
/>
-
+
diff --git a/apps/dialtone-documentation/docs/components/card.md b/apps/dialtone-documentation/docs/components/card.md
index a2515d3b8e..ec45eec901 100644
--- a/apps/dialtone-documentation/docs/components/card.md
+++ b/apps/dialtone-documentation/docs/components/card.md
@@ -10,24 +10,62 @@ keywords: ["panel", "container", "box", "d-card", "DtCard", "dt-card", "tile", "
---
-
-
-
- Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
-
+
+
+
+
+
+
+
+
+
+ Edit
+ Share
+
+ Delete
+
+
+
+
+
+
+ Main branch, last updated 2 days ago. Currently 3 commits ahead and 1 behind the upstream target.
+ All checks passing. Latest build completed in 4m 12s with no warnings or errors.
+ Open pull requests: 2 pending review, 1 approved and ready to merge.
+ Recent activity includes dependency updates, a hotfix for the login flow, and minor copy changes across settings pages.
+ Protected branch rules are enforced. Requires at least one approval before merging.
+
+
+
+
+
+ Button
+
+
+
## Usage
@@ -60,33 +98,20 @@ They should be easy to scan for relevant and actionable information. Elements, l
### Base
-
+
+
+ (header slot)
+
+
+ (content slot)
+
+
+ (footer slot)
+
+
-
-
-
-
-'
vueCode='
@@ -105,37 +130,30 @@ showHtmlWarning />
### With Header
-
-
-
+
+
+ Lorem ipsum
+
+
+
+
+
+
+
Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
+
+
-
-
- Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
-'
vueCode='
@@ -143,6 +161,7 @@ vueCode='
@@ -163,31 +182,22 @@ showHtmlWarning />
### With Footer
-
-
+
+
Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
-
+
+
+
+ Button
+
+
+
-
- Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
-
-'
vueCode='
@@ -235,54 +245,46 @@ showHtmlWarning />
### With Header, Footer and Scrollable Content
-
-
-
- Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
-
+
+
+ Lorem ipsum
+
+
+
+
+
+
+
+ Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
+
+
+
+ Button
+
+
+
-
-
- Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
-
-
-
-'
vueCode='
-
+
Lorem ipsum
@@ -294,7 +296,7 @@ vueCode='
- Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
+ Content slot. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec fermentum molestie semper. Morbi finibus nulla turpis, nec molestie mi rutrum.
$refs.invertedExample'
vueCode='
-
-
-
+
'
showHtmlWarning />
diff --git a/apps/dialtone-documentation/docs/components/link.md b/apps/dialtone-documentation/docs/components/link.md
index 62541cd094..fd7f21d897 100644
--- a/apps/dialtone-documentation/docs/components/link.md
+++ b/apps/dialtone-documentation/docs/components/link.md
@@ -95,30 +95,28 @@ showHtmlWarning />
kind="error"
class="d-wmx100p d-my16"
>
- The inverted prop has been deprecated in favor of using DtModeIsland as a wrapper.
+ The inverted prop has been deprecated. Use the
+ v-dt-mode directive
+ instead.
-In place of the inverted prop, use the DtModeIsland component as a wrapper.
+In place of the `inverted` prop, use the [v-dt-mode directive](mode-island.html#inverting) on the component element.
-
-
- Base link
- Danger link
- Success link
- Warning link
- Muted link
- Mention link
-
-
+
+ Base link
+ Danger link
+ Success link
+ Warning link
+ Muted link
+ Mention link
+
diff --git a/apps/dialtone-documentation/docs/components/mode-island.md b/apps/dialtone-documentation/docs/components/mode-island.md
index cdf680e19a..78e6e2db6e 100644
--- a/apps/dialtone-documentation/docs/components/mode-island.md
+++ b/apps/dialtone-documentation/docs/components/mode-island.md
@@ -1,14 +1,14 @@
---
title: Mode Island
-description: Create independent sections with their own color modes.
+description: Apply light, dark, or inverted color mode to any element or region.
status: beta
-keywords: ["theme island","mode override","d-mode-island","DtModeIsland","dt-mode-island"]
+keywords: ["theme island","mode override","v-dt-mode","directive","light","dark","invert","v-dt"]
---
-
-
- Demo
+
+
+ Demo
- Mode: {{ currentMode.charAt(0).toUpperCase() + currentMode.slice(1) }}
- Contrast: {{ currentContrast.charAt(0).toUpperCase() + currentContrast.slice(1) }}
+
+ Mode:
+ {{ currentMode.charAt(0).toUpperCase() + currentMode.slice(1) }}
+
+
+ Contrast:
+ {{ currentContrast.charAt(0).toUpperCase() + currentContrast.slice(1) }}
+
-
+
+
+
+
@@ -96,90 +109,157 @@ keywords: ["theme island","mode override","d-mode-island","DtModeIsland","dt-mod
-
-
-
- Inverted (auto)
+
+ Inverted (auto)
+
+
+ Primary
+ Muted
+ Critical
+ Link
-
- Primary
- Tertiary
- Critical
-
-
- Text link
-
- Button
- Button
+ Button
+ Button
-
-
-
-
- Explicit light
+
+
+ Explicit light
+
+
+ Primary
+ Muted
+ Critical
+ Link
-
- Primary
- Tertiary
- Critical
-
-
- Text link
-
- Button
- Button
+ Button
+ Button
-
-
-
-
- Explicit dark
+
+
+ Explicit dark
+
+
+ Primary
+ Muted
+ Critical
+ Link
-
- Primary
- Tertiary
- Critical
-
-
- Text link
-
- Button
- Button
+ Button
+ Button
-
+
## Usage
-Mode islands create isolated regions that may display in a different color mode, `light`, `dark`, or `inverted`. Useful for forcing a region to a controlled mode for a unique UI purpose.
+Use the `v-dt-mode` directive to control the color mode of a region, component, or element. It creates a scoped region with the specified mode. Descendant elements retain their original styling but are rendered with the specified mode.
-### Structure
+
+ Dark content
+ Light content
+ Inverted — opposite of parent or root
+
+ Dark content
+ Light content
+ Inverted — opposite of parent or root
+'
+/>
+
+### Inverting
+
+This effectively removes the need for `inverted` props or variants on elements or components.
+
+For example, instead of using `inverted` on a DtButton, use `v-dt-mode:invert`
+
+
+ Button
+ Button
+
+
+
+### Dynamic mode
+
+Bind a reactive variable as the directive arg to switch modes at runtime.
+
+
+
+
+
+ Invert
+
+
+
+
+
+ Light
+
+
+
+
+
+ Dark
+
+
+
+
+
+ {{ dynamicMode }} mode
+
+
+
+ {{ dynamicMode }} mode content
+'
+showHtmlWarning />
+
+### Conditional
+
+Pass a boolean value to conditionally apply or remove the directive. When `false`, mode attributes are removed entirely.
+
+```vue
+Button
+```
+
### Guidance
@@ -195,43 +275,39 @@ vueCode='
- Do not overuse mode islands, respect user theme preference
-- Do not use purely for decoration. Ensure Mode Island use serves a functional and unique purpose
+- Do not use purely for decoration. Ensure mode island use serves a functional and unique purpose
- Avoid nesting deeply. Keep hierarchy shallow for maintainability
-### Reactive Mode Updates
-
-Inverted islands reactively track parent/root mode changes. User switches light ↔ dark → inverted islands flip automatically. Even directly modifying the `mode` attribute will also work.
-
-### Brand Theme Protection
-
-`data-dt-brand` (aka "Theme", e.g. "tmo", "sunflower", etc) can not be set on Mode Islands. Brand theme can only be set at root level and are inherited
+### How it works
-### Contrast Inheritance
-
-Contrast is not an option to set to a Mode Island. Contrast theme setting is inherited from the root element, i.e. ``.
+- CSS tokens activate via `[data-dt-mode="light"]` and `[data-dt-mode="dark"]` attribute selectors
+- High-contrast tokens layer via `[data-dt-mode][data-dt-contrast="high"]`
+- Contrast is inherited from the root `` element and kept in sync via MutationObserver
+- For `invert` mode, the directive reads the nearest ancestor's `data-dt-mode`, computes the opposite, and reacts when it changes
+- `data-dt-brand` (theme) cannot be set on mode islands — brand is root-level only
## Variants
### Inverted
-The default mode, inverts the container relative to the parent or root's mode. When `mode` attribute is omitted, it defaults to `inverted`.
+The default mode — inverts relative to the nearest parent mode boundary or the root. When no arg is provided, `v-dt-mode` defaults to invert.
-
- Inverted mode (opposite of parent)
-
+
+ Inverted mode (opposite of parent)
+
@@ -240,17 +316,17 @@ showHtmlWarning />
Explicitly set to light mode regardless of parent or root mode.
-
- Always light mode
-
+
@@ -259,69 +335,48 @@ showHtmlWarning />
Explicitly set to dark mode regardless of parent or root mode.
-
- Always dark mode
-
+
-
-## Render as
-
-Polymorphic rendering via `as` prop—controls which HTML element wraps content. Ensures proper document structure and semantic markup. Example values: `section` for thematic grouping, `article` for self-contained content. Defaults to `div` where semantics aren't a concern.
-
-
-
- Rendered as section element
-
-
-
- $refs.sectionExample'
-vueCode='
-
- Rendered as section element
-
+
'
showHtmlWarning />
-**Common values:** `div` (default), `section`, `article`, `nav`, `aside`, `header`, `footer`, `main`
-
## Nesting
-Mode islands may be nested, though should rarely occur.
+Mode boundaries can be nested. Each `v-dt-mode:invert` reads the nearest parent boundary and flips. In this example the first level is explicitly set to light mode, the second level inverts against that, and the third level inverts again.
-
- Light island
-
- Inverted → Dark island
-
- Inverted again → Light island
-
-
-
+
+ Explicit Light
+
+ Inverted (Dark)
+
+ Inverted again (Light)
+
+
+
@@ -346,17 +401,17 @@ The background surface of a Mode Island defaults to the root surface color. To o
-
+
- warning background, dark mode island
+ critical background, dark mode island
Button
-
+
- warning background, light mode island
+ critical background, light mode island
Button
@@ -365,104 +420,127 @@ The background surface of a Mode Island defaults to the root surface color. To o
$refs.customBackgroundExample'
+:htmlCode='() => $refs.nestingExample'
vueCode='
-
- Button
-
+
+ Explicit Light
+
+ Inverted (Dark)
+
+ Inverted again (Light)
+
+
+
'
-showHtmlWarning />
+/>
## Examples
### Callbar
-
-
-
-
-
-
- Ted Anderson
-
- (913) 555-6745
- •
- 21:18
-
-
-
-
-
-
- Unmute
-
-
-
- Record
-
-
-
- Keypad
-
-
-
- Add
-
-
-
- More
-
-
-
-
-
-
-
-
-
- * Not real, still just an example
-
+A real-world pattern: the callbar container already exists as a semantic element. The directive applies mode theming directly — no wrapper needed.
- $refs.callbarExample'
-vueCode='
-
-
+
+
-
+
Ted Anderson
- (913) 555-6745
- •
- 21:18
+ (913) 555-6745
+ •
+ 21:18
-
-
-
+
+
+
Unmute
-
+
+
+ Record
+
+
+
+ Keypad
+
+
+
+ Add
+
+
+
+ More
+
-
+
-
+ * Not real, still just an example
+
+
+ $refs.callbarExample'
+vueCode='
+
+
+
+
+ Ted Anderson
+
+ (913) 555-6745
+ •
+ 21:18
+
+
+
+
+
+
+ Unmute
+
+
+
+ Record
+
+
+
+ Keypad
+
+
+
+ Add
+
+
+
+ More
+
+
+
+
+
+
+
+
'
showHtmlWarning />
### Positioned Components
-[Popovers](/components/popover.html), [Dropdowns](/components/dropdown.html), and [Hovercards](/components/hovercard.html) are typically rendered at the root element of the DOM tree, and thus inherit the page's mode by default. They can be forced to a specific mode by assigning a Mode Island to its content slot.
+[Popovers](/components/popover.html), [Dropdowns](/components/dropdown.html), and [Hovercards](/components/hovercard.html) render at the root of the DOM tree and inherit the page's mode. Use `v-dt-mode` on the slot content's container to override.
@@ -484,9 +562,9 @@ showHtmlWarning />
Inverted
-
+
-
+
@@ -494,9 +572,9 @@ showHtmlWarning />
Light
-
+
-
+
@@ -504,9 +582,9 @@ showHtmlWarning />
Dark
-
+
-
+
@@ -529,9 +607,9 @@ showHtmlWarning />
Inverted
-
+
This Popover's content is in the inverted mode.
-
+
@@ -539,9 +617,9 @@ showHtmlWarning />
Light
-
+
This Popover's content is in explicit light mode.
-
+
@@ -549,9 +627,9 @@ showHtmlWarning />
Dark
-
+
This Popover's content is in explicit dark mode.
-
+
@@ -590,7 +668,7 @@ showHtmlWarning />
-
+
>
{{ item.name }}
-
+
@@ -613,7 +691,7 @@ showHtmlWarning />
-
+
>
{{ item.name }}
-
+
@@ -636,7 +714,7 @@ showHtmlWarning />
-
+
>
{{ item.name }}
-
+
@@ -662,9 +740,9 @@ vueCode='
Inverted
-
+
-
+
@@ -673,9 +751,9 @@ vueCode='
Inverted
-
+
This Popover content is in the inverted mode.
-
+
@@ -689,7 +767,7 @@ vueCode='
-
+
+## Component
+
+The `
` component is the underlying abstraction that the directive builds on. The key rendered difference is that it creates a wrapper element, while the directive attaches to mode to the existing element.
+
+
+ The only real case where you might want to use the component is when you need to create a container element that doesn't already exist, but even then, you can create any kind of containing element with the directive e.g. <span v-dt-mode:invert">...</span>.
+
+
+
+ Rendered as a section element inverted
+
+
+ Inverted (default)
+
+
+ Light
+
+
+ Dark
+
+'
+/>
+
## Vue API
+### Directive
+
+```js
+import { DtModeDirective } from '@dialpad/dialtone-vue';
+app.use(DtModeDirective);
+```
+
+### Component
+
## Accessibility
@@ -726,6 +843,8 @@ const {
setContrast,
} = useThemeManager({ includeThemes: false });
+const dynamicMode = ref('invert');
+
const items = ref([
{ id: '1', name: 'Option 1' },
{ id: '2', name: 'Option 2' },
diff --git a/apps/dialtone-documentation/docs/components/scrollbar.md b/apps/dialtone-documentation/docs/components/scrollbar.md
index 4bcd18ad8e..0b9ba39522 100644
--- a/apps/dialtone-documentation/docs/components/scrollbar.md
+++ b/apps/dialtone-documentation/docs/components/scrollbar.md
@@ -4,7 +4,7 @@ description: A directive that adds a custom overlay scrollbar to any scrollable
status: beta
thumb: true
image: assets/images/components/scrollbar.png
-keywords: ["scrollable", "d-scrollbar", "DtScrollbar", "dt-scrollbar", "custom scrollbar", "scroll container"]
+keywords: ["scrollable", "d-scrollbar", "DtScrollbar", "dt-scrollbar", "custom scrollbar", "scroll container", "v-dt", "directive"]
---
## Scrollbar Directive
@@ -95,10 +95,6 @@ To customize the behavior of the scrollbar, you can use different arguments with
Show the scrollbar when the mouse enters the scrollable area. This is the default option, so no argument is needed.
-```javascript
-
-```
-
@@ -109,14 +105,16 @@ Show the scrollbar when the mouse enters the scrollable area. This is the defaul
+
+'
+/>
+
### Always
Always show the scrollbar if the region is overflowing the available space.
-```javascript
-
-```
-
@@ -127,14 +125,16 @@ Always show the scrollbar if the region is overflowing the available space.
+
+'
+/>
+
### Scroll
Show the scrollbar on scroll.
-```javascript
-
-```
-
@@ -145,14 +145,16 @@ Show the scrollbar on scroll.
+
+'
+/>
+
### Move
Show the scrollbar when the mouse moves inside the scrollable area.
-```javascript
-
-```
-
@@ -163,6 +165,12 @@ Show the scrollbar when the mouse moves inside the scrollable area.
+
+'
+/>
+
## Limitations
Adding this directive to a DOM element or a Vue component will alter the DOM structure, by adding four elements inside the one that the directive was attached to. If the scrollable region is a Vue component, it's recommended to wrap it in a ``, to avoid altering the structure that the component needs.
diff --git a/apps/dialtone-documentation/docs/components/table.md b/apps/dialtone-documentation/docs/components/table.md
index 3a19cfa337..7e65c1b6fa 100644
--- a/apps/dialtone-documentation/docs/components/table.md
+++ b/apps/dialtone-documentation/docs/components/table.md
@@ -82,15 +82,13 @@ keywords: ["data table", "grid", "rows", "d-table", "DtTable", "dt-table", "data
### Inverted Style
-
- The d-table--inverted modifier has been deprecated in favor of using DtModeIsland as a wrapper.
+
+ The d-table--inverted modifier has been deprecated. Use the v-dt-mode directive instead.
-In place of the d-table--inverted modifier, wrap the table in the DtModeIsland component.
-
-
-
+
+
Office List
@@ -109,13 +107,11 @@ In place of the d-table--inverted modifier, wrap the table in the <
-
+
```html
-
-
-
+
```
### Striped
@@ -164,36 +160,6 @@ In place of the d-table--inverted modifier, wrap the table in the <
```
-
-
-
- Office List
-
-
- | Office |
- Country |
- Employees |
- Contact |
-
-
-
-
- | {{ i.office }} |
- {{ i.country }} |
- {{ i.size }} |
- {{ i.contact }} |
-
-
-
-
-
-
-```html
-
-
-
-```
-
## Classes
diff --git a/apps/dialtone-documentation/docs/components/tabs.md b/apps/dialtone-documentation/docs/components/tabs.md
index 76b1194f44..c8d8a5b97a 100644
--- a/apps/dialtone-documentation/docs/components/tabs.md
+++ b/apps/dialtone-documentation/docs/components/tabs.md
@@ -176,29 +176,29 @@ showHtmlWarning />
### Inverted
-
- The inverted prop has been deprecated in favor of using DtModeIsland as a wrapper.
+
+ The inverted prop has been deprecated. Use the v-dt-mode directive instead.
-In place of the inverted prop, use the DtModeIsland component as a wrapper.
+In place of the `inverted` prop, use the [v-dt-mode directive](mode-island.html#inverting) on the component element.
-
-
-
+
@@ -289,7 +289,7 @@ showHtmlWarning />
Use the `#startIcon` or `#endIcon` slot on `dt-tab` to add an icon. The slot provides `iconSize` to match the tab's size.
-
+
The #icon slot has been deprecated. Use #startIcon or #endIcon instead.
diff --git a/apps/dialtone-documentation/docs/components/text.md b/apps/dialtone-documentation/docs/components/text.md
index 9f0e849332..31ed05c108 100644
--- a/apps/dialtone-documentation/docs/components/text.md
+++ b/apps/dialtone-documentation/docs/components/text.md
@@ -308,6 +308,26 @@ vueCode='
critical-strong
'/>
+### Inverted
+
+Rather than use the `-inverted` tone variants, use the [v-dt-mode](/components/mode-island.html) directive.
+
+
+
+
+ critical tone on default surface
+
+
+ critical tone on contrasting surface
+
+
+
+
+critical tone on contrasting surface
+'/>
+
## Render as
Use `as` to declare the underlying HTML tag that the component should render, independent of the visual styling. Defaults to `span`.
diff --git a/apps/dialtone-documentation/docs/components/tooltip.md b/apps/dialtone-documentation/docs/components/tooltip.md
index d8d041ac40..d6b1d947c2 100644
--- a/apps/dialtone-documentation/docs/components/tooltip.md
+++ b/apps/dialtone-documentation/docs/components/tooltip.md
@@ -135,6 +135,10 @@ showHtmlWarning />
### Inverted
+
+ The inverted prop is still required for DtTooltip because the tooltip renders outside the normal DOM tree (via Tippy.js). The v-dt-mode directive cannot reach the tooltip shell. See DLT-3077 for follow-up.
+
+
diff --git a/apps/dialtone-documentation/docs/guides/theme-and-mode/index.md b/apps/dialtone-documentation/docs/guides/theme-and-mode/index.md
index 5e0f1f24b3..558493191b 100644
--- a/apps/dialtone-documentation/docs/guides/theme-and-mode/index.md
+++ b/apps/dialtone-documentation/docs/guides/theme-and-mode/index.md
@@ -179,11 +179,18 @@ Switch themes by changing the brand CSS import and updating the `data-dt-brand`
### Mode Sections
-You can create sections with different modes using the [Mode Island component](/components/mode-island.html). This is useful for:
+Use the `v-dt-mode` directive to create sections with different modes within a page.
+Apply it to any existing element — no wrapper needed:
-- Dark mode previews within light mode pages
-- Code examples showing both modes
-- Mixed-mode UI sections
+```html
+
+```
+
+When no container element exists, the [Mode Island component](/components/mode-island.html)
+creates one for you. See the [Mode Island page](/components/mode-island.html) for full
+documentation on both approaches.
### Micro-frontends (Separate Bundles)
diff --git a/apps/dialtone-documentation/docs/scratch.md b/apps/dialtone-documentation/docs/scratch.md
index 9ed8cb1325..e69133661d 100644
--- a/apps/dialtone-documentation/docs/scratch.md
+++ b/apps/dialtone-documentation/docs/scratch.md
@@ -276,7 +276,7 @@ const checkRadioDisabled = ref(false);
Sizing update: Button/Input/Select
-
+
+
+
+
+
...
```
+## Inverted
+
+
+ Avoid -inverted utility variants. Use the v-dt-mode directive with base classes instead — it automatically resolves the correct colors for the current mode.
+
+
+
+
+
+ {{ color.charAt(0).toUpperCase() + color.slice(1) }}
+
+
+
+
+```html
+...
+...
+```
+
## Classes
diff --git a/apps/dialtone-documentation/docs/utilities/borders/color.md b/apps/dialtone-documentation/docs/utilities/borders/color.md
index 9532f36c46..04e9b9dac7 100644
--- a/apps/dialtone-documentation/docs/utilities/borders/color.md
+++ b/apps/dialtone-documentation/docs/utilities/borders/color.md
@@ -168,6 +168,10 @@ You can also change the border color opacity value on `:hover`
```
+
+ Prefer using the v-dt-mode directive with base utility classes instead of -inverted variants. For example, use <div v-dt-mode:invert class="d-bc-critical"> instead of <div class="d-bc-critical-inverted">.
+
+
## Classes
diff --git a/apps/dialtone-documentation/docs/utilities/typography/font-color.md b/apps/dialtone-documentation/docs/utilities/typography/font-color.md
index 3d37a3c466..6c4d300c25 100644
--- a/apps/dialtone-documentation/docs/utilities/typography/font-color.md
+++ b/apps/dialtone-documentation/docs/utilities/typography/font-color.md
@@ -13,7 +13,7 @@ Use [DtText's](/components/text.html#tone) `tone` prop to declare the text's ton
- primary
+ primary
secondary
tertiary
muted
@@ -25,19 +25,6 @@ Use [DtText's](/components/text.html#tone) `tone` prop to declare the text's ton
critical
critical-strong
-
- primary-inverted
- secondary-inverted
- tertiary-inverted
- muted-inverted
- disabled-inverted
- placeholder-inverted
- success-inverted
- success-strong-inverted
- warning-inverted
- critical-inverted
- critical-strong-inverted
-
@@ -97,6 +84,67 @@ Use `fv:d-fc-{color}` to change an element's text color on `:focus-visible` stat
Keyboard focus me
```
+## Inverted
+
+
+ Avoid -inverted utility variants, which will be sunset. Use the
+ v-dt-mode directive
+ with base classes instead — it automatically resolves the correct colors for
+ the current mode.
+
+
+
+
+
+ primary
+ secondary
+ tertiary
+ muted
+ disabled
+ placeholder
+ success
+ success-strong
+ warning
+ critical
+ critical-strong
+
+
+ primary
+ secondary
+ tertiary
+ muted
+ disabled
+ placeholder
+ success
+ success-strong
+ warning
+ critical
+ critical-strong
+
+
+
+
+
+ primary
+ secondary
+ tertiary
+ muted
+ disabled
+ placeholder
+ success
+ success-strong
+ warning
+ critical
+ critical-strong
+
+'/>
+
## Changing Opacity
Use `d-fco{n}` to change an element's text color opacity. You can also change font color opacity on `:hover`, `:focus`,
diff --git a/packages/dialtone-css/lib/build/less/components/mode-island.less b/packages/dialtone-css/lib/build/less/components/mode-island.less
index 7db98777c9..0d99a658ac 100644
--- a/packages/dialtone-css/lib/build/less/components/mode-island.less
+++ b/packages/dialtone-css/lib/build/less/components/mode-island.less
@@ -1,6 +1,9 @@
@layer dialtone.components {
-.d-mode-island {
+:where([data-dt-mode]) {
color: var(--dt-color-foreground-primary);
+}
+
+:where(.d-mode-island) {
background-color: var(--dt-color-surface-primary);
}
}
diff --git a/packages/dialtone-css/lib/build/less/components/notice.less b/packages/dialtone-css/lib/build/less/components/notice.less
index 8350dbeac2..633958b531 100644
--- a/packages/dialtone-css/lib/build/less/components/notice.less
+++ b/packages/dialtone-css/lib/build/less/components/notice.less
@@ -51,7 +51,7 @@
flex: 1 auto;
flex-direction: column;
margin-inline-end: var(--dt-size-500);
- gap: var(--dt-size-300);
+ gap: var(--dt-size-400);
align-self: center;
}
diff --git a/packages/dialtone-vue/.storybook/preview.jsx b/packages/dialtone-vue/.storybook/preview.jsx
index c499eaf7b8..f98a46cd26 100644
--- a/packages/dialtone-vue/.storybook/preview.jsx
+++ b/packages/dialtone-vue/.storybook/preview.jsx
@@ -48,6 +48,7 @@ import { dialtoneDarkTheme, dialtoneLightTheme } from './dialtone-themes.js';
import { DialtoneDocsPage } from './DialtoneDocsPage.jsx';
import { DtTooltipDirective } from '@/directives/tooltip_directive';
import { DtScrollbarDirective } from '@/directives/scrollbar_directive';
+import { DtModeDirective } from '@/directives/mode_directive';
import { DtStack } from '@/components/stack';
import { faker } from '@faker-js/faker';
@@ -133,6 +134,7 @@ setup((app) => {
app.use(fixDefaultSlot);
app.use(DtTooltipDirective);
app.use(DtScrollbarDirective);
+ app.use(DtModeDirective);
app.component('DtStack', DtStack);
// global seed, to make sure results are reproducible on percy and don't change on every reload too.
faker.seed(6687422389464139);
diff --git a/packages/dialtone-vue/components/breadcrumbs/breadcrumb_item.vue b/packages/dialtone-vue/components/breadcrumbs/breadcrumb_item.vue
index 89e7d0402a..fd918fce3b 100644
--- a/packages/dialtone-vue/components/breadcrumbs/breadcrumb_item.vue
+++ b/packages/dialtone-vue/components/breadcrumbs/breadcrumb_item.vue
@@ -41,8 +41,8 @@ export default {
props: {
/**
- * @ignore
* Passed through to link. If true, applies inverted styles to the link.
+ * @deprecated Use v-dt-mode directive instead.
*/
inverted: {
type: Boolean,
diff --git a/packages/dialtone-vue/components/breadcrumbs/breadcrumbs.vue b/packages/dialtone-vue/components/breadcrumbs/breadcrumbs.vue
index fb4277a0e4..b440f7faf2 100644
--- a/packages/dialtone-vue/components/breadcrumbs/breadcrumbs.vue
+++ b/packages/dialtone-vue/components/breadcrumbs/breadcrumbs.vue
@@ -55,9 +55,9 @@ export default {
},
/**
- * @ignore
* Passed through to link. If true, applies inverted styles to the link.
* @values true, false
+ * @deprecated Use v-dt-mode directive instead.
*/
inverted: {
type: Boolean,
diff --git a/packages/dialtone-vue/components/button/button.vue b/packages/dialtone-vue/components/button/button.vue
index cc8b45995d..95a1e74f9e 100644
--- a/packages/dialtone-vue/components/button/button.vue
+++ b/packages/dialtone-vue/components/button/button.vue
@@ -315,6 +315,7 @@ export default {
/**
* The color of the button.
+ * The inverted value is deprecated — use v-dt-mode directive instead.
* @values default, unstyled, muted, danger, positive
*/
kind: {
diff --git a/packages/dialtone-vue/components/keyboard_shortcut/keyboard_shortcut.vue b/packages/dialtone-vue/components/keyboard_shortcut/keyboard_shortcut.vue
index d20a1cb26a..d6d3f1aa4e 100644
--- a/packages/dialtone-vue/components/keyboard_shortcut/keyboard_shortcut.vue
+++ b/packages/dialtone-vue/components/keyboard_shortcut/keyboard_shortcut.vue
@@ -113,9 +113,9 @@ export default {
props: {
/**
- * @ignore
* If true, applies inverted styles.
* @values true, false
+ * @deprecated Use v-dt-mode directive instead.
*/
inverted: {
type: Boolean,
diff --git a/packages/dialtone-vue/components/link/link.vue b/packages/dialtone-vue/components/link/link.vue
index fb14d123ef..ef592c8240 100644
--- a/packages/dialtone-vue/components/link/link.vue
+++ b/packages/dialtone-vue/components/link/link.vue
@@ -40,7 +40,7 @@ export default {
* Determines whether the link should have inverted styling
* default is false.
* @values true, false
- * @deprecated
+ * @deprecated Use v-dt-mode directive instead.
*/
inverted: {
type: Boolean,
diff --git a/packages/dialtone-vue/components/mode_island/mode_island.mdx b/packages/dialtone-vue/components/mode_island/mode_island.mdx
deleted file mode 100644
index 04d3edda6e..0000000000
--- a/packages/dialtone-vue/components/mode_island/mode_island.mdx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Meta } from '@storybook/addon-docs/blocks';
-import * as ModeIslandStories from './mode_island.stories';
-import RedirectToDocs from "@/common/snippets/redirect-to-docs.mdx"
-
-
-
-# Mode Island
-
-
diff --git a/packages/dialtone-vue/components/tab/tab_group.vue b/packages/dialtone-vue/components/tab/tab_group.vue
index 2bc5452814..a2a0c034f3 100644
--- a/packages/dialtone-vue/components/tab/tab_group.vue
+++ b/packages/dialtone-vue/components/tab/tab_group.vue
@@ -93,7 +93,7 @@ export default {
/**
* If true, applies inverted styles to the tab group
- * @deprecated
+ * @deprecated Use v-dt-mode directive instead.
* @values true, false
*/
inverted: {
diff --git a/packages/dialtone-vue/directives/mode_directive/index.js b/packages/dialtone-vue/directives/mode_directive/index.js
new file mode 100644
index 0000000000..b3260306cb
--- /dev/null
+++ b/packages/dialtone-vue/directives/mode_directive/index.js
@@ -0,0 +1 @@
+export { default as DtModeDirective } from './mode.js';
diff --git a/packages/dialtone-vue/directives/mode_directive/mode.js b/packages/dialtone-vue/directives/mode_directive/mode.js
new file mode 100644
index 0000000000..a0f55d788f
--- /dev/null
+++ b/packages/dialtone-vue/directives/mode_directive/mode.js
@@ -0,0 +1,162 @@
+import {
+ getOppositeMode,
+ getRootContrast,
+ findParentMode,
+} from '@/components/mode_island/utils';
+
+const VALID_MODES = ['light', 'dark', 'invert'];
+
+const SUGGESTIONS = {
+ inverted: 'invert',
+};
+
+/**
+ * v-dt-mode directive — applies a color mode (light, dark, or invert) to an element.
+ *
+ * Sets `data-dt-mode` and `data-dt-contrast` attributes so descendant token-based
+ * styles (`d-fc-primary`, `d-bgc-secondary`, etc.) resolve to the correct palette.
+ *
+ * @example
+ * // Explicit modes
+ *
+ *
+ *
+ * // Invert nearest parent mode (default when no arg)
+ *
+ *
+ *
+ * // Dynamic arg
+ *
+ *
+ * // Disable with false value
+ *
+ */
+export const DtModeDirective = {
+ name: 'dt-mode-directive',
+ install (app) {
+ const instances = new WeakMap();
+
+ app.directive('dt-mode', {
+ mounted (el, binding) {
+ if (binding.value === false) return;
+ const mode = resolveArg(binding.arg);
+ const state = applyMode(el, mode);
+ instances.set(el, state);
+ },
+
+ updated (el, binding) {
+ const prev = instances.get(el);
+ const valueChanged = binding.value !== binding.oldValue;
+ const resolvedArg = resolveArg(binding.arg);
+ const argChanged = resolvedArg !== prev?.arg;
+ if (!valueChanged && !argChanged) return;
+
+ cleanup(prev);
+ removeAttributes(el);
+ instances.delete(el);
+
+ if (binding.value === false) return;
+
+ const state = applyMode(el, resolvedArg);
+ instances.set(el, state);
+ },
+
+ unmounted (el) {
+ cleanup(instances.get(el));
+ removeAttributes(el);
+ instances.delete(el);
+ },
+ });
+
+ function resolveArg (arg) {
+ if (!arg) return 'invert';
+ if (VALID_MODES.includes(arg)) return arg;
+ if (SUGGESTIONS[arg]) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[DtModeDirective] Invalid mode "${arg}". Did you mean "${SUGGESTIONS[arg]}"? Falling back to "${SUGGESTIONS[arg]}".`,
+ );
+ return SUGGESTIONS[arg];
+ }
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[DtModeDirective] Invalid mode "${arg}". Valid modes: ${VALID_MODES.join(', ')}. Falling back to "invert".`,
+ );
+ return 'invert';
+ }
+
+ function applyMode (el, mode) {
+ const state = {
+ arg: mode,
+ contrastObserver: null,
+ modeObserver: null,
+ };
+
+ // Set contrast from root
+ el.setAttribute('data-dt-contrast', getRootContrast());
+
+ // Watch for contrast changes on root
+ state.contrastObserver = new MutationObserver(() => {
+ el.setAttribute('data-dt-contrast', getRootContrast());
+ });
+ state.contrastObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-dt-contrast'],
+ });
+
+ if (mode === 'light' || mode === 'dark') {
+ el.setAttribute('data-dt-mode', mode);
+ } else {
+ // invert mode
+ const parentMode = findParentMode(el);
+ el.setAttribute('data-dt-mode', getOppositeMode(parentMode));
+
+ // Watch for mode changes on root and ancestors
+ const config = {
+ attributes: true,
+ attributeFilter: ['data-dt-mode'],
+ subtree: false,
+ };
+
+ state.modeObserver = new MutationObserver(() => {
+ const currentParentMode = findParentMode(el);
+ el.setAttribute('data-dt-mode', getOppositeMode(currentParentMode));
+ });
+
+ // Observe root element
+ state.modeObserver.observe(document.documentElement, config);
+
+ // Observe all ancestor elements for data-dt-mode changes.
+ // This includes ancestors that don't yet have the attribute,
+ // because a sibling directive may set it after this one mounts
+ // (Vue 3 fires child mounted before parent mounted).
+ let parent = el.parentElement;
+ while (parent && parent !== document.documentElement) {
+ state.modeObserver.observe(parent, config);
+ parent = parent.parentElement;
+ }
+ }
+
+ return state;
+ }
+
+ function removeAttributes (el) {
+ el.removeAttribute('data-dt-mode');
+ el.removeAttribute('data-dt-contrast');
+ }
+
+ function cleanup (state) {
+ if (!state) return;
+ if (state.contrastObserver) {
+ state.contrastObserver.disconnect();
+ state.contrastObserver = null;
+ }
+ if (state.modeObserver) {
+ state.modeObserver.disconnect();
+ state.modeObserver = null;
+ }
+ }
+ },
+};
+
+export default DtModeDirective;
diff --git a/packages/dialtone-vue/directives/mode_directive/mode.mdx b/packages/dialtone-vue/directives/mode_directive/mode.mdx
new file mode 100644
index 0000000000..78c7adaf78
--- /dev/null
+++ b/packages/dialtone-vue/directives/mode_directive/mode.mdx
@@ -0,0 +1,91 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+import * as ModeDirectiveStories from './mode.stories.js';
+
+
+
+# Mode directive
+
+Apply light, dark, or inverted color mode to any element. Sets `data-dt-mode` and
+`data-dt-contrast` on the bound element, scoping descendant token-based styles
+(`d-fc-primary`, `d-bgc-secondary`, etc.) to the specified mode.
+
+For full documentation, see the [Mode Island page](https://dialtone.dialpad.com/components/mode-island.html).
+
+## Usage
+
+Import the directive from dialtone-vue
+
+```js
+import { DtModeDirective } from "@dialpad/dialtone-vue";
+```
+
+Install the directive into vue instance
+
+```js
+app.use(DtModeDirective);
+```
+
+### Explicit modes
+
+```html
+
+
+```
+
+### Invert (default)
+
+When no arg is provided, the directive inverts the nearest parent mode boundary (or the root).
+
+```html
+Inverted from parent or root
+
+```
+
+### Dynamic arg
+
+Bind a reactive variable to switch modes at runtime.
+
+```html
+
+```
+
+## Args
+
+
+
+
+ | Arg |
+ Description |
+
+
+
+
+ dark |
+ Explicitly set dark mode |
+
+
+ light |
+ Explicitly set light mode |
+
+
+ invert |
+ Invert the nearest parent or root mode |
+
+
+ | (none) |
+ Default — same as invert |
+
+
+ [dynamic] |
+ Reactive — re-evaluates when the bound value changes |
+
+
+
+
+## Behavior
+
+- Sets `data-dt-mode` (`light` or `dark`) on the element
+- Sets `data-dt-contrast` inherited from root `` element
+- For `invert`, watches ancestor mode changes via MutationObserver and reacts automatically
+- Does **not** add the `d-mode-island` CSS class or set `background-color`
+- Interoperable with `` — nesting either inside the other works naturally
diff --git a/packages/dialtone-vue/directives/mode_directive/mode.stories.js b/packages/dialtone-vue/directives/mode_directive/mode.stories.js
new file mode 100644
index 0000000000..05267f6a1b
--- /dev/null
+++ b/packages/dialtone-vue/directives/mode_directive/mode.stories.js
@@ -0,0 +1,28 @@
+import { createTemplateFromVueFile } from '@/common/storybook_utils.js';
+import ModeDirectiveDefaultTemplate from './mode_directive_default.story.vue';
+
+export const argsData = {};
+
+export const argTypesData = {};
+
+// Story Collection
+export default {
+ title: 'Directives/Mode',
+ component: ModeDirectiveDefaultTemplate,
+ args: argsData,
+ argTypes: argTypesData,
+ excludeStories: /.*Data$/,
+};
+
+// Templates
+const DefaultTemplate = (args, { argTypes }) =>
+ createTemplateFromVueFile(args, argTypes, ModeDirectiveDefaultTemplate);
+
+// Stories
+export const Default = {
+ render: DefaultTemplate,
+ parameters: {
+ options: { showPanel: false },
+ controls: { disable: true },
+ },
+};
diff --git a/packages/dialtone-vue/directives/mode_directive/mode.test.js b/packages/dialtone-vue/directives/mode_directive/mode.test.js
new file mode 100644
index 0000000000..f219f1203e
--- /dev/null
+++ b/packages/dialtone-vue/directives/mode_directive/mode.test.js
@@ -0,0 +1,336 @@
+import { mount } from '@vue/test-utils';
+import { DtModeDirective } from './mode.js';
+import DtModeIsland from '@/components/mode_island/mode_island.vue';
+
+describe('DtModeDirective Tests', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ document.documentElement.setAttribute('data-dt-mode', 'light');
+ document.documentElement.setAttribute('data-dt-contrast', 'default');
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ document.documentElement.removeAttribute('data-dt-mode');
+ document.documentElement.removeAttribute('data-dt-contrast');
+ });
+
+ describe('Explicit Mode Tests', () => {
+ it('should set data-dt-mode to light with v-dt-mode:light', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('light');
+ });
+
+ it('should set data-dt-mode to dark with v-dt-mode:dark', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+ });
+ });
+
+ describe('Invert Mode Tests', () => {
+ it('should invert root mode when no arg is provided (default)', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ // Root is light, so invert should be dark
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+ });
+
+ it('should invert root mode with explicit :invert arg', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+ });
+
+ it('should invert dark root mode', () => {
+ document.documentElement.setAttribute('data-dt-mode', 'dark');
+
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('light');
+ });
+
+ it('should invert nearest parent with data-dt-mode', () => {
+ wrapper = mount({
+ template: `
+
+
+
+ `,
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('light');
+ });
+ });
+
+ describe('Contrast Tests', () => {
+ it('should set data-dt-contrast from root', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-contrast')).toBe('default');
+ });
+
+ it('should update data-dt-contrast when root changes', async () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ document.documentElement.setAttribute('data-dt-contrast', 'high');
+ // Wait for MutationObserver callback
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-contrast')).toBe('high');
+ });
+ });
+
+ describe('Reactive Tests', () => {
+ it('should re-invert when root mode changes', async () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+
+ document.documentElement.setAttribute('data-dt-mode', 'dark');
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('light');
+ });
+
+ it('should not apply mode when value is false', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBeUndefined();
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-contrast')).toBeUndefined();
+ });
+
+ it('should apply mode when value changes from false to true', async () => {
+ wrapper = mount({
+ template: '',
+ data () {
+ return { enabled: false };
+ },
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBeUndefined();
+
+ await wrapper.setData({ enabled: true });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+ });
+
+ it('should remove mode when value changes from true to false', async () => {
+ wrapper = mount({
+ template: '',
+ data () {
+ return { enabled: true };
+ },
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+
+ await wrapper.setData({ enabled: false });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBeUndefined();
+ });
+
+ it('should not teardown observers on re-render when arg is unchanged', async () => {
+ const disconnectSpy = vi.spyOn(MutationObserver.prototype, 'disconnect');
+
+ wrapper = mount({
+ template: '',
+ data () {
+ return { count: 0 };
+ },
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ disconnectSpy.mockClear();
+
+ // Trigger a re-render without changing the directive arg or value
+ await wrapper.setData({ count: 1 });
+
+ expect(disconnectSpy).not.toHaveBeenCalled();
+ disconnectSpy.mockRestore();
+ });
+
+ it('should re-initialize when dynamic arg changes', async () => {
+ wrapper = mount({
+ template: '',
+ data () {
+ return { mode: 'dark' };
+ },
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+
+ await wrapper.setData({ mode: 'light' });
+
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('light');
+ });
+ });
+
+ describe('Nesting Tests', () => {
+ it('should read parent mode from DtModeIsland correctly', () => {
+ wrapper = mount({
+ template: `
+
+
+
+ `,
+ components: { DtModeIsland },
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="parent"]').attributes('data-dt-mode')).toBe('dark');
+ expect(wrapper.find('[data-qa="child"]').attributes('data-dt-mode')).toBe('light');
+ });
+
+ it('should nest directive inside directive correctly', async () => {
+ wrapper = mount({
+ template: `
+
+
+
+ `,
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ // Wait for MutationObserver to propagate (child mounted fires before parent)
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(wrapper.find('[data-qa="outer"]').attributes('data-dt-mode')).toBe('dark');
+ expect(wrapper.find('[data-qa="inner"]').attributes('data-dt-mode')).toBe('light');
+ });
+
+ it('should alternate correctly with triple nesting', async () => {
+ wrapper = mount({
+ template: `
+
+ `,
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ // Wait for MutationObserver to propagate through nesting levels
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(wrapper.find('[data-qa="level1"]').attributes('data-dt-mode')).toBe('light');
+ expect(wrapper.find('[data-qa="level2"]').attributes('data-dt-mode')).toBe('dark');
+ expect(wrapper.find('[data-qa="level3"]').attributes('data-dt-mode')).toBe('light');
+ });
+ });
+
+ describe('Cleanup Tests', () => {
+ it('should disconnect MutationObservers on unmount', () => {
+ const disconnectSpy = vi.spyOn(MutationObserver.prototype, 'disconnect');
+
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ wrapper.unmount();
+ expect(disconnectSpy).toHaveBeenCalled();
+ disconnectSpy.mockRestore();
+ });
+ });
+
+ describe('Validation Tests', () => {
+ it('should suggest "invert" when "inverted" is used', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Did you mean "invert"?'),
+ );
+ // Falls back to invert — root is light, so should be dark
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+ warnSpy.mockRestore();
+ });
+
+ it('should warn and fall back to invert on invalid arg', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid mode "invalid"'),
+ );
+ // Falls back to invert — root is light, so should be dark
+ expect(wrapper.find('[data-qa="target"]').attributes('data-dt-mode')).toBe('dark');
+ warnSpy.mockRestore();
+ });
+ });
+
+ describe('Class Tests', () => {
+ it('should NOT auto-add d-mode-island class', () => {
+ wrapper = mount({
+ template: '',
+ }, {
+ global: { plugins: [DtModeDirective] },
+ });
+
+ expect(wrapper.find('[data-qa="target"]').classes()).not.toContain('d-mode-island');
+ });
+ });
+});
diff --git a/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue b/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue
new file mode 100644
index 0000000000..5e6ab6d725
--- /dev/null
+++ b/packages/dialtone-vue/directives/mode_directive/mode_directive_default.story.vue
@@ -0,0 +1,220 @@
+
+
+
+ Explicit modes
+
+
+
+
+
+ Light mode (v-dt-mode:light)
+
+
+ Primary
+ Tertiary
+ Success
+ Critical
+
+
+
+
+
+
+ Dark mode (v-dt-mode:dark)
+
+
+ Primary
+ Tertiary
+ Success
+ Critical
+
+
+
+
+
+
+ Invert mode (default)
+
+
+
+ Inverted from root (v-dt-mode)
+
+
+
+
+ Nested directives
+
+
+
+
+ Dark (outer)
+
+
+
+
+ Light — inverted from dark (inner)
+
+
+
+ Dark — inverted again (deepest)
+
+
+
+
+
+
+
+
+ Disabled (value = false)
+
+
+
+
+ v-dt-mode:invert="false" — no mode applied
+
+
+
+
+
+ v-dt-mode:dark="{{ enabled }}" — toggle to compare
+
+
+
+
+
+
+
+ Dynamic arg
+
+
+
+
+
+
+
+ v-dt-mode:{{ dynamicMode }}
+
+
+
+
+
+
+
diff --git a/packages/dialtone-vue/index.js b/packages/dialtone-vue/index.js
index fd0aad3bc7..387df72cf0 100644
--- a/packages/dialtone-vue/index.js
+++ b/packages/dialtone-vue/index.js
@@ -69,6 +69,7 @@ export * from './components/combobox_with_popover';
// Directives
export * from './directives/tooltip_directive';
export * from './directives/scrollbar_directive';
+export * from './directives/mode_directive';
/// Recipes
export * from './recipes/buttons/callbar_button';