@esportsplus/ui 0.56.2 → 0.56.4

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.
@@ -0,0 +1,213 @@
1
+ @use '/tokens';
2
+ @use 'variables';
3
+
4
+ .navigation-menu {
5
+ display: flex;
6
+ font-size: var(--font-size);
7
+ max-width: 100%;
8
+ position: relative;
9
+ width: max-content;
10
+
11
+
12
+ &-chevron {
13
+ flex-shrink: 0;
14
+ height: 12px;
15
+ margin-left: 4px;
16
+ position: relative;
17
+ top: 1px;
18
+ width: 12px;
19
+ }
20
+
21
+ // Layers stack in one spot; only the active one, and the one sliding out, are shown.
22
+ &-content {
23
+ left: 0;
24
+ max-width: calc(100vw - 32px);
25
+ padding: 4px;
26
+ position: absolute;
27
+ top: 0;
28
+ visibility: hidden;
29
+ width: max-content;
30
+
31
+ &.--active,
32
+ &.--leaving {
33
+ visibility: visible;
34
+ }
35
+
36
+ &.--leaving {
37
+ pointer-events: none;
38
+ }
39
+ }
40
+
41
+ &-item {
42
+ position: relative;
43
+ }
44
+
45
+ &-link {
46
+ align-items: flex-start;
47
+ border-radius: var(--link-radius);
48
+ color: var(--color);
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 2px;
52
+ outline: none;
53
+ padding: 8px 12px 8px 16px;
54
+ text-align: left;
55
+ text-decoration: none;
56
+ transition: background-color 0.15s ease-out;
57
+
58
+ &:focus-visible {
59
+ outline: 2px solid var(--focus-color);
60
+ outline-offset: -2px;
61
+ }
62
+
63
+ &:hover,
64
+ &:focus-visible {
65
+ background: var(--hover-background);
66
+ }
67
+
68
+ &--compact {
69
+ align-items: center;
70
+ color: var(--description-color);
71
+ flex-direction: row;
72
+ gap: 6px;
73
+ padding: 4px 8px;
74
+
75
+ &:hover {
76
+ color: var(--color);
77
+ }
78
+ }
79
+ }
80
+
81
+ &-link-description {
82
+ color: var(--description-color);
83
+ font-size: var(--description-font-size);
84
+ line-height: 1.625;
85
+ }
86
+
87
+ &-link-title {
88
+ font-size: var(--title-font-size);
89
+ font-weight: var(--font-weight-600);
90
+ }
91
+
92
+ &-list {
93
+ align-items: center;
94
+ display: flex;
95
+ flex-wrap: wrap;
96
+ gap: var(--gap);
97
+ justify-content: center;
98
+ list-style: none;
99
+ margin: 0;
100
+ padding: 0;
101
+ position: relative;
102
+ }
103
+
104
+ // Positioned before it shows, so it fades in where the pointer is; once shown it slides between items.
105
+ &-pill {
106
+ background: var(--hover-background);
107
+ border-radius: 999px;
108
+ left: 0;
109
+ opacity: 0;
110
+ pointer-events: none;
111
+ position: absolute;
112
+ top: 0;
113
+ transition: opacity 0.15s var(--pill-timing-function);
114
+
115
+ @include tokens.state(active) {
116
+ opacity: 1;
117
+ transition:
118
+ opacity var(--pill-duration) var(--pill-timing-function),
119
+ translate var(--pill-duration) var(--pill-timing-function),
120
+ width var(--pill-duration) var(--pill-timing-function);
121
+ }
122
+ }
123
+
124
+ // Size and position ease between panels; the flip on open and the fade on close are played from script.
125
+ &-popup {
126
+ background: var(--background);
127
+ border-radius: var(--popup-radius);
128
+ box-shadow: var(--box-shadow);
129
+ color: var(--color);
130
+ left: 0;
131
+ opacity: 0;
132
+ pointer-events: none;
133
+ position: absolute;
134
+ top: 0;
135
+ transition:
136
+ height var(--popup-duration) cubic-bezier(0.23, 1, 0.32, 1),
137
+ translate var(--popup-duration) cubic-bezier(0.23, 1, 0.32, 1),
138
+ width var(--popup-duration) cubic-bezier(0.23, 1, 0.32, 1);
139
+ visibility: hidden;
140
+ z-index: 50;
141
+
142
+ // Invisible bridge over the gap, so the cursor never "leaves" on its way down from the trigger.
143
+ &::before {
144
+ content: '';
145
+ height: 10px;
146
+ inset-inline: 0;
147
+ position: absolute;
148
+ top: -10px;
149
+ }
150
+
151
+ @include tokens.state(active) {
152
+ opacity: 1;
153
+ pointer-events: auto;
154
+ visibility: visible;
155
+ }
156
+
157
+ &.--instant {
158
+ transition: none;
159
+ }
160
+ }
161
+
162
+ &-trigger {
163
+ align-items: center;
164
+ background: transparent;
165
+ border: 0;
166
+ border-radius: 999px;
167
+ color: var(--color);
168
+ cursor: pointer;
169
+ display: inline-flex;
170
+ font-size: inherit;
171
+ font-weight: var(--font-weight-500);
172
+ height: var(--trigger-height);
173
+ justify-content: center;
174
+ outline: none;
175
+ padding-inline: var(--trigger-padding);
176
+ position: relative;
177
+ text-decoration: none;
178
+ touch-action: manipulation;
179
+ user-select: none;
180
+ white-space: nowrap;
181
+
182
+ &:focus-visible {
183
+ outline: 2px solid var(--focus-color);
184
+ }
185
+
186
+ // Stretches the open trigger's hit area across the gap, so moving to a neighbour never falls through.
187
+ @include tokens.state(active) {
188
+ &::after {
189
+ content: '';
190
+ inset-block: 0;
191
+ inset-inline: calc(var(--gap) * -1);
192
+ position: absolute;
193
+ }
194
+ }
195
+
196
+ &--link {
197
+ padding-inline: 10px;
198
+ }
199
+ }
200
+
201
+ &-viewport {
202
+ height: 100%;
203
+ overflow: hidden;
204
+ position: relative;
205
+ width: 100%;
206
+ }
207
+
208
+
209
+ @media (prefers-reduced-motion: reduce) {
210
+ --pill-duration: 0s;
211
+ --popup-duration: 0s;
212
+ }
213
+ }
@@ -0,0 +1,24 @@
1
+ .navigation-menu {
2
+ --background: var(--color-white-300);
3
+ --box-shadow: 0 0 0 var(--border-width-400) oklch(from var(--color-black-300) l c h / 0.1), var(--box-shadow-300);
4
+ --color: var(--color-text-500);
5
+ --description-color: var(--color-text-300);
6
+ --description-font-size: 0.875rem;
7
+ --focus-color: var(--color-text-500);
8
+ --font-size: 0.875rem;
9
+ --gap: 8px;
10
+ --hover-background: oklch(0.945 0 0);
11
+ --link-radius: 8px;
12
+ --pill-duration: 0.25s;
13
+ --pill-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
14
+ --popup-duration: 0.2s;
15
+ --popup-radius: 12px;
16
+ --title-font-size: 0.9375rem;
17
+ --trigger-height: 36px;
18
+ --trigger-padding: 12px;
19
+
20
+
21
+ &--muted {
22
+ --hover-background: var(--color-grey-400);
23
+ }
24
+ }
@@ -0,0 +1,404 @@
1
+ import { html, type Attributes, type Renderable } from '@esportsplus/template';
2
+ import { effect, onCleanup, reactive, untrack } from '@esportsplus/reactivity';
3
+ import './scss/index.scss';
4
+
5
+
6
+ type A = Attributes & {
7
+ [PLAN_CARD_APPROVE]?: Attributes;
8
+ [PLAN_CARD_VIEW]?: Attributes;
9
+ // Seconds before the plan approves itself; 0 leaves it to the user.
10
+ autoApprove?: number;
11
+ description?: Renderable<unknown>;
12
+ icon?: Renderable<unknown>;
13
+ onapprove?: (auto: boolean) => void;
14
+ ondownload?: VoidFunction;
15
+ onexpand?: VoidFunction;
16
+ onkeydown?: never;
17
+ onview?: VoidFunction;
18
+ // To-dos shown before the rest fold behind "N more".
19
+ peek?: number;
20
+ state?: State;
21
+ title: string;
22
+ todos: Todo[];
23
+ };
24
+
25
+ type State = {
26
+ approved: boolean;
27
+ // Seconds left before auto approval; -1 once cancelled, or when it is off.
28
+ countdown: number;
29
+ expanded: boolean;
30
+ };
31
+
32
+ type Todo = {
33
+ done?: boolean;
34
+ label: string;
35
+ };
36
+
37
+
38
+ const EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';
39
+
40
+ const PLAN_CARD_APPROVE = Symbol.for('@esportsplus/ui/plan-card.approve');
41
+
42
+ const PLAN_CARD_VIEW = Symbol.for('@esportsplus/ui/plan-card.view');
43
+
44
+ // Circumference of the countdown ring (r = 7).
45
+ const RING = 2 * Math.PI * 7;
46
+
47
+
48
+ let uid = 0;
49
+
50
+
51
+ function mac() {
52
+ return /Mac|iPhone|iPad/.test(navigator.platform);
53
+ }
54
+
55
+ function reduced() {
56
+ return matchMedia('(prefers-reduced-motion: reduce)').matches;
57
+ }
58
+
59
+
60
+ const planCard = ({
61
+ autoApprove = 10,
62
+ description,
63
+ icon,
64
+ onapprove,
65
+ ondownload,
66
+ onexpand,
67
+ onview,
68
+ peek = 3,
69
+ state,
70
+ title,
71
+ todos,
72
+ ...attributes
73
+ }: A) => {
74
+ let digits: HTMLElement | undefined,
75
+ id = `plan-card-${++uid}`,
76
+ observer: IntersectionObserver | undefined,
77
+ s = state ?? reactive({ approved: false, countdown: autoApprove > 0 ? autoApprove : -1, expanded: false }),
78
+ started = reactive({ value: false }),
79
+ timer: ReturnType<typeof setInterval> | undefined,
80
+ total = untrack(() => s.countdown);
81
+
82
+ function approve(auto: boolean) {
83
+ if (s.approved) {
84
+ return;
85
+ }
86
+
87
+ clearInterval(timer);
88
+ s.approved = true;
89
+ onapprove?.(auto);
90
+ }
91
+
92
+ function cancel() {
93
+ clearInterval(timer);
94
+ s.countdown = -1;
95
+ }
96
+
97
+ function start() {
98
+ if (started.value || s.approved || s.countdown <= 0) {
99
+ return;
100
+ }
101
+
102
+ started.value = true;
103
+ timer = setInterval(() => {
104
+ if (s.countdown <= 0) {
105
+ clearInterval(timer);
106
+ return;
107
+ }
108
+
109
+ s.countdown -= 1;
110
+
111
+ if (s.countdown === 0) {
112
+ approve(true);
113
+ }
114
+ }, 1000);
115
+ }
116
+
117
+ // Only the digits that change roll in, one after another, so 10 → 9 reads as a single tick.
118
+ function write(value: number, animate: boolean) {
119
+ if (!digits) {
120
+ return;
121
+ }
122
+
123
+ let reduce = reduced(),
124
+ text = String(Math.max(value, 0));
125
+
126
+ while (digits.children.length > text.length) {
127
+ digits.lastElementChild!.remove();
128
+ }
129
+
130
+ for (let i = 0, n = text.length; i < n; i++) {
131
+ let span = digits.children[i] as HTMLElement | undefined;
132
+
133
+ if (span?.textContent === text[i]) {
134
+ continue;
135
+ }
136
+
137
+ let next = document.createElement('span');
138
+
139
+ next.className = 'plan-card-digit';
140
+ next.textContent = text[i];
141
+
142
+ if (span) {
143
+ span.replaceWith(next);
144
+ }
145
+ else {
146
+ digits.append(next);
147
+ }
148
+
149
+ if (animate && !reduce) {
150
+ next.animate(
151
+ [
152
+ { filter: 'blur(1.5px)', opacity: 0, translate: '0 -4px' },
153
+ { filter: 'blur(0px)', opacity: 1, translate: '0 0' }
154
+ ],
155
+ { delay: i * 40, duration: 260, easing: EASE_OUT, fill: 'backwards' }
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ let hidden = Math.max(todos.length - peek, 0),
162
+ previous: number | undefined,
163
+ // Returns nothing on purpose: an effect's value is a dependency of whatever renders this card, so returning
164
+ // the countdown would re-render that parent on every tick.
165
+ stop = effect(() => {
166
+ let countdown = s.countdown;
167
+
168
+ untrack(() => write(countdown, previous !== undefined));
169
+ previous = countdown;
170
+ });
171
+
172
+ onCleanup(() => {
173
+ clearInterval(timer);
174
+ observer?.disconnect();
175
+ stop();
176
+ });
177
+
178
+ return html`
179
+ <article
180
+ class='plan-card'
181
+ ${attributes}
182
+ ${{
183
+ 'aria-labelledby': `${id}-title`,
184
+ class: () => s.approved && '--approved',
185
+ onconnect: (element: HTMLElement) => {
186
+ if (s.countdown <= 0) {
187
+ return;
188
+ }
189
+
190
+ // The clock starts once the card is actually on screen, not while it waits below the fold.
191
+ observer = new IntersectionObserver((entries) => {
192
+ if (!entries.some((entry) => entry.isIntersecting)) {
193
+ return;
194
+ }
195
+
196
+ observer?.disconnect();
197
+ start();
198
+ }, { threshold: 0.5 });
199
+ observer.observe(element);
200
+ },
201
+ onkeydown: (e: KeyboardEvent) => {
202
+ if (e.key === 'Enter' && (mac() ? e.metaKey : e.ctrlKey)) {
203
+ e.preventDefault();
204
+ approve(false);
205
+ }
206
+ }
207
+ }}
208
+ >
209
+ <header class='plan-card-header'>
210
+ <span aria-hidden='true' class='plan-card-icon'>
211
+ ${icon ?? html`
212
+ <svg fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
213
+ <path d='M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7M9 4v13M15 7v13' />
214
+ </svg>
215
+ `}
216
+ </span>
217
+ <h3 class='plan-card-title' id='${id}-title'>${title}</h3>
218
+ ${(ondownload || onexpand) && html`
219
+ <span class='plan-card-actions'>
220
+ ${ondownload && html`
221
+ <button aria-label='Download the plan' class='plan-card-action' data-tooltip='Download' type='button' onclick='${ondownload}'>
222
+ <svg aria-hidden='true' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
223
+ <path d='M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5 5-5M12 4v12' />
224
+ </svg>
225
+ </button>
226
+ `}
227
+ ${onexpand && html`
228
+ <button aria-label='Open the full plan' class='plan-card-action' data-tooltip='Open' type='button' onclick='${onexpand}'>
229
+ <svg aria-hidden='true' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
230
+ <path d='M16 4h4v4M14 10l6-6M8 20H4v-4M4 20l6-6' />
231
+ </svg>
232
+ </button>
233
+ `}
234
+ </span>
235
+ `}
236
+ </header>
237
+
238
+ ${description && html`<p class='plan-card-description'>${description}</p>`}
239
+
240
+ <div aria-labelledby='${id}-todos' class='plan-card-todos' role='group'>
241
+ <div class='plan-card-todos-header'>
242
+ <svg aria-hidden='true' class='plan-card-todos-icon' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
243
+ <path d='M3.5 5.5 5 7l2.5-2.5M3.5 11.5 5 13l2.5-2.5M3.5 17.5 5 19l2.5-2.5M11 6h9M11 12h9M11 18h9' />
244
+ </svg>
245
+ <span class='plan-card-todos-label' id='${id}-todos'>To-dos</span>
246
+ <span class='plan-card-todos-count'>${todos.length}</span>
247
+ </div>
248
+ <ul class='plan-card-list' id='${id}-list'>
249
+ ${todos.map((todo, index) => {
250
+ let row = html`
251
+ <div class='plan-card-todo ${todo.done && '--done'}'>
252
+ <svg aria-hidden='true' class='plan-card-todo-icon' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
253
+ <path d='${todo.done ? 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM8.5 12l2.5 2.5 4.5-5' : 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z'}' />
254
+ </svg>
255
+ <span class='plan-card-todo-label'>${todo.label}</span>
256
+ </div>
257
+ `;
258
+
259
+ if (index < peek) {
260
+ return html`<li>${row}</li>`;
261
+ }
262
+
263
+ // Folded rows close to zero height; the grid track animates, so their text never reflows.
264
+ return html`
265
+ <li
266
+ class='plan-card-fold'
267
+ ${{
268
+ class: () => s.expanded && '--active',
269
+ inert: () => !s.expanded
270
+ }}
271
+ >
272
+ <div class='plan-card-fold-inner'>${row}</div>
273
+ </li>
274
+ `;
275
+ })}
276
+ ${hidden > 0 && html`
277
+ <li>
278
+ <button
279
+ class='plan-card-toggle'
280
+ type='button'
281
+ ${{
282
+ 'aria-controls': `${id}-list`,
283
+ 'aria-expanded': () => String(s.expanded),
284
+ class: () => s.expanded && '--active',
285
+ onclick: () => {
286
+ s.expanded = !s.expanded;
287
+ }
288
+ }}
289
+ >
290
+ <span aria-hidden='true' class='plan-card-toggle-icon'>
291
+ <svg class='plan-card-toggle-more' fill='currentColor' viewBox='0 0 24 24'>
292
+ <circle cx='5' cy='12' r='1.75' />
293
+ <circle cx='12' cy='12' r='1.75' />
294
+ <circle cx='19' cy='12' r='1.75' />
295
+ </svg>
296
+ <svg class='plan-card-toggle-less' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
297
+ <path d='m6 15 6-6 6 6' />
298
+ </svg>
299
+ </span>
300
+ <span class='plan-card-toggle-labels'>
301
+ <span class='plan-card-toggle-label plan-card-toggle-label--more' ${{ 'aria-hidden': () => s.expanded && 'true' }}>${hidden} more</span>
302
+ <span class='plan-card-toggle-label plan-card-toggle-label--less' ${{ 'aria-hidden': () => !s.expanded && 'true' }}>Show less</span>
303
+ </span>
304
+ </button>
305
+ </li>
306
+ `}
307
+ </ul>
308
+ </div>
309
+
310
+ <footer class='plan-card-footer'>
311
+ <span
312
+ aria-live='polite'
313
+ class='plan-card-status'
314
+ ${{
315
+ class: () => s.approved ? '--approved' : s.countdown === -1 && '--cancelled'
316
+ }}
317
+ >
318
+ ${() => s.approved
319
+ ? html`
320
+ <svg aria-hidden='true' class='plan-card-status-icon' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
321
+ <path d='M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM8.5 12l2.5 2.5 4.5-5' />
322
+ </svg>
323
+ <span>${total > 0 && s.countdown === 0 ? 'Auto-approved' : 'Approved'}</span>
324
+ `
325
+ : total > 0 && html`
326
+ <button
327
+ aria-label='Cancel auto approval'
328
+ class='plan-card-cancel'
329
+ data-tooltip='Cancel'
330
+ type='button'
331
+ ${{
332
+ disabled: () => s.countdown === -1,
333
+ onclick: cancel
334
+ }}
335
+ >
336
+ <svg aria-hidden='true' class='plan-card-ring' viewBox='0 0 18 18'>
337
+ <circle class='plan-card-ring-track' cx='9' cy='9' r='7' />
338
+ <circle
339
+ class='plan-card-ring-progress'
340
+ cx='9'
341
+ cy='9'
342
+ r='7'
343
+ style='${() => `stroke-dasharray: ${RING}; stroke-dashoffset: ${
344
+ // Aims at where the ring will be at the next tick, so it drains through each second.
345
+ RING * (1 - Math.max(started.value ? s.countdown - 1 : s.countdown, 0) / total)
346
+ }`}'
347
+ />
348
+ </svg>
349
+ <span aria-hidden='true' class='plan-card-cancel-icon'>
350
+ <svg fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2.5' viewBox='0 0 24 24'>
351
+ <path d='M18 6 6 18M6 6l12 12' />
352
+ </svg>
353
+ </span>
354
+ </button>
355
+ <span>
356
+ Auto Approve in
357
+ <span
358
+ class='plan-card-digits'
359
+ ${{
360
+ onrender: (element: HTMLElement) => {
361
+ digits = element;
362
+ write(untrack(() => s.countdown), false);
363
+ }
364
+ }}
365
+ ></span>s
366
+ </span>
367
+ `}
368
+ </span>
369
+
370
+ <span class='plan-card-buttons'>
371
+ ${onview && html`
372
+ <button class='plan-card-button plan-card-button--secondary' type='button' ${attributes[PLAN_CARD_VIEW]} onclick='${onview}'>
373
+ View Plan
374
+ </button>
375
+ `}
376
+ <button
377
+ class='plan-card-button plan-card-button--primary'
378
+ type='button'
379
+ ${attributes[PLAN_CARD_APPROVE]}
380
+ ${{
381
+ 'aria-keyshortcuts': mac() ? 'Meta+Enter' : 'Control+Enter',
382
+ disabled: () => s.approved,
383
+ onclick: () => approve(false)
384
+ }}
385
+ >
386
+ ${() => s.approved ? 'Approved' : 'Approve'}
387
+ <kbd aria-hidden='true' class='plan-card-kbd'>
388
+ <kbd>${mac() ? '⌘' : 'Ctrl'}</kbd>
389
+ <kbd>
390
+ <svg fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
391
+ <path d='M18 6v6a3 3 0 0 1-3 3H5M9 11l-4 4 4 4' />
392
+ </svg>
393
+ </kbd>
394
+ </kbd>
395
+ </button>
396
+ </span>
397
+ </footer>
398
+ </article>
399
+ `;
400
+ };
401
+
402
+
403
+ export default Object.assign(planCard, { approve: PLAN_CARD_APPROVE, view: PLAN_CARD_VIEW } as const);
404
+ export type { State, Todo };