Skip to content

Commit 185c56b

Browse files
authored
Merge pull request #94 from tenphi/feat/longhand-modifier
feat(styles): add `longhand` modifier to force longhand CSS output
2 parents 84bb7b9 + ef3adef commit 185c56b

File tree

11 files changed

+401
-10
lines changed

11 files changed

+401
-10
lines changed

.changeset/gentle-pens-expand.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tenphi/tasty': minor
3+
---
4+
5+
Add `longhand` modifier to force longhand CSS output for radius, padding, margin, scroll-margin, inset, and border style handlers.

AGENTS.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ Follow the ordered steps in [`.cursor/commands/submit-changes.md`](.cursor/comma
2929
1. **Typecheck** — Run `pnpm typecheck`. If it fails, stop and fix errors before formatting or committing.
3030
2. **Lint** — Run `pnpm lint`. If it fails, stop and fix errors before formatting or committing.
3131
3. **Format** — Run `pnpm format` so committed code matches Prettier output.
32-
4. **Commit** — Use [Conventional Commits](https://www.conventionalcommits.org/) (`feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`, `ci`; optional scope). Keep the subject line short.
33-
5. **Push** — Do not push to `main`. Confirm the current branch, then push with `git push -u origin HEAD`.
34-
35-
**Changesets:** When a change should trigger an npm release, add a changeset as described in `submit-changes.md` and include it in the same commit. When a change does not need a version bump (for example documentation or repo-only churn), skip the changeset but still run typecheck, lint, and format.
32+
4. **Changeset** — If the change affects published package behavior (features, fixes, refactors, perf), create a changeset file in `.changeset/` as described in `submit-changes.md` and include it in the commit. Use `patch` for fixes/small changes, `minor` for new features/non-breaking API changes, `major` for breaking changes. Skip the changeset only when the change is purely internal (docs, CI, repo-only churn, tests with no behavior change).
33+
5. **Commit** — Use [Conventional Commits](https://www.conventionalcommits.org/) (`feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`, `ci`; optional scope). Keep the subject line short. Include the changeset file in the same commit.
34+
6. **Push** — Do not push to `main`. Confirm the current branch, then push with `git push -u origin HEAD`.
3635

3736
## Stack
3837

docs/styles.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ Element padding with directional modifiers and multi-group support. Use **comma-
104104

105105
**Direction modifiers:** `top`, `right`, `bottom`, `left`
106106

107+
**Output modifier:** `longhand` — forces output as individual CSS longhand properties (`padding-top`, `padding-right`, `padding-bottom`, `padding-left`) instead of the `padding` shorthand. Useful when children need to selectively inherit individual directions.
108+
107109
| Value | Effect |
108110
|-------|--------|
109111
| `"2x"` | All sides `2x` |
@@ -112,6 +114,7 @@ Element padding with directional modifiers and multi-group support. Use **comma-
112114
| `"1x left right"` | Left and right `1x`, top/bottom `0` |
113115
| `"1x, 2x top"` | All sides `1x`, then top overridden to `2x` |
114116
| `"1x, 2x top bottom"` | Left/right `1x`, top/bottom `2x` |
117+
| `"2x longhand"` | All sides `2x`, output as 4 individual `padding-*` properties |
115118
| `true` | All sides `1x` |
116119
| Number | Converted to `px` |
117120

@@ -129,6 +132,8 @@ Element margin. Same syntax, modifiers, and multi-group support as `padding`.
129132

130133
**Direction modifiers:** `top`, `right`, `bottom`, `left`
131134

135+
**Output modifier:** `longhand` — forces output as individual CSS longhand properties (`margin-top`, etc.) instead of the `margin` shorthand.
136+
132137
| Value | Effect |
133138
|-------|--------|
134139
| `"2x"` | All sides `2x` |
@@ -190,12 +195,15 @@ Positioning offsets with directional modifiers and multi-group support. Same dir
190195

191196
**Direction modifiers:** `top`, `right`, `bottom`, `left`
192197

198+
**Output modifier:** `longhand` — forces output as individual CSS properties (`top`, `right`, `bottom`, `left`) instead of the `inset` shorthand.
199+
193200
| Value | Effect |
194201
|-------|--------|
195202
| `"0"` | All sides `0` |
196203
| `"2x top"` | Top `2x`, right/bottom/left `auto` |
197204
| `"1x left right"` | Left and right `1x`, top/bottom `auto` |
198205
| `"0, 2x top"` | All sides `0`, then top overridden to `2x` |
206+
| `"0 longhand"` | All sides `0`, output as individual `top`/`right`/`bottom`/`left` |
199207
| `true` | All sides `0` |
200208

201209
Later comma-separated groups override earlier groups for conflicting directions.
@@ -281,12 +289,15 @@ Border shorthand with directional and multi-group support. Use **comma-separated
281289

282290
**Direction modifiers:** `top`, `right`, `bottom`, `left`
283291

292+
**Output modifier:** `longhand` — forces output as individual CSS properties (`border-top`, `border-right`, `border-bottom`, `border-left`) instead of the `border` shorthand. Useful when children need to selectively inherit individual sides.
293+
284294
| Value | Effect |
285295
|-------|--------|
286296
| `true` | Default border (`1bw solid #border`) on all sides |
287297
| `"2bw dashed #purple"` | All sides: 2bw dashed purple |
288298
| `"2bw top"` | Top only: 2bw solid `#border`, others: 0 |
289299
| `"dotted #danger left right"` | Left/right: 1bw dotted `#danger`, others: 0 |
300+
| `"1bw longhand"` | All sides: 1bw solid `#border`, output as 4 individual `border-*` properties |
290301
| `"1bw #red, 2bw #blue top"` | All sides: 1bw solid `#red`, top overridden to 2bw solid `#blue` |
291302
| `"1bw, dashed top bottom, #purple left right"` | Base: 1bw solid `#border`, top/bottom: 1bw dashed `#border`, left/right: 1bw solid `#purple` |
292303

@@ -310,6 +321,8 @@ Border radius with shape presets and directional modifiers.
310321

311322
**Direction modifiers:** `top`, `right`, `bottom`, `left` — rounds only the specified corners.
312323

324+
**Output modifier:** `longhand` — forces output as individual CSS longhand properties (`border-top-left-radius`, `border-top-right-radius`, `border-bottom-right-radius`, `border-bottom-left-radius`) instead of the `border-radius` shorthand. Useful when children need to selectively inherit individual corners via `radius: 'inherit left'`.
325+
313326
| Value | Effect |
314327
|-------|--------|
315328
| `"2r"` | All corners `2r` |
@@ -318,6 +331,7 @@ Border radius with shape presets and directional modifiers.
318331
| `"1r top"` | Top-left and top-right `1r`, bottom-left and bottom-right `0` |
319332
| `"leaf"` | Alternating sharp/round corners (top-left `0`, top-right `1r`, bottom-right `0`, bottom-left `1r`) |
320333
| `"backleaf"` | Inverse leaf (top-left `1r`, top-right `0`, bottom-right `1r`, bottom-left `0`) |
334+
| `"1r longhand"` | All corners `1r`, output as 4 individual `border-*-radius` properties |
321335
| `"inherit"` | All corners inherit from parent (`border-radius: inherit`) |
322336
| `"inherit right"` | Right corners inherit from parent (uses longhand properties) |
323337

src/styles/border.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,48 @@ describe('borderStyle', () => {
7070
});
7171
});
7272

73+
describe('longhand modifier', () => {
74+
it('expands single value to 4 individual border-* properties', () => {
75+
const result = borderStyle({ border: '1bw longhand' });
76+
expect(result['border-top']).toContain('1px');
77+
expect(result['border-top']).toContain('solid');
78+
expect(result['border-right']).toContain('1px');
79+
expect(result['border-bottom']).toContain('1px');
80+
expect(result['border-left']).toContain('1px');
81+
expect(result).not.toHaveProperty('border');
82+
});
83+
84+
it('expands border with style and color to longhands', () => {
85+
const result = borderStyle({ border: '2bw dashed #purple longhand' });
86+
expect(result['border-top']).toContain('2px');
87+
expect(result['border-top']).toContain('dashed');
88+
expect(result['border-top']).toContain('var(--purple-color)');
89+
expect(result['border-right']).toEqual(result['border-top']);
90+
expect(result['border-bottom']).toEqual(result['border-top']);
91+
expect(result['border-left']).toEqual(result['border-top']);
92+
expect(result).not.toHaveProperty('border');
93+
});
94+
95+
it('expands CSS-wide keyword with longhand', () => {
96+
const result = borderStyle({ border: 'inherit longhand' });
97+
expect(result).toEqual({
98+
'border-top': 'inherit',
99+
'border-right': 'inherit',
100+
'border-bottom': 'inherit',
101+
'border-left': 'inherit',
102+
});
103+
});
104+
105+
it('expands multi-group with longhand to longhands', () => {
106+
const result = borderStyle({ border: '1bw longhand, 2bw top' });
107+
expect(result['border-top']).toContain('2px');
108+
expect(result['border-right']).toContain('1px');
109+
expect(result['border-bottom']).toContain('1px');
110+
expect(result['border-left']).toContain('1px');
111+
expect(result).not.toHaveProperty('border');
112+
});
113+
});
114+
73115
// Multi-group support tests
74116
describe('multi-group support', () => {
75117
it('handles multiple groups with base and direction override', () => {

src/styles/border.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CSS_WIDE_KEYWORDS } from '../parser/const';
22
import { DIRECTIONS, filterMods, parseStyle } from '../utils/styles';
33
import { BORDER_STYLES } from './const';
4+
import { extractCSSWideKeyword } from './shared';
45

56
type Direction = (typeof DIRECTIONS)[number];
67

@@ -75,21 +76,42 @@ export function borderStyle({
7576
}
7677

7778
const processed = parseStyle(strBorder);
78-
const groups: GroupData[] = processed.groups ?? [];
79+
const groups = processed.groups ?? [];
7980

8081
if (!groups.length) return null;
8182

83+
const useLonghand = groups.some((g) => (g.mods ?? []).includes('longhand'));
84+
8285
// Single group - use original logic for backward compatibility
8386
if (groups.length === 1) {
87+
const group = groups[0];
88+
const keyword = extractCSSWideKeyword(group);
89+
90+
if (keyword) {
91+
if (useLonghand) {
92+
return Object.fromEntries(
93+
DIRECTIONS.map((dir) => [`border-${dir}`, keyword]),
94+
);
95+
}
96+
97+
return { border: keyword };
98+
}
99+
84100
const { directions, borderValue } = processGroup({
85-
values: groups[0].values ?? [],
86-
mods: groups[0].mods ?? [],
87-
colors: groups[0].colors ?? [],
101+
values: group.values ?? [],
102+
mods: group.mods ?? [],
103+
colors: group.colors ?? [],
88104
});
89105

90106
const styleValue = formatBorderValue(borderValue);
91107

92108
if (!directions.length) {
109+
if (useLonghand) {
110+
return Object.fromEntries(
111+
DIRECTIONS.map((dir) => [`border-${dir}`, styleValue]),
112+
);
113+
}
114+
93115
return { border: styleValue };
94116
}
95117

@@ -149,9 +171,17 @@ export function borderStyle({
149171
}
150172

151173
// If no group specified any directions and we have an all-directions value,
152-
// return the simple `border` shorthand
174+
// return the simple `border` shorthand (or longhands if requested)
153175
if (!hasAnyDirections && allDirectionsValue) {
154-
return { border: formatBorderValue(allDirectionsValue) };
176+
const formatted = formatBorderValue(allDirectionsValue);
177+
178+
if (useLonghand) {
179+
return Object.fromEntries(
180+
DIRECTIONS.map((dir) => [`border-${dir}`, formatted]),
181+
);
182+
}
183+
184+
return { border: formatted };
155185
}
156186

157187
// Otherwise, output individual border-* properties

src/styles/directional.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ export function processDirectionalStyle(
172172
left: defaultInit,
173173
};
174174

175+
let useLonghand = false;
176+
175177
if (main != null) {
176178
if (typeof main === 'number') {
177179
const v = `${main}px`;
@@ -189,6 +191,10 @@ export function processDirectionalStyle(
189191
const groups = processed.groups ?? [];
190192

191193
for (const group of groups) {
194+
if (group.mods.includes('longhand')) {
195+
useLonghand = true;
196+
}
197+
192198
const kw = extractCSSWideKeyword(group);
193199

194200
if (kw) {
@@ -243,5 +249,14 @@ export function processDirectionalStyle(
243249
if (val) dirs.left = val;
244250
}
245251

252+
if (useLonghand) {
253+
return {
254+
[dirProp('top')]: dirs.top,
255+
[dirProp('right')]: dirs.right,
256+
[dirProp('bottom')]: dirs.bottom,
257+
[dirProp('left')]: dirs.left,
258+
};
259+
}
260+
246261
return optimizeShorthand(property, dirs);
247262
}

src/styles/inset.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,44 @@ describe('insetStyle', () => {
8888
});
8989
});
9090

91+
describe('longhand modifier', () => {
92+
it('expands inset to individual top/right/bottom/left properties', () => {
93+
expect(insetStyle({ inset: '0 longhand' })).toEqual({
94+
top: '0',
95+
right: '0',
96+
bottom: '0',
97+
left: '0',
98+
});
99+
});
100+
101+
it('expands inset with value to individual properties', () => {
102+
expect(insetStyle({ inset: '1x longhand' })).toEqual({
103+
top: '8px',
104+
right: '8px',
105+
bottom: '8px',
106+
left: '8px',
107+
});
108+
});
109+
110+
it('expands directional inset with longhand', () => {
111+
expect(insetStyle({ inset: '2x top longhand' })).toEqual({
112+
top: '16px',
113+
right: 'auto',
114+
bottom: 'auto',
115+
left: 'auto',
116+
});
117+
});
118+
119+
it('expands CSS-wide keyword with longhand', () => {
120+
expect(insetStyle({ inset: 'inherit longhand' })).toEqual({
121+
top: 'inherit',
122+
right: 'inherit',
123+
bottom: 'inherit',
124+
left: 'inherit',
125+
});
126+
});
127+
});
128+
91129
describe('multi-group (comma-separated)', () => {
92130
it('base value with directional override', () => {
93131
expect(insetStyle({ inset: '0, 2x top' })).toEqual({

src/styles/margin.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,53 @@ describe('marginStyle', () => {
261261
});
262262
});
263263

264+
describe('longhand modifier', () => {
265+
it('expands single value to 4 individual properties', () => {
266+
expect(marginStyle({ margin: '2x longhand' })).toEqual({
267+
'margin-top': '16px',
268+
'margin-right': '16px',
269+
'margin-bottom': '16px',
270+
'margin-left': '16px',
271+
});
272+
});
273+
274+
it('expands two-value string to 4 individual properties', () => {
275+
expect(marginStyle({ margin: '1x 2x longhand' })).toEqual({
276+
'margin-top': '8px',
277+
'margin-right': '16px',
278+
'margin-bottom': '8px',
279+
'margin-left': '16px',
280+
});
281+
});
282+
283+
it('expands directional value with longhand', () => {
284+
expect(marginStyle({ margin: '2x top longhand' })).toEqual({
285+
'margin-top': '16px',
286+
'margin-right': '0',
287+
'margin-bottom': '0',
288+
'margin-left': '0',
289+
});
290+
});
291+
292+
it('respects individual direction overrides with longhand', () => {
293+
expect(marginStyle({ margin: '1x longhand', marginTop: '3x' })).toEqual({
294+
'margin-top': '24px',
295+
'margin-right': '8px',
296+
'margin-bottom': '8px',
297+
'margin-left': '8px',
298+
});
299+
});
300+
301+
it('expands CSS-wide keyword with longhand', () => {
302+
expect(marginStyle({ margin: 'inherit longhand' })).toEqual({
303+
'margin-top': 'inherit',
304+
'margin-right': 'inherit',
305+
'margin-bottom': 'inherit',
306+
'margin-left': 'inherit',
307+
});
308+
});
309+
});
310+
264311
describe('auto values', () => {
265312
it('handles auto values for centering', () => {
266313
const result = marginStyle({

0 commit comments

Comments
 (0)