Skip to content

Commit 8554f5a

Browse files
feat(eslint-plugin-dialtone): DLT-3365 add deprecated-stack-flow-classes rule (#1247)
1 parent 5837895 commit 8554f5a

7 files changed

Lines changed: 322 additions & 3 deletions

File tree

apps/dialtone-documentation/docs/guides/migration/flex-to-stack/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ The migration tool intelligently handles various edge cases:
213213
**Skipped (with warnings):**
214214

215215
- `d-fl-col*` - Deprecated flex column system
216-
- `d-stack*`, `d-flow*` - Auto-spacing utilities (margin-based, incompatible with gap)
216+
- `d-stack*`, `d-flow*` - Auto-spacing utilities (margin-based, incompatible with gap). Also flagged by the [`deprecated-stack-flow-classes`](https://github.com/dialpad/dialtone/blob/staging/packages/eslint-plugin-dialtone/docs/rules/deprecated-stack-flow-classes.md) ESLint rule, which suggests `<dt-stack>` with the equivalent `gap` prop. The rule's docs include a px → `gap` prop mapping table.
217217
- `d-d-inline-flex` - Inline flex containers (DtStack is block-level only)
218218
- Elements with `ref` attributes used for DOM manipulation (see [Ref Attributes](#ref-attributes) below)
219219
- Dynamic `:class` bindings containing flex utilities (see [Dynamic Class Bindings](#dynamic-class-bindings))

apps/dialtone-documentation/docs/utilities/spacing/auto-spacing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ keywords: ["margin","padding","gap","whitespace"]
55
---
66

77
> [!CRITICAL] Deprecated
8-
> `d-stack` and `d-flow` utilities are deprecated. Please use the [Stack](/components/stack) component instead.
8+
> `d-stack` and `d-flow` utilities are deprecated. Please use the [Stack](/components/stack) component with the equivalent `gap` prop instead. New usages are flagged by the [`deprecated-stack-flow-classes`](https://github.com/dialpad/dialtone/blob/staging/packages/eslint-plugin-dialtone/docs/rules/deprecated-stack-flow-classes.md) ESLint rule, which includes a px to `gap` prop mapping.
99
1010
## Adding Space Vertically
1111

packages/dialtone-vue/components/combobox_multi_select/combobox_multi_select_default.story.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<span v-html="$attrs.header" />
4040
</template>
4141
<template #list>
42+
<!-- eslint-disable-next-line dialtone/deprecated-stack-flow-classes -->
4243
<ul
4344
class="d-ps-relative d-stack2 d-m-50 d-px-0"
4445
>

packages/dialtone-vue/components/dropdown/dropdown_list.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
{{ heading }}
1717
</dt-text>
1818
</dt-stack>
19-
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events -->
19+
<!-- eslint-disable-next-line vuejs-accessibility/mouse-events-have-key-events, dialtone/deprecated-stack-flow-classes -->
2020
<ul
2121
:class="['d-ps-relative', 'd-stack2', 'd-px-0', listClass]"
2222
data-qa="dt-dropdown-list-wrapper"
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# deprecated-stack-flow-classes
2+
3+
Flags `d-stack*` and `d-flow*` sibling-margin utility classes and recommends [`<dt-stack>`](https://dialtone.dialpad.com/components/stack.html) with the equivalent `gap` prop.
4+
5+
## Rule Details
6+
7+
`d-stack*` and `d-flow*` apply spacing between adjacent siblings via a `> * + *` margin selector. They predate `<dt-stack>`, which provides the same vertical/horizontal spacing through native flexbox `gap` and exposes it via a typed prop with responsive support.
8+
9+
### Examples of incorrect code
10+
11+
```vue
12+
<div class="d-stack16">
13+
<p>One</p>
14+
<p>Two</p>
15+
</div>
16+
17+
<div class="d-flow24">
18+
<span>One</span>
19+
<span>Two</span>
20+
</div>
21+
22+
<ul class="d-ps-relative d-stack2 d-px-0">
23+
<li>...</li>
24+
</ul>
25+
26+
<ul :class="['d-ps-relative', 'd-stack2', listClass]">
27+
<li>...</li>
28+
</ul>
29+
30+
<div :class="{ 'd-stack16': isCompact }">...</div>
31+
```
32+
33+
### Examples of correct code
34+
35+
```vue
36+
<dt-stack gap="200">
37+
<p>One</p>
38+
<p>Two</p>
39+
</dt-stack>
40+
41+
<dt-stack direction="row" gap="300">
42+
<span>One</span>
43+
<span>Two</span>
44+
</dt-stack>
45+
46+
<dt-stack :gap="{ default: '200', md: '400' }">
47+
<p>One</p>
48+
<p>Two</p>
49+
</dt-stack>
50+
```
51+
52+
## Px to `gap` Prop Mapping
53+
54+
The pixel suffix on the deprecated class maps to a Dialtone spacing token, which is what `<dt-stack>`'s `gap` prop accepts. Use `direction="column"` (the default) to replace `d-stack*`; use `direction="row"` to replace `d-flow*`.
55+
56+
| Deprecated class | DtStack gap prop |
57+
| --- | --- |
58+
| `d-stack0` / `d-flow0` | `gap="0"` |
59+
| `d-stack1` / `d-flow1` | `gap="1"` |
60+
| `d-stack2` / `d-flow2` | `gap="25"` |
61+
| `d-stack4` / `d-flow4` | `gap="50"` |
62+
| `d-stack6` / `d-flow6` | `gap="75"` |
63+
| `d-stack8` / `d-flow8` | `gap="100"` |
64+
| `d-stack12` / `d-flow12` | `gap="150"` |
65+
| `d-stack16` / `d-flow16` | `gap="200"` |
66+
| `d-stack20` / `d-flow20` | `gap="250"` |
67+
| `d-stack24` / `d-flow24` | `gap="300"` |
68+
| `d-stack32` / `d-flow32` | `gap="400"` |
69+
| `d-stack48` / `d-flow48` | `gap="600"` |
70+
| `d-stack64` / `d-flow64` | `gap="800"` |
71+
72+
`d-stack72` and larger sizes have no exact `gap` value in DtStack's prop scale — use the closest available `gap` and fall back to a custom CSS rule on the parent if a larger gap is genuinely required.
73+
74+
## Dynamic Bindings
75+
76+
The rule flags `d-stack*` / `d-flow*` string literals inside `:class` bindings (array, object, or single-string forms). These can't be safely auto-rewritten — the surrounding logic typically needs to move to a `:gap` prop binding:
77+
78+
```vue
79+
<!-- Before -->
80+
<div :class="{ 'd-stack16': isCompact, 'd-stack8': !isCompact }">
81+
82+
<!-- After -->
83+
<dt-stack :gap="isCompact ? '200' : '100'">
84+
```
85+
86+
## When Not To Use
87+
88+
Only `d-stack*` / `d-flow*` are flagged. Other layout utility classes (`d-d-flex`, `d-ai-*`, `d-jc-*`, etc.) are out of scope — see [`prefer-stack-over-flex`](./prefer-stack-over-flex.md) and [`deprecated-stack-alignment-classes`](./deprecated-stack-alignment-classes.md) for those.
89+
90+
If a specific occurrence cannot be migrated yet, disable the rule on that line:
91+
92+
```vue
93+
<!-- eslint-disable-next-line dialtone/deprecated-stack-flow-classes -- legacy code path -->
94+
<ul :class="['d-ps-relative', 'd-stack2', listClass]">
95+
```
96+
97+
## Further Reading
98+
99+
- [DtStack component documentation](https://dialtone.dialpad.com/components/stack.html)
100+
- [Migrating from Flex CSS Utilities to DtStack](https://dialtone.dialpad.com/guides/migration/flex-to-stack/)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @fileoverview Detect deprecated `d-stack*` / `d-flow*` sibling-margin classes;
3+
* recommend `<dt-stack>` with the equivalent `gap` prop.
4+
*/
5+
'use strict';
6+
7+
// Word boundaries handle responsive prefixes (`md:d-stack16`) and adjacent classes.
8+
// Capture group 1 is the px size; used to map to the equivalent `<dt-stack>` `gap` token.
9+
const DEPRECATED_AUTO_SPACING_RE = /\bd-(?:stack|flow)(\d+)\b/;
10+
11+
// Same pattern as a quoted string literal anywhere inside a `:class` binding.
12+
// Scans the whole quoted span so multi-class strings (`'d-ps-relative d-stack2 d-px-0'`)
13+
// and responsive prefixes (`'md:d-stack16'`) both match.
14+
const DEPRECATED_IN_BINDING_RE = /['"][^'"]*\bd-(?:stack|flow)\d+\b[^'"]*['"]/;
15+
16+
// Mirrors the table in docs/rules/deprecated-stack-flow-classes.md.
17+
const PX_TO_GAP = {
18+
0: '0',
19+
1: '1',
20+
2: '25',
21+
4: '50',
22+
6: '75',
23+
8: '100',
24+
12: '150',
25+
16: '200',
26+
20: '250',
27+
24: '300',
28+
32: '400',
29+
48: '600',
30+
64: '800',
31+
};
32+
33+
function describeMatch(text) {
34+
const match = DEPRECATED_AUTO_SPACING_RE.exec(text);
35+
if (!match) return null;
36+
const px = match[1];
37+
const gap = PX_TO_GAP[px];
38+
return { className: match[0], px, gap };
39+
}
40+
41+
/** @type {import('eslint').Rule.RuleModule} */
42+
module.exports = {
43+
meta: {
44+
type: 'suggestion',
45+
docs: {
46+
description: 'Detect deprecated `d-stack*` / `d-flow*` sibling-margin utilities; prefer `<dt-stack>` with the equivalent `gap` prop',
47+
recommended: false,
48+
url: 'https://github.com/dialpad/dialtone/blob/staging/packages/eslint-plugin-dialtone/docs/rules/deprecated-stack-flow-classes.md',
49+
},
50+
fixable: null,
51+
schema: [],
52+
messages: {
53+
preferStack: '`{{className}}` is deprecated. Use `<dt-stack>` with `gap="{{gap}}"` instead.',
54+
preferStackUnmapped: '`{{className}}` is deprecated. No exact `gap` equivalent for {{px}}px — use the closest `<dt-stack>` `gap` value (see rule docs).',
55+
preferStackInBinding: '`{{className}}` detected in dynamic `:class` binding. Use `<dt-stack>` with `gap="{{gap}}"`. Manual migration required.',
56+
preferStackInBindingUnmapped: '`{{className}}` detected in dynamic `:class` binding. No exact `gap` equivalent for {{px}}px — use the closest `<dt-stack>` `gap` value (see rule docs). Manual migration required.',
57+
},
58+
},
59+
60+
create(context) {
61+
const sourceCode = context.sourceCode ?? context.getSourceCode();
62+
const defineTemplateBodyVisitor = sourceCode.parserServices?.defineTemplateBodyVisitor;
63+
if (!defineTemplateBodyVisitor) return {};
64+
65+
return defineTemplateBodyVisitor({
66+
67+
VElement(node) {
68+
const classAttr = node.startTag.attributes.find(
69+
attr => attr.key && attr.key.name === 'class' && !attr.directive,
70+
);
71+
72+
if (classAttr && classAttr.value && classAttr.value.value) {
73+
const info = describeMatch(classAttr.value.value);
74+
if (info) {
75+
context.report({
76+
node: classAttr,
77+
messageId: info.gap ? 'preferStack' : 'preferStackUnmapped',
78+
data: info,
79+
});
80+
}
81+
}
82+
},
83+
84+
VAttribute(node) {
85+
if (node.directive &&
86+
node.key.name.name === 'bind' &&
87+
node.key.argument?.name === 'class' &&
88+
node.value) {
89+
const bindingText = sourceCode.getText(node.value);
90+
if (DEPRECATED_IN_BINDING_RE.test(bindingText)) {
91+
const info = describeMatch(bindingText);
92+
if (info) {
93+
context.report({
94+
node: node,
95+
messageId: info.gap ? 'preferStackInBinding' : 'preferStackInBindingUnmapped',
96+
data: info,
97+
});
98+
}
99+
}
100+
}
101+
},
102+
});
103+
},
104+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @fileoverview Tests for deprecated-stack-flow-classes rule.
3+
*/
4+
'use strict';
5+
6+
const rule = require('../../../lib/rules/deprecated-stack-flow-classes'),
7+
RuleTester = require('eslint').RuleTester;
8+
9+
const ruleTester = new RuleTester({
10+
languageOptions: {
11+
// eslint-disable-next-line n/no-extraneous-require
12+
parser: require('vue-eslint-parser'),
13+
ecmaVersion: 'latest',
14+
},
15+
});
16+
17+
ruleTester.run('deprecated-stack-flow-classes', rule, {
18+
valid: [
19+
{ code: '<template><div>...</div></template>' },
20+
{ code: '<template><div class="">...</div></template>' },
21+
{ code: '<template><div class="d-p-200 d-bgc-primary">...</div></template>' },
22+
{ code: '<template><dt-stack gap="200">...</dt-stack></template>' },
23+
// Word-boundary check: tokens that contain "stack"/"flow" but aren't the deprecated pattern
24+
{ code: '<template><div class="my-stack-thing flow-chart">...</div></template>' },
25+
// Word-boundary check: deprecated stem without the digit suffix is not flagged
26+
{ code: '<template><div class="d-stack d-flow">...</div></template>' },
27+
{ code: '<template><div :class="[\'d-p-200\', someClass]">...</div></template>' },
28+
{ code: '<template><div :class="{ \'d-bgc-primary\': active }">...</div></template>' },
29+
// Identifier (not a string literal) referencing a similarly-named variable is not flagged
30+
{ code: '<template><div :class="dStack16">...</div></template>' },
31+
],
32+
33+
invalid: [
34+
// Carries the data assertion that pins the px → gap mapping in the message.
35+
{
36+
code: '<template><div class="d-stack16">...</div></template>',
37+
errors: [{
38+
messageId: 'preferStack',
39+
data: { className: 'd-stack16', px: '16', gap: '200' },
40+
}],
41+
},
42+
{
43+
code: '<template><div class="d-flow24">...</div></template>',
44+
errors: [{ messageId: 'preferStack' }],
45+
},
46+
// Mirrors a real OOS site: deprecated class alongside other utilities
47+
{
48+
code: '<template><ul class="d-ps-relative d-stack2 d-m-50 d-px-0">...</ul></template>',
49+
errors: [{ messageId: 'preferStack' }],
50+
},
51+
{
52+
code: '<template><div class="md:d-stack16">...</div></template>',
53+
errors: [{ messageId: 'preferStack' }],
54+
},
55+
// Pin: rule does NOT whitelist <dt-stack> — using deprecated classes there is still flagged
56+
{
57+
code: '<template><dt-stack class="d-stack16">...</dt-stack></template>',
58+
errors: [{ messageId: 'preferStack' }],
59+
},
60+
{
61+
code: '<template><ul :class="[\'d-ps-relative\', \'d-stack2\', \'d-px-0\', listClass]">...</ul></template>',
62+
errors: [{ messageId: 'preferStackInBinding' }],
63+
},
64+
{
65+
code: '<template><div :class="{ \'d-stack16\': condition }">...</div></template>',
66+
errors: [{ messageId: 'preferStackInBinding' }],
67+
},
68+
// Carries the data assertion for binding messages.
69+
{
70+
code: '<template><div :class="\'d-flow24\'">...</div></template>',
71+
errors: [{
72+
messageId: 'preferStackInBinding',
73+
data: { className: 'd-flow24', px: '24', gap: '300' },
74+
}],
75+
},
76+
{
77+
code: '<template><div v-bind:class="[\'d-stack8\']">...</div></template>',
78+
errors: [{ messageId: 'preferStackInBinding' }],
79+
},
80+
// Responsive-prefixed deprecated class inside a binding string literal
81+
{
82+
code: '<template><div :class="[\'md:d-stack16\']">...</div></template>',
83+
errors: [{ messageId: 'preferStackInBinding' }],
84+
},
85+
// Multi-class quoted string literal: deprecated token alongside other classes
86+
{
87+
code: '<template><div :class="\'d-ps-relative d-stack2 d-px-0\'">...</div></template>',
88+
errors: [{ messageId: 'preferStackInBinding' }],
89+
},
90+
// Static and dynamic on the same element each report independently
91+
{
92+
code: '<template><div class="d-stack16" :class="[\'d-flow24\']">...</div></template>',
93+
errors: [
94+
{ messageId: 'preferStack' },
95+
{ messageId: 'preferStackInBinding' },
96+
],
97+
},
98+
// Template literal binding: outer attribute quotes wrap the backtick-delimited
99+
// expression, so the binding regex still matches and the rule fires.
100+
{
101+
code: '<template><div :class="`d-stack16`">...</div></template>',
102+
errors: [{ messageId: 'preferStackInBinding' }],
103+
},
104+
// Sizes outside the gap-token scale (>= 72) fall back to the unmapped message.
105+
{
106+
code: '<template><div class="d-stack72">...</div></template>',
107+
errors: [{ messageId: 'preferStackUnmapped' }],
108+
},
109+
{
110+
code: '<template><div :class="\'d-stack72\'">...</div></template>',
111+
errors: [{ messageId: 'preferStackInBindingUnmapped' }],
112+
},
113+
],
114+
});

0 commit comments

Comments
 (0)