Skip to content

Commit 16ce2bf

Browse files
committed
feat: use lit web components
1 parent bbe68aa commit 16ce2bf

3 files changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { type ThemeWidgetOptions, ThemeWidgetElement, pluginId } from './themeWidgetElement';
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { LitElement, css, html, nothing } from 'lit';
2+
import { customElement, property, state } from 'lit/decorators.js';
3+
4+
export const pluginId = 'd-widget';
5+
6+
export interface ThemeWidgetOptions {
7+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
8+
size?: 'small' | 'medium' | 'large';
9+
shortcut?: string;
10+
}
11+
12+
@customElement(pluginId)
13+
export class ThemeWidgetElement extends LitElement {
14+
@property({ type: String, reflect: true })
15+
position: ThemeWidgetOptions['position'] = 'bottom-right';
16+
17+
@property({ type: String, reflect: true })
18+
size: ThemeWidgetOptions['size'] = 'medium';
19+
20+
@property({ type: String, reflect: true })
21+
shortcut: ThemeWidgetOptions['shortcut'] = '';
22+
23+
@state()
24+
private theme: string = 'light';
25+
26+
private _host?: any;
27+
28+
static override styles = css`
29+
:host {
30+
--widget-safe-top: env(safe-area-inset-top, 0px);
31+
--widget-safe-right: env(safe-area-inset-right, 0px);
32+
--widget-safe-bottom: env(safe-area-inset-bottom, 0px);
33+
--widget-safe-left: env(safe-area-inset-left, 0px);
34+
35+
--margin: 24px;
36+
--top: var(--margin);
37+
--right: var(--margin);
38+
--bottom: auto;
39+
--left: auto;
40+
}
41+
42+
:host([position='top-left']) {
43+
--top: var(--margin);
44+
--right: auto;
45+
--bottom: auto;
46+
--left: var(--margin);
47+
}
48+
49+
:host([position='top-right']) {
50+
--top: var(--margin);
51+
--right: var(--margin);
52+
--bottom: auto;
53+
--left: auto;
54+
}
55+
56+
:host([position='bottom-left']) {
57+
--top: auto;
58+
--right: auto;
59+
--bottom: calc(var(--margin) * 2);
60+
--left: var(--margin);
61+
}
62+
63+
:host([position='bottom-right']) {
64+
--top: auto;
65+
--right: var(--margin);
66+
--bottom: calc(var(--margin) * 2);
67+
--left: auto;
68+
}
69+
70+
:host([size='small']) {
71+
--size: 36px;
72+
}
73+
74+
:host([size='medium']) {
75+
--size: 56px;
76+
}
77+
78+
:host([size='large']) {
79+
--size: 72px;
80+
}
81+
82+
.d-wrapper {
83+
position: fixed;
84+
top: calc(var(--top) + var(--widget-safe-top));
85+
right: calc(var(--right) + var(--widget-safe-right));
86+
bottom: calc(var(--bottom) + var(--widget-safe-bottom));
87+
left: calc(var(--left) + var(--widget-safe-left));
88+
z-index: 9999;
89+
max-width: fit-content;
90+
display: flex;
91+
align-items: center;
92+
column-gap: 0.5rem;
93+
}
94+
95+
.d-button {
96+
--icon-size: calc(var(--size) * 0.4);
97+
98+
position: relative;
99+
width: var(--size);
100+
height: var(--size);
101+
border: none;
102+
border-bottom: 2px solid hsl(from canvastext h s l / calc(alpha * 0.5));
103+
border-radius: 50%;
104+
box-shadow:
105+
0 1px 3px 0 hsla(210, 6%, 25%, 0.3),
106+
0 4px 8px 3px hsla(210, 6%, 25%, 0.3);
107+
background-color: transparent;
108+
color: canvastext;
109+
cursor: pointer;
110+
font-size: var(--icon-size);
111+
display: flex;
112+
flex-direction: column;
113+
align-items: center;
114+
justify-content: center;
115+
overflow: hidden;
116+
user-select: none;
117+
-webkit-user-select: none;
118+
-webkit-tap-highlight-color: transparent;
119+
}
120+
121+
.d-button::before {
122+
content: '';
123+
position: absolute;
124+
inset: 0;
125+
z-index: -1;
126+
border: none;
127+
background-color: canvas;
128+
filter: invert(90%);
129+
}
130+
131+
.d-button:focus-visible {
132+
outline: 2px solid currentcolor;
133+
outline-offset: 2px;
134+
}
135+
136+
.d-button:active {
137+
transform: scale(0.98);
138+
transition: transform 0.4s ease-in-out;
139+
}
140+
141+
.d-kbd {
142+
position: relative;
143+
padding: 0.25em 0.4em;
144+
font-size: 11px;
145+
font-family: ui-monospace, monospace;
146+
line-height: 1;
147+
letter-spacing: -0.025em;
148+
background-color: canvas;
149+
color: canvastext;
150+
filter: invert(90%);
151+
border: none;
152+
border-bottom: 2px solid hsl(from canvastext h s l / calc(alpha * 0.5));
153+
border-radius: 0.25rem;
154+
box-shadow: 0 0 2px hsla(0, 0%, 0%, 0.1);
155+
user-select: none;
156+
-webkit-user-select: none;
157+
}
158+
`;
159+
160+
init(host: any, options: Required<ThemeWidgetOptions>): void {
161+
this._host = host;
162+
this.position = options.position;
163+
this.size = options.size;
164+
this.shortcut = options.shortcut;
165+
this.theme = host.getCurrentTheme();
166+
}
167+
168+
onThemeChange(theme: string): void {
169+
this.theme = theme;
170+
}
171+
172+
private _handleToggle(): void {
173+
this._host?.toggleTheme();
174+
}
175+
176+
override disconnectedCallback(): void {
177+
super.disconnectedCallback();
178+
this._host._elm.clearListeners();
179+
}
180+
181+
override render() {
182+
const isLeftPosition = this.position?.includes('left');
183+
// TODO: add icon customization option
184+
// accept custom labels/icons for light/dark themes via ThemeWidgetOptions
185+
const iconContent = this.theme === 'light' ? '🌞' : '🌚';
186+
187+
const button = html`
188+
<button
189+
class="d-button"
190+
aria-label="Toggle theme"
191+
role="switch"
192+
aria-checked=${this.theme === 'dark'}
193+
@click=${this._handleToggle}
194+
data-theme=${this.theme}>
195+
<span class="d-icon">${iconContent}</span>
196+
</button>
197+
`;
198+
199+
const kbd = this.shortcut ? html`<kbd class="d-kbd">${this.shortcut}</kbd>` : nothing;
200+
201+
return html`
202+
<div class="d-wrapper">${isLeftPosition ? html`${button}${kbd}` : html`${kbd}${button}`}</div>
203+
`;
204+
}
205+
}
206+
207+
declare global {
208+
interface HTMLElementTagNameMap {
209+
'd-widget': ThemeWidgetElement;
210+
}
211+
}

src/plugins/widget/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type ThemeWidgetOptions, ThemeWidgetElement, pluginId } from './components';
2+
import type { DarkifyPlugin } from '@/types';
3+
4+
export class ThemeWidget implements DarkifyPlugin<ThemeWidgetElement> {
5+
public static readonly pluginId = pluginId;
6+
public el!: ThemeWidgetElement;
7+
private options: Required<ThemeWidgetOptions>;
8+
9+
constructor(host: any, options?: ThemeWidgetOptions) {
10+
this.options = {
11+
position: options?.position ?? 'bottom-right',
12+
size: options?.size ?? 'medium',
13+
shortcut: options?.shortcut ?? '',
14+
};
15+
this.el = document.createElement(pluginId);
16+
this.el.init(host, this.options);
17+
}
18+
19+
render(): ThemeWidgetElement {
20+
document.body.appendChild(this.el);
21+
return this.el;
22+
}
23+
24+
onThemeChange(theme: string): void {
25+
this.el.onThemeChange(theme);
26+
}
27+
28+
onDestroy(): void {
29+
this.el.remove();
30+
}
31+
}

0 commit comments

Comments
 (0)