Skip to content

Commit 27fca3f

Browse files
committed
Collect all btld code
1 parent 2f9e887 commit 27fca3f

11 files changed

Lines changed: 494 additions & 0 deletions

File tree

btld-template/btld/attributes.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
interface ObservedBase {
2+
attr: string;
3+
prop?: string;
4+
toAttr?: (value: unknown) => string | null;
5+
attrSync?: true;
6+
}
7+
8+
interface Observed<T> extends ObservedBase {
9+
toProp: (value: string | null) => T;
10+
}
11+
12+
interface ObservedInstance extends Observed<unknown> {
13+
// mark as required:
14+
prop: string;
15+
16+
// component specific:
17+
state: unknown;
18+
setter?: (value: any) => void;
19+
}
20+
21+
const camelCase = (a: string) => a.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
22+
23+
// Helper Functions
24+
25+
function toAttrDefaut(value: unknown) {
26+
if (value === null || value === undefined || value === false) return null;
27+
if (value === true) return '';
28+
return String(value);
29+
}
30+
31+
export function setAttrSafe(el: HTMLElement | undefined, base: ObservedBase | string, value: unknown) {
32+
if (typeof base === 'string') base = { attr: base };
33+
if (!el) return;
34+
const oldValue = el.getAttribute(base.attr);
35+
const newValue = (base.toAttr ?? toAttrDefaut)(value);
36+
37+
if (newValue === oldValue) return;
38+
if (newValue === null) {
39+
el.removeAttribute(base.attr);
40+
} else {
41+
el.setAttribute(base.attr, newValue);
42+
}
43+
}
44+
45+
// Store
46+
47+
export function createAttributeObserverStore(el: HTMLElement) {
48+
const store: { [attr: string]: ObservedInstance } = {};
49+
50+
const api = {
51+
set(o: ObservedInstance | string, value: unknown, opt?: { noSetter?: true; noAttrSync?: true }) {
52+
if (typeof o === 'string') o = store[o];
53+
if (value === o.state) return;
54+
o.state = value;
55+
if (!opt?.noSetter && o.setter) o.setter(value);
56+
if (!opt?.noAttrSync && o.attrSync) setAttrSafe(el, o, value);
57+
},
58+
59+
redefine() {
60+
for (const o of Object.values(store)) {
61+
Object.defineProperty(el, o.prop, {
62+
enumerable: true,
63+
configurable: true,
64+
get: () => o.state,
65+
set: (value: unknown) => api.set(o, value),
66+
});
67+
store[o.attr].state = o.toProp(el.getAttribute(o.attr));
68+
}
69+
},
70+
71+
observe<T>(observed: Observed<T>, setter?: (value: T) => void): T {
72+
const state = observed.toProp(null);
73+
store[observed.attr] = {
74+
...observed,
75+
prop: observed.prop ?? camelCase(observed.attr),
76+
state,
77+
setter,
78+
};
79+
return state;
80+
},
81+
82+
observeBool(o: ObservedBase, setter?: (value: boolean) => void): boolean {
83+
return this.observe({ ...o, toProp: (v) => v !== null }, setter);
84+
},
85+
86+
observeString(o: ObservedBase, setter?: (value: string | null) => void): string | null {
87+
return this.observe({ ...o, toProp: (v) => v }, setter);
88+
},
89+
90+
attributeChange(name: string, attrValue: string | null) {
91+
let o = store[name];
92+
if (o) api.set(o, o.toProp(attrValue), { noAttrSync: true });
93+
},
94+
};
95+
return api;
96+
}

