@fluixi-ui/fx 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/lib/color.d.ts +18 -0
- package/dist/lib/color.d.ts.map +1 -0
- package/dist/lib/color.js +53 -0
- package/dist/lib/hash.d.ts +6 -0
- package/dist/lib/hash.d.ts.map +1 -0
- package/dist/lib/hash.js +12 -0
- package/dist/lib/merge.d.ts +8 -0
- package/dist/lib/merge.d.ts.map +1 -0
- package/dist/lib/merge.js +46 -0
- package/dist/lib/props.d.ts +20 -0
- package/dist/lib/props.d.ts.map +1 -0
- package/dist/lib/props.js +50 -0
- package/dist/lib/resolve.d.ts +8 -0
- package/dist/lib/resolve.d.ts.map +1 -0
- package/dist/lib/resolve.js +142 -0
- package/dist/lib/scales.d.ts +59 -0
- package/dist/lib/scales.d.ts.map +1 -0
- package/dist/lib/scales.js +222 -0
- package/dist/lib/selectors.d.ts +128 -0
- package/dist/lib/selectors.d.ts.map +1 -0
- package/dist/lib/selectors.js +82 -0
- package/dist/lib/sheet.d.ts +17 -0
- package/dist/lib/sheet.d.ts.map +1 -0
- package/dist/lib/sheet.js +74 -0
- package/dist/lib/types.d.ts +100 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +1 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fluixi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# @fluixi-ui/fx
|
|
2
|
+
|
|
3
|
+
Token-aware style objects for Fluixi UI — the engine behind the `fx` prop.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
<Box fx={{ p: 4, bg: 'bgSubtle', radius: 'lg', _hover: { bg: 'bgMuted' } }} />
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No CSS-in-JS runtime, no dependencies. Values resolve to the `--flx-ui-*` custom
|
|
10
|
+
properties `@fluixi-ui/tokens` defines, so anything written with `fx` follows the
|
|
11
|
+
theme, the accent, and the skin — including a theme driven from `@fluixi-css`.
|
|
12
|
+
|
|
13
|
+
## Values
|
|
14
|
+
|
|
15
|
+
A property's scale decides both which tokens a string key resolves against and
|
|
16
|
+
what a bare number means:
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
p: 3 // padding: var(--flx-ui-space-3) — space scale
|
|
20
|
+
m: -2 // margin: calc(var(--flx-ui-space-2) * -1)
|
|
21
|
+
width: 240 // width: 240px — sizing is pixels
|
|
22
|
+
zIndex: 3 // z-index: 3 — unitless stays bare
|
|
23
|
+
radius: 'lg' // border-radius: var(--flx-ui-radius-lg)
|
|
24
|
+
bg: 'primary' // background-color: var(--flx-ui-color-primary)
|
|
25
|
+
bg: 'accent-9'// background-color: var(--flx-ui-accent-9)
|
|
26
|
+
bg: '#ff0000' // background-color: #ff0000 — raw CSS always allowed
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Aliases are deliberately few — `p` `px` `py` `m` `mx` `my` `bg` `radius` `shadow`.
|
|
30
|
+
Everything else is the real CSS property name (`paddingTop`, not `pt`), so there's
|
|
31
|
+
no table to learn. `px`/`py` map to the logical properties and follow writing
|
|
32
|
+
direction.
|
|
33
|
+
|
|
34
|
+
## Selectors
|
|
35
|
+
|
|
36
|
+
A closed set of `_`-prefixed keys. They expand to the library's real conventions,
|
|
37
|
+
not just the CSS pseudo — headless parts signal state through `data-*`/ARIA:
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
_disabled: { opacity: 'disabled' }
|
|
41
|
+
// :disabled, [disabled], [data-disabled]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
State: `_hover` `_active` `_focus` `_focusVisible` `_focusWithin` `_disabled`
|
|
45
|
+
`_checked` `_selected` `_expanded` `_open` `_invalid`
|
|
46
|
+
Pseudo-elements: `_before` `_after` `_placeholder` `_selection`
|
|
47
|
+
Structural: `_first` `_last` `_odd` `_even` `_notFirst` `_notLast` `_children`
|
|
48
|
+
Environment: `_dark` `_light` `_hc` `_rtl` `_motionReduce` `_motionSafe`
|
|
49
|
+
|
|
50
|
+
`_dark` targets `[data-theme='dark']` — the axis `createTheme` drives — and matches
|
|
51
|
+
the element itself as well as a themed ancestor, so scoped theming works.
|
|
52
|
+
|
|
53
|
+
The set being closed is the point: `fx` can't reach an app's class names, so a
|
|
54
|
+
component can never couple itself to consumer CSS.
|
|
55
|
+
|
|
56
|
+
Nesting composes:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
_dark: { _hover: { bg: 'primaryHover' } }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Responsive
|
|
63
|
+
|
|
64
|
+
Mobile-first objects keyed by breakpoint. `base` carries no media query:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
fx={{ p: { base: 3, md: 5 } }}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Container queries
|
|
71
|
+
|
|
72
|
+
Respond to the space a component is actually given rather than the viewport.
|
|
73
|
+
The container is an ancestor — an element can't query itself:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
<Card fx={{ containerType: 'inline-size' }}>
|
|
77
|
+
<Stack fx={{ _container: { md: { flexDirection: 'row' } } }} />
|
|
78
|
+
</Card>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Keys are a breakpoint name or a raw condition. Element-relative sizes often don't
|
|
82
|
+
line up with the page scale, so raw conditions are first-class:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
_container: { '(min-width: 30rem)': { gap: 4 } }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Name a container when the nearest one isn't the one you mean:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
<aside fx={{ containerType: 'inline-size', containerName: 'panel' }}>
|
|
92
|
+
<div fx={{ _container: { name: 'panel', lg: { p: 6 } } }} />
|
|
93
|
+
</aside>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
At-rules nest rather than combine, so a container query and a media query can
|
|
97
|
+
wrap the same declarations.
|
|
98
|
+
|
|
99
|
+
## Colour
|
|
100
|
+
|
|
101
|
+
Helpers emit CSS; they never compute a colour in JS. That matters here: tokens
|
|
102
|
+
are custom properties that re-resolve against `data-theme` / `data-accent`, so a
|
|
103
|
+
value computed at render would freeze whichever theme happened to be active.
|
|
104
|
+
`color-mix()` and relative colour syntax re-resolve on their own, so a derived
|
|
105
|
+
colour keeps following the theme, the accent and the skin.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { alpha, mix, lighten, darken } from '@fluixi-ui/fx';
|
|
109
|
+
|
|
110
|
+
alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
|
|
111
|
+
mix('primary', 'bg', 20) // 20% primary over the page background
|
|
112
|
+
lighten('primary', 8) // oklch(from … calc(l + 0.08) c h / alpha)
|
|
113
|
+
darken('border', 10)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Every amount is a percentage, 0–100. `lighten`/`darken` shift lightness in OKLCH,
|
|
117
|
+
which keeps chroma and hue — a tint stays the same colour instead of drifting grey
|
|
118
|
+
the way mixing with white or black does. They need relative colour syntax
|
|
119
|
+
(Chrome 119+, Safari 16.4+, Firefox 128+); for a wider floor use
|
|
120
|
+
`mix(color, 'white', n)`.
|
|
121
|
+
|
|
122
|
+
## How it lands on the page
|
|
123
|
+
|
|
124
|
+
Flat declarations and every `base` value go to the `style` attribute — most `fx`
|
|
125
|
+
usage never touches the stylesheet. Only selector keys and non-`base` breakpoints
|
|
126
|
+
need a rule; those are hashed, deduped, and inserted under `[data-fx="…"]` into
|
|
127
|
+
`@layer flx.fx`, which sits above components and skins so `fx` wins, and below
|
|
128
|
+
unlayered consumer CSS so an app can still override it.
|
|
129
|
+
|
|
130
|
+
## Server rendering
|
|
131
|
+
|
|
132
|
+
Inline declarations need nothing. For the rule tier, emit what was collected:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
const { css, tokens } = collectFx();
|
|
136
|
+
`<style data-fx-sheet data-fx-tokens="${tokens}">${css}</style>`;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The client adopts that sheet instead of re-inserting its rules.
|
|
140
|
+
|
|
141
|
+
## Composing
|
|
142
|
+
|
|
143
|
+
`mergeFx` folds a component's own styling, its `slotProps`, and the consumer's
|
|
144
|
+
`fx` into one object — later wins per property, selector blocks and breakpoint
|
|
145
|
+
records merge rather than replace:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
resolveFx(mergeFx(componentOwn, slotProps.body?.fx, props.fx));
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Binding it to an element
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
const [local, others] = splitProps(props, ['fx', 'style']);
|
|
155
|
+
return <div {...fxProps(() => local.fx, () => local.style)} {...others} />;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Pass accessors, not values. The resolve runs inside a memo, so a reactive `fx`
|
|
159
|
+
updates without re-rendering. A `style` prop is merged last and stays raw CSS —
|
|
160
|
+
no token resolution — so it remains the lowest-level escape hatch.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './lib/types.js';
|
|
2
|
+
export * from './lib/scales.js';
|
|
3
|
+
export * from './lib/selectors.js';
|
|
4
|
+
export * from './lib/resolve.js';
|
|
5
|
+
export * from './lib/sheet.js';
|
|
6
|
+
export * from './lib/merge.js';
|
|
7
|
+
export * from './lib/color.js';
|
|
8
|
+
export * from './lib/props.js';
|
|
9
|
+
export { hash } from './lib/hash.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './lib/types.js';
|
|
2
|
+
export * from './lib/scales.js';
|
|
3
|
+
export * from './lib/selectors.js';
|
|
4
|
+
export * from './lib/resolve.js';
|
|
5
|
+
export * from './lib/sheet.js';
|
|
6
|
+
export * from './lib/merge.js';
|
|
7
|
+
export * from './lib/color.js';
|
|
8
|
+
export * from './lib/props.js';
|
|
9
|
+
export { hash } from './lib/hash.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ColorValue } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fade a colour toward transparent.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export declare function alpha(color: ColorValue, amount: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* Blend two colours. The amount applies to the first, matching how `color-mix`
|
|
13
|
+
* reads: `mix('primary', 'bg', 20)` is 20% primary over the page background.
|
|
14
|
+
*/
|
|
15
|
+
export declare function mix(color: ColorValue, into: ColorValue, amount: number): string;
|
|
16
|
+
export declare function lighten(color: ColorValue, amount: number): string;
|
|
17
|
+
export declare function darken(color: ColorValue, amount: number): string;
|
|
18
|
+
//# sourceMappingURL=color.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"color.d.ts","sourceRoot":"","sources":["../../src/lib/color.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAmB7C;;;;;;;GAOG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;GAGG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAG/E;AAeD,wBAAgB,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEhE"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { resolveColor } from './scales.js';
|
|
2
|
+
/**
|
|
3
|
+
* Colour helpers emit CSS, they never compute a colour in JS.
|
|
4
|
+
*
|
|
5
|
+
* That's not a stylistic choice. Tokens are custom properties that re-resolve
|
|
6
|
+
* against `data-theme` / `data-accent`, so anything computed here would freeze a
|
|
7
|
+
* value that was only correct for the theme active at render. `color-mix()` and
|
|
8
|
+
* relative colour syntax re-resolve on their own, so a derived colour keeps
|
|
9
|
+
* following the theme, the accent and the skin.
|
|
10
|
+
*
|
|
11
|
+
* Every amount is a percentage, 0–100.
|
|
12
|
+
*/
|
|
13
|
+
function percent(amount) {
|
|
14
|
+
// Trim float noise from the divisions below without pulling in a rounding lib.
|
|
15
|
+
return Math.round(Math.min(100, Math.max(0, amount)) * 1000) / 1000;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fade a colour toward transparent.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* alpha('primary', 12) // color-mix(in oklab, var(--flx-ui-color-primary) 12%, transparent)
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function alpha(color, amount) {
|
|
26
|
+
return `color-mix(in oklab, ${resolveColor(String(color))} ${percent(amount)}%, transparent)`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Blend two colours. The amount applies to the first, matching how `color-mix`
|
|
30
|
+
* reads: `mix('primary', 'bg', 20)` is 20% primary over the page background.
|
|
31
|
+
*/
|
|
32
|
+
export function mix(color, into, amount) {
|
|
33
|
+
const from = resolveColor(String(color));
|
|
34
|
+
return `color-mix(in oklab, ${from} ${percent(amount)}%, ${resolveColor(String(into))})`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Shift lightness in OKLCH, which keeps chroma and hue — so a tint stays the same
|
|
38
|
+
* colour rather than drifting grey the way mixing with white or black does.
|
|
39
|
+
* Alpha is carried through.
|
|
40
|
+
*
|
|
41
|
+
* Relative colour syntax is required (Chrome 119+, Safari 16.4+, Firefox 128+).
|
|
42
|
+
* For a wider floor, `mix(color, 'white', n)` degrades gracefully.
|
|
43
|
+
*/
|
|
44
|
+
function shift(color, delta) {
|
|
45
|
+
const sign = delta < 0 ? '-' : '+';
|
|
46
|
+
return `oklch(from ${resolveColor(String(color))} calc(l ${sign} ${percent(Math.abs(delta)) / 100}) c h / alpha)`;
|
|
47
|
+
}
|
|
48
|
+
export function lighten(color, amount) {
|
|
49
|
+
return shift(color, amount);
|
|
50
|
+
}
|
|
51
|
+
export function darken(color, amount) {
|
|
52
|
+
return shift(color, -amount);
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../src/lib/hash.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAO1C"}
|
package/dist/lib/hash.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FNV-1a, base36. Only needs to distinguish rule bodies within one document, so
|
|
3
|
+
* 32 bits is ample and the short output keeps the emitted attribute readable.
|
|
4
|
+
*/
|
|
5
|
+
export function hash(input) {
|
|
6
|
+
let value = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < input.length; i++) {
|
|
8
|
+
value ^= input.charCodeAt(i);
|
|
9
|
+
value = Math.imul(value, 0x01000193);
|
|
10
|
+
}
|
|
11
|
+
return (value >>> 0).toString(36);
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FxObject, FxProp } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Combine style objects, later winning per property. Used to fold a component's
|
|
4
|
+
* own styling, its `slotProps`, and the consumer's `fx` into one object before
|
|
5
|
+
* resolving — so all three compose instead of clobbering.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mergeFx(...inputs: FxProp[]): FxObject;
|
|
8
|
+
//# sourceMappingURL=merge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../src/lib/merge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAiCnD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,CAOrD"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function flatten(input, out) {
|
|
5
|
+
if (!input)
|
|
6
|
+
return;
|
|
7
|
+
const value = typeof input === 'function' ? input() : input;
|
|
8
|
+
if (!value)
|
|
9
|
+
return;
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
for (const entry of value)
|
|
12
|
+
flatten(entry, out);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
out.push(value);
|
|
16
|
+
}
|
|
17
|
+
function mergeInto(target, source) {
|
|
18
|
+
for (const key of Object.keys(source)) {
|
|
19
|
+
const value = source[key];
|
|
20
|
+
const existing = target[key];
|
|
21
|
+
// Selector blocks and breakpoint records merge; anything else replaces, so a
|
|
22
|
+
// scalar overriding a responsive record (or the reverse) does what it reads like.
|
|
23
|
+
if (isPlainObject(value) && isPlainObject(existing)) {
|
|
24
|
+
const merged = { ...existing };
|
|
25
|
+
mergeInto(merged, value);
|
|
26
|
+
target[key] = merged;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
target[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Combine style objects, later winning per property. Used to fold a component's
|
|
35
|
+
* own styling, its `slotProps`, and the consumer's `fx` into one object before
|
|
36
|
+
* resolving — so all three compose instead of clobbering.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeFx(...inputs) {
|
|
39
|
+
const objects = [];
|
|
40
|
+
for (const input of inputs)
|
|
41
|
+
flatten(input, objects);
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const object of objects)
|
|
44
|
+
mergeInto(result, object);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FxProp } from './types.js';
|
|
2
|
+
export interface FxAttributes {
|
|
3
|
+
style: () => string | undefined;
|
|
4
|
+
'data-fx': () => string | undefined;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Bind an `fx` prop to the attributes an element needs. Pass accessors, not
|
|
8
|
+
* values — the resolve runs inside a memo, so a reactive `fx` updates the style
|
|
9
|
+
* without re-rendering, and identical objects reuse the same inserted rules.
|
|
10
|
+
*
|
|
11
|
+
* A `style` prop is merged last, so it stays the lowest-level escape hatch.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* const [local, others] = splitProps(props, ['fx', 'style']);
|
|
16
|
+
* <div {...fxProps(() => local.fx, () => local.style)} {...others} />
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function fxProps(fx: () => FxProp, style?: () => unknown): FxAttributes;
|
|
20
|
+
//# sourceMappingURL=props.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"props.d.ts","sourceRoot":"","sources":["../../src/lib/props.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAChC,SAAS,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;CACrC;AAeD;;;;;;;;;;;;GAYG;AACH,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,OAAO,GAAG,YAAY,CAiB7E"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createMemo } from '@fluixi/reactive/signal';
|
|
2
|
+
import { resolveFx } from './resolve.js';
|
|
3
|
+
import { cssProperty } from './scales.js';
|
|
4
|
+
import { insertFx } from './sheet.js';
|
|
5
|
+
/** A raw `style` prop stays raw CSS — no token resolution, no alias expansion. */
|
|
6
|
+
function serializeStyle(value) {
|
|
7
|
+
if (value == null)
|
|
8
|
+
return '';
|
|
9
|
+
if (typeof value === 'function')
|
|
10
|
+
return serializeStyle(value());
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return value;
|
|
13
|
+
if (typeof value !== 'object')
|
|
14
|
+
return '';
|
|
15
|
+
return Object.keys(value)
|
|
16
|
+
.map((key) => [key, value[key]])
|
|
17
|
+
.filter(([, entry]) => entry != null && entry !== false)
|
|
18
|
+
.map(([key, entry]) => `${cssProperty(key)}:${String(entry)}`)
|
|
19
|
+
.join(';');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Bind an `fx` prop to the attributes an element needs. Pass accessors, not
|
|
23
|
+
* values — the resolve runs inside a memo, so a reactive `fx` updates the style
|
|
24
|
+
* without re-rendering, and identical objects reuse the same inserted rules.
|
|
25
|
+
*
|
|
26
|
+
* A `style` prop is merged last, so it stays the lowest-level escape hatch.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const [local, others] = splitProps(props, ['fx', 'style']);
|
|
31
|
+
* <div {...fxProps(() => local.fx, () => local.style)} {...others} />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function fxProps(fx, style) {
|
|
35
|
+
// Inserting from inside the memo keeps resolve and insert in one pass; it's
|
|
36
|
+
// idempotent, so re-running on a dependency change costs a Set lookup.
|
|
37
|
+
const resolved = createMemo(() => {
|
|
38
|
+
const result = resolveFx(fx());
|
|
39
|
+
insertFx(result);
|
|
40
|
+
return result;
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
style: () => {
|
|
44
|
+
const own = resolved().style;
|
|
45
|
+
const user = serializeStyle(style?.());
|
|
46
|
+
return (own && user ? `${own};${user}` : own || user) || undefined;
|
|
47
|
+
},
|
|
48
|
+
'data-fx': () => resolved().token,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FxProp, FxResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a style object into inline declarations plus, when it uses selectors or
|
|
4
|
+
* breakpoints, a block of rule text. Pure — nothing is inserted into the document
|
|
5
|
+
* here; see `insertFx`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveFx(input: FxProp): FxResult;
|
|
8
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/lib/resolve.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAqB,MAAM,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AA+ItE;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,CAkBjD"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { breakpoint } from '@fluixi-ui/tokens/reference';
|
|
2
|
+
import { hash } from './hash.js';
|
|
3
|
+
import { ALIASES, cssProperty, resolveValue } from './scales.js';
|
|
4
|
+
import { composeSelectors, isSelectorKey, selectorEntry } from './selectors.js';
|
|
5
|
+
const BREAKPOINTS = breakpoint;
|
|
6
|
+
const ROOT = ['&'];
|
|
7
|
+
const CONTAINER_KEY = '_container';
|
|
8
|
+
function isPlainObject(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function bucketKey(selectors, atRules) {
|
|
12
|
+
return `${atRules.join('||')}||${selectors.join(', ')}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `element` replaces the `&` placeholder, and only inside selectors — a declaration
|
|
16
|
+
* value is free to contain an ampersand (a `url()` query string, say).
|
|
17
|
+
*/
|
|
18
|
+
function serializeRules(buckets, inlineKey, element) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const [key, bucket] of buckets) {
|
|
21
|
+
if (key === inlineKey || bucket.declarations.size === 0)
|
|
22
|
+
continue;
|
|
23
|
+
const selector = bucket.selectors.map((part) => part.split('&').join(element)).join(', ');
|
|
24
|
+
let body = `${selector}{${[...bucket.declarations.values()].join(';')}}`;
|
|
25
|
+
for (let i = bucket.atRules.length - 1; i >= 0; i--)
|
|
26
|
+
body = `${bucket.atRules[i]}{${body}}`;
|
|
27
|
+
out.push(body);
|
|
28
|
+
}
|
|
29
|
+
return out.join('');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Flatten the array form and unwrap the accessor form. Falsy entries drop out so
|
|
33
|
+
* `fx={[base, dense && {...}]}` reads naturally.
|
|
34
|
+
*/
|
|
35
|
+
function normalize(input) {
|
|
36
|
+
if (!input)
|
|
37
|
+
return [];
|
|
38
|
+
const value = typeof input === 'function' ? input() : input;
|
|
39
|
+
if (!value)
|
|
40
|
+
return [];
|
|
41
|
+
if (Array.isArray(value))
|
|
42
|
+
return value.filter(Boolean);
|
|
43
|
+
return [value];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Walk a style object into buckets keyed by selector + media. Nothing is emitted
|
|
47
|
+
* until the walk finishes, so nesting order never affects the result.
|
|
48
|
+
*/
|
|
49
|
+
function walk(object, selectors, atRules, buckets) {
|
|
50
|
+
const key = bucketKey(selectors, atRules);
|
|
51
|
+
let bucket = buckets.get(key);
|
|
52
|
+
if (!bucket) {
|
|
53
|
+
bucket = { selectors, atRules, declarations: new Map() };
|
|
54
|
+
buckets.set(key, bucket);
|
|
55
|
+
}
|
|
56
|
+
for (const name of Object.keys(object)) {
|
|
57
|
+
const value = object[name];
|
|
58
|
+
if (value == null || value === false)
|
|
59
|
+
continue;
|
|
60
|
+
if (name === CONTAINER_KEY) {
|
|
61
|
+
if (isPlainObject(value))
|
|
62
|
+
walkContainer(value, selectors, atRules, buckets);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (isSelectorKey(name)) {
|
|
66
|
+
if (!isPlainObject(value))
|
|
67
|
+
continue;
|
|
68
|
+
const entry = selectorEntry(name);
|
|
69
|
+
// A pseudo-element with no `content` would never render, and supplying it
|
|
70
|
+
// every time is noise.
|
|
71
|
+
const child = 'selector' in entry && /::(before|after)$/.test(entry.selector) && !('content' in value)
|
|
72
|
+
? { content: "''", ...value }
|
|
73
|
+
: value;
|
|
74
|
+
if ('selector' in entry) {
|
|
75
|
+
walk(child, composeSelectors(selectors, entry.selector), atRules, buckets);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
walk(child, selectors, [...atRules, entry.atRule], buckets);
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const property = ALIASES[name] ?? name;
|
|
83
|
+
if (isPlainObject(value)) {
|
|
84
|
+
for (const step of Object.keys(value)) {
|
|
85
|
+
const stepValue = value[step];
|
|
86
|
+
if (stepValue == null || stepValue === false)
|
|
87
|
+
continue;
|
|
88
|
+
if (step === 'base') {
|
|
89
|
+
declare(bucket, property, stepValue);
|
|
90
|
+
}
|
|
91
|
+
else if (BREAKPOINTS[step]) {
|
|
92
|
+
walk({ [property]: stepValue }, selectors, [...atRules, `@media (min-width: ${BREAKPOINTS[step]})`], buckets);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
declare(bucket, property, value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `_container` is parameterised, so it can't live in the static selector table.
|
|
102
|
+
* Keys are either a breakpoint name or a raw condition starting with `(`; a
|
|
103
|
+
* `name` entry scopes the query to a named container rather than the nearest one.
|
|
104
|
+
*/
|
|
105
|
+
function walkContainer(query, selectors, atRules, buckets) {
|
|
106
|
+
const name = typeof query['name'] === 'string' ? `${query['name']} ` : '';
|
|
107
|
+
for (const key of Object.keys(query)) {
|
|
108
|
+
if (key === 'name')
|
|
109
|
+
continue;
|
|
110
|
+
const block = query[key];
|
|
111
|
+
if (!isPlainObject(block))
|
|
112
|
+
continue;
|
|
113
|
+
const condition = BREAKPOINTS[key] ? `(min-width: ${BREAKPOINTS[key]})` : key;
|
|
114
|
+
walk(block, selectors, [...atRules, `@container ${name}${condition}`], buckets);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function declare(bucket, property, value) {
|
|
118
|
+
bucket.declarations.set(property, `${cssProperty(property)}:${resolveValue(property, value)}`);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a style object into inline declarations plus, when it uses selectors or
|
|
122
|
+
* breakpoints, a block of rule text. Pure — nothing is inserted into the document
|
|
123
|
+
* here; see `insertFx`.
|
|
124
|
+
*/
|
|
125
|
+
export function resolveFx(input) {
|
|
126
|
+
const objects = normalize(input);
|
|
127
|
+
if (objects.length === 0)
|
|
128
|
+
return { style: '', rules: '', token: undefined };
|
|
129
|
+
const buckets = new Map();
|
|
130
|
+
const inlineKey = bucketKey(ROOT, []);
|
|
131
|
+
for (const object of objects)
|
|
132
|
+
walk(object, ROOT, [], buckets);
|
|
133
|
+
const inline = buckets.get(inlineKey);
|
|
134
|
+
const style = inline ? [...inline.declarations.values()].join(';') : '';
|
|
135
|
+
// Hash the unscoped form, then re-serialize against the attribute it names, so
|
|
136
|
+
// identical style objects always land on the same token.
|
|
137
|
+
const unscoped = serializeRules(buckets, inlineKey, '&');
|
|
138
|
+
if (!unscoped)
|
|
139
|
+
return { style, rules: '', token: undefined };
|
|
140
|
+
const token = hash(unscoped);
|
|
141
|
+
return { style, rules: serializeRules(buckets, inlineKey, `[data-fx="${token}"]`), token };
|
|
142
|
+
}
|