btld-template/btld/base.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createAttributeObserverStore, setAttrSafe } from './attributes';
2+
import { createEventStore } from './events';
3+
import { css, type Literal } from './literals';
4+
5+
const baseStyleShadow = css`
6+
:host([no-animation]),
7+
:host([no-animation]) * {
8+
transition-duration: 0ms !important;
9+
}
10+
`;
11+
12+
export abstract class BtldElementBase extends HTMLElement {
13+
// Stores
14+
15+
readonly events = createEventStore();
16+
readonly props = createAttributeObserverStore(this);
17+
18+
// Literals
19+
20+
applyScoped(scope: string, ...literals: Literal[]) {
21+
for (const l of literals) l.apply(this, scope);
22+
}
23+
24+
applyShadow(...literals: Literal[]) {
25+
const shadow = this.attachShadow({ mode: 'open' });
26+
baseStyleShadow.apply(shadow, '');
27+
for (const l of literals) l.apply(shadow, '');
28+
}
29+
30+
apply(...literals: Literal[]) {
31+
this.applyScoped('', ...literals);
32+
}
33+
34+
// Lifecycle
35+
36+
connectedCallback() {
37+
setAttrSafe(this, 'no-animation', true);
38+
this.connect();
39+
40+
setTimeout(() => {
41+
setAttrSafe(this, 'no-animation', false);
42+
});
43+
}
44+
45+
disconnectedCallback() {
46+
this.events.removeAll();
47+
this.disconnect();
48+
setAttrSafe(this, 'no-animation', true);
49+
}
50+
51+
attributeChangedCallback(name: string, old: string | null, value: string | null) {
52+
this.props.attributeChange(name, value);
53+
this.attributeChanged(name, old, value);
54+
}
55+
56+
connectedMoveCallback() {
57+
this.disconnectedCallback();
58+
this.connectedCallback();
59+
}
60+
61+
// Abstract
62+
63+
connect() {}
64+
disconnect() {}
65+
attributeChanged(name: string, old: string | null, value: string | null) {}
66+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { setAttrSafe } from '../attributes';
2+
import { BtldElementBase } from '../base';
3+
import { css, html } from '../literals';
4+
5+
const detailsCss = css`
6+
details:has(> details-panel[layout='grid-columns'])::details-content,
7+
details:has(> details-panel:not([layout]))::details-content {
8+
content-visibility: visible !important;
9+
overflow: hidden;
10+
}
11+
12+
details:has(> details-panel[layout='flex']) {
13+
display: contents;
14+
}
15+
16+
details:has(> details-panel[layout='flex'])::details-content {
17+
content-visibility: visible !important;
18+
display: contents !important;
19+
}
20+
`;
21+
22+
const layoutCss = css`
23+
:where(details-panel[open]) {
24+
--details-delay: var(--details-delay-opening);
25+
}
26+
27+
:where(details-panel:not([open])) {
28+
--details-delay: var(--details-delay-closing);
29+
}
30+
31+
:where(details-panel) {
32+
--details-ease: ease-in-out;
33+
--details-duration: 0.15s;
34+
35+
--details-transition: var(--details-duration) var(--details-ease) var(--details-delay, 0s);
36+
37+
transition:
38+
flex-grow var(--details-transition),
39+
grid-template-rows var(--details-transition),
40+
grid-template-columns var(--details-transition);
41+
}
42+
43+
:where(details-panel:not([layout]), details-panel[layout='grid-columns']) {
44+
display: grid;
45+
}
46+
47+
:where(details-panel:not([layout]):not([open])) {
48+
grid-template-rows: 0fr;
49+
--details-transform: var(--details-transform-closed, traslateY(-100%));
50+
}
51+
52+
:where(details-panel:not([layout])[open]) {
53+
grid-template-rows: 1fr;
54+
transform: translateY(0);
55+
}
56+
57+
:where(details-panel[layout='grid-columns']:not([open])) {
58+
grid-template-columns: 0fr;
59+
--details-transform: var(--details-transform-closed, traslateX(-100%));
60+
}
61+
62+
:where(details-panel[layout='grid-columns'][open]) {
63+
grid-template-columns: 1fr;
64+
}
65+
66+
:where(details-panel[layout='flex']:not([open])) {
67+
flex-grow: 0.00001;
68+
--details-transform: var(--details-transform-closed);
69+
}
70+
71+
:where(details-panel[layout='flex'][open]) {
72+
flex-grow: 1;
73+
}
74+
`;
75+
76+
const easeCss = css`
77+
details-panel {
78+
--ease-step-at-start: cubic-bezier(0, 1, 0, 1);
79+
--ease-step-at-end: cubic-bezier(1, 0, 1, 0);
80+
--ease-around-closed: var(--ease-step-at-end);
81+
--ease-around-opened: var(--ease-step-at-start);
82+
}
83+
84+
details-panel[open] {
85+
--ease-around-closed: var(--ease-step-at-start);
86+
--ease-around-opened: var(--ease-step-at-end);
87+
}
88+
`;
89+
90+
const shadowHtml = html`
91+
<div part="item">
92+
<div part="transform">
93+
<slot />
94+
</div>
95+
</div>
96+
`;
97+
98+
const shadowCss = css`
99+
[part='item'] {
100+
min-height: 0;
101+
}
102+
[part='transform'] {
103+
transform: var(--details-transform);
104+
transition: transform var(--details-transition);
105+
}
106+
`;
107+
108+
class BtldDetailsPanel extends BtldElementBase {
109+
static observedAttributes = ['open', 'layout'];
110+
111+
open = this.props.observeBool({ attr: 'open', attrSync: true }, (value) => {
112+
setAttrSafe(this.details, 'open', value);
113+
});
114+
115+
layout = this.props.observeString({ attr: 'layout', attrSync: true });
116+
117+
details: HTMLDetailsElement | undefined;
118+
119+
constructor() {
120+
super();
121+
this.props.redefine();
122+
this.applyShadow(shadowHtml, shadowCss);
123+
this.apply(detailsCss, layoutCss, easeCss);
124+
}
125+
126+
override connect() {
127+
if (this.parentElement instanceof HTMLDetailsElement) {
128+
this.details = this.parentElement;
129+
this.props.set('open', this.details.open, { noSetter: true });
130+
this.events.add(this.details, 'toggle', ({ newState }) => {
131+
this.props.set('open', newState === 'open', { noSetter: true });
132+
});
133+
}
134+
135+
// TODO make this changeable after connect
136+
if (this.hasAttribute('toggle-on-click')) {
137+
this.events.add(this, 'click', () => {
138+
this.open = !this.open;
139+
});
140+
}
141+
}
142+
}
143+
144+
customElements.define('details-panel', BtldDetailsPanel);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './details-panel';

btld-template/btld/events.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function createEventStore() {
2+
const cleanup: (() => void)[] = [];
3+
4+
type Evt = HTMLElementEventMap;
5+
6+
return {
7+
add<T extends keyof Evt>(el: EventTarget, type: T, listener: (ev: Evt[T]) => void) {
8+
el.addEventListener(type, listener as EventListener);
9+
cleanup.push(() => el.removeEventListener(type, listener as EventListener));
10+
},
11+
12+
removeAll() {
13+
cleanup.forEach((callback) => callback());
14+
cleanup.length = 0;
15+
},
16+
};
17+
}

btld-template/btld/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './attributes';
2+
export * from './base';
3+
export * from './events';
4+
export * from './literals';

btld-template/btld/literals.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export interface Literal {
2+
apply: (el: ShadowRoot | Element, scope: string) => void;
3+
}
4+
5+
export function css(strings: TemplateStringsArray, ...values: any[]): Literal {
6+
const content = String.raw(strings, ...values);
7+
const cache: { [key: string]: CSSStyleSheet } = {};
8+
9+
return {
10+
apply(el: ShadowRoot | Element, scope: string) {
11+
const root = el instanceof ShadowRoot ? el : document;
12+
if (cache[scope] && !(el instanceof ShadowRoot)) return;
13+
14+
if (!cache[scope]) {
15+
// Replace [_] with [_scope] and [_="value"] with [_scope="value"]
16+
// (?=...): positive lookahead, doesn't include character set in the match
17+
let scoped = scope ? content.replace(/\[_(?=[\]=])/g, `[_${scope}`) : content;
18+
19+
cache[scope] = new CSSStyleSheet();
20+
cache[scope].replaceSync(scoped);
21+
}
22+
23+
root.adoptedStyleSheets.push(cache[scope]);
24+
},
25+
};
26+
}
27+
28+
export function html(strings: TemplateStringsArray, ...values: any[]): Literal {
29+
const content = String.raw(strings, ...values);
30+
31+
return {
32+
apply(el: ShadowRoot | Element, scope: string) {
33+
// Replace _="value" with _scope="value" if preceded by whitespace
34+
// () creates a capturing group, stored as $1
35+
let scoped = scope ? content.replace(/(\s)_(?=\=")/g, `$1_${scope}`) : content;
36+
37+
const fragment = document.createElement('template');
38+
fragment.innerHTML = scoped;
39+
el.prepend(fragment.content);
40+
},
41+
};
42+
}
45.6 KB
Loading

0 commit comments

Comments
 (0)