@studiocms/ui 0.1.0 → 0.3.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.
@@ -0,0 +1,376 @@
1
+ ---
2
+ import { Icon } from '../../utils';
3
+ import type { StudioCMSColorway } from '../../utils/colors';
4
+ import { generateID } from '../../utils/generateID';
5
+ import type { HeroIconName } from '../../utils/iconType';
6
+
7
+ interface Tab {
8
+ icon?: HeroIconName;
9
+ label: string;
10
+ color: Exclude<StudioCMSColorway, 'default'>;
11
+ tabId: string;
12
+ }
13
+
14
+ /**
15
+ * The props for the Tabs component.
16
+ */
17
+ interface Props {
18
+ /**
19
+ * The sync key for the tabs. If provided, the active tab will be synced across all instances of the tabs with the same sync key.
20
+ * Additionally, the active tab will be stored session- or local storage depending on the `storage` prop.
21
+ */
22
+ syncKey?: string;
23
+ /**
24
+ * The storage type for the tabs. Defaults to `session`.
25
+ */
26
+ storage?: 'session' | 'persistent';
27
+ /**
28
+ * The variant of the tabs. Defaults to `default`.
29
+ */
30
+ variant?: 'default' | 'starlight';
31
+ /**
32
+ * The alignment of the tabs. Defaults to `left`.
33
+ */
34
+ align?: 'left' | 'center' | 'right';
35
+ }
36
+
37
+ const extractTabInfoWithRegex = (input: string) => {
38
+ const tabItemRegex = /<sui-tab-item([^>]*)>/g;
39
+
40
+ const attributeRegex = /data-([\w-]+)="([^"]*)"/g;
41
+
42
+ const tabs: Tab[] = [];
43
+ let tabMatch: RegExpExecArray | null;
44
+
45
+ // biome-ignore lint/suspicious/noAssignInExpressions: Nop
46
+ while ((tabMatch = tabItemRegex.exec(input)) !== null) {
47
+ let attributes: { [key: string]: string } = {};
48
+
49
+ let attributeMatch: RegExpExecArray | null;
50
+
51
+ if (!tabMatch[1]) continue;
52
+
53
+ // biome-ignore lint/suspicious/noAssignInExpressions: Nop
54
+ while ((attributeMatch = attributeRegex.exec(tabMatch[1])) !== null) {
55
+ if (!attributeMatch[1] || !attributeMatch[2]) continue;
56
+
57
+ if (
58
+ attributeMatch[1] === 'icon' ||
59
+ attributeMatch[1] === 'label' ||
60
+ attributeMatch[1] === 'color'
61
+ ) {
62
+ attributes[attributeMatch[1]] = attributeMatch[2];
63
+ }
64
+
65
+ if (attributeMatch[1] === 'tab-id') {
66
+ attributes.tabId = attributeMatch[2];
67
+ }
68
+ }
69
+
70
+ tabs.push(attributes as unknown as Tab);
71
+ }
72
+
73
+ return tabs;
74
+ };
75
+
76
+ const markTabAsActive = (tabId: string, html: string): string => {
77
+ if (!tabId) return html;
78
+
79
+ const updatedHtml = html.replace(
80
+ new RegExp(`(<sui-tab-item[^>]*data-tab-id="${tabId}"[^>]*class=")([^"]*)(")`, 'g'),
81
+ '$1$2 active$3'
82
+ );
83
+
84
+ return updatedHtml;
85
+ };
86
+
87
+ const uniqueId = generateID('sui-tabs-container');
88
+
89
+ const {
90
+ syncKey: originalSyncKey,
91
+ storage = 'session',
92
+ variant = 'default',
93
+ align = 'left',
94
+ } = Astro.props;
95
+
96
+ const syncKey = originalSyncKey ? `sui-tabs-${originalSyncKey}` : undefined;
97
+
98
+ const tabContents = await Astro.slots.render('default');
99
+ const tabs = extractTabInfoWithRegex(tabContents);
100
+ const finalizedTabContents = markTabAsActive(tabs[0]?.tabId || '', tabContents);
101
+ const containerId = generateID('sui-tabs-container');
102
+ ---
103
+
104
+ <div
105
+ class="sui-tabs-container"
106
+ id={containerId}
107
+ data-sync-key={syncKey}
108
+ data-unique-id={uniqueId}
109
+ data-storage-strategy={storage}
110
+ class:list={[variant, align]}
111
+ >
112
+ <div class="sui-tabs-list" role="tablist">
113
+ {tabs.map((tab, i) => (
114
+ <button
115
+ role="tab"
116
+ class="sui-tab-header"
117
+ id={syncKey ? `${syncKey}-${i}` : undefined}
118
+ tabindex={i === 0 ? 0 : -1}
119
+ data-tab-child={tab.tabId}
120
+ class:list={[i === 0 && "active", tab.color, syncKey && `${syncKey}:${i}`]}
121
+ >
122
+ {tab.icon && (
123
+ <Icon name={tab.icon} width={24} height={24} />
124
+ )}
125
+ <span>{tab.label}</span>
126
+ </button>
127
+ ))}
128
+ </div>
129
+ <div class="sui-tabs-content">
130
+ <Fragment set:html={finalizedTabContents} />
131
+ </div>
132
+ </div>
133
+ <script>
134
+ const tabContainers = document.querySelectorAll<HTMLDivElement>('.sui-tabs-container');
135
+
136
+ for (const tabContainer of tabContainers) {
137
+ const storage = tabContainer.dataset.storageStrategy!;
138
+ const syncKey = tabContainer.dataset.syncKey!;
139
+
140
+ let storageLayer = storage === 'session' ? sessionStorage : localStorage;
141
+
142
+ const constructCustomEvent = (tabIndex: number, uniqueId: string) => {
143
+ return new CustomEvent(`sui-tab-switch:${syncKey}`, {
144
+ detail: {
145
+ tabIndex,
146
+ uniqueId
147
+ }
148
+ });
149
+ }
150
+
151
+ const switchTab = (target: HTMLElement, container: HTMLElement, originatedFromSync = false) => {
152
+ const activeChildren = container.querySelectorAll<HTMLElement>('.active');
153
+
154
+ for (const child of activeChildren) {
155
+ child.tabIndex = -1;
156
+ child.classList.remove('active');
157
+ }
158
+
159
+ const newActiveTab = target as HTMLElement;
160
+ newActiveTab.classList.add('active');
161
+ newActiveTab.tabIndex = 0;
162
+
163
+ const newActiveTabContentId = newActiveTab.dataset.tabChild;
164
+ const newActiveTabContent = container.querySelector<HTMLElement>(`sui-tab-item[data-tab-id="${newActiveTabContentId}"]`)!;
165
+
166
+ newActiveTabContent.classList.add('active');
167
+
168
+ if (syncKey && !originatedFromSync) {
169
+ const tabIndex = Array.prototype.indexOf.call(newActiveTab.parentElement!.children, newActiveTab);
170
+ storageLayer.setItem(syncKey, tabIndex.toString());
171
+
172
+ document.dispatchEvent(constructCustomEvent(tabIndex, container.dataset.uniqueId!));
173
+ }
174
+
175
+ }
176
+
177
+ const tabHeaders = tabContainer.querySelectorAll<HTMLElement>('.sui-tab-header');
178
+
179
+ for (const tab of tabHeaders) {
180
+ tab.addEventListener('click', (e) => switchTab(e.target as HTMLElement, tabContainer));
181
+
182
+ tab.addEventListener('keydown', (e) => {
183
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
184
+ const activeTabIndex = Array.prototype.indexOf.call(tab.parentElement!.children, tab);
185
+ const nextTabIndex = e.key === 'ArrowLeft' ? activeTabIndex - 1 : activeTabIndex + 1;
186
+
187
+
188
+ if (nextTabIndex >= 0 && nextTabIndex < tab.parentElement!.children.length) {
189
+ tab.tabIndex = -1;
190
+
191
+ const nextTab = tab.parentElement!.children[nextTabIndex]! as HTMLElement;
192
+
193
+ nextTab.tabIndex = 0;
194
+ nextTab.click();
195
+ nextTab.focus();
196
+ } else if (nextTabIndex < 0) {
197
+ tab.tabIndex = -1;
198
+
199
+ const lastTab = tab.parentElement!.children[tab.parentElement!.children.length - 1] as HTMLElement;
200
+
201
+ lastTab.tabIndex = 0;
202
+ lastTab.click();
203
+ lastTab.focus();
204
+ } else {
205
+ tab.tabIndex = -1;
206
+
207
+ const firstTab = tab.parentElement!.children[0] as HTMLElement;
208
+
209
+ firstTab.tabIndex = 0;
210
+ firstTab.click();
211
+ firstTab.focus();
212
+ }
213
+ }
214
+ });
215
+ }
216
+
217
+ if (syncKey) {
218
+ // Retrieve the sync key value from localstorage, set the tab.
219
+ const activeTabIndex = storageLayer.getItem(syncKey);
220
+
221
+ if (activeTabIndex) {
222
+ const activeTab = tabContainer.querySelector<HTMLElement>(`#${syncKey}-${activeTabIndex}`);
223
+
224
+ if (activeTab) {
225
+ activeTab.click();
226
+ }
227
+ }
228
+
229
+ document.addEventListener(`sui-tab-switch:${syncKey}`, (e) => {
230
+ const event = e as CustomEvent<{ tabIndex: number, uniqueId: string }>;
231
+ const { tabIndex, uniqueId } = event.detail;
232
+
233
+ if (uniqueId === tabContainer.dataset.uniqueId) return;
234
+
235
+ const newTab = tabContainer.querySelector<HTMLElement>(`#${syncKey}-${tabIndex}`)!;
236
+
237
+ switchTab(newTab, tabContainer, true);
238
+ });
239
+ }
240
+ }
241
+ </script>
242
+ <style>
243
+ .sui-tabs-container {
244
+ width: 100%;
245
+ }
246
+
247
+ .sui-tabs-list {
248
+ display: flex;
249
+ flex-direction: row;
250
+ gap: 1rem;
251
+ align-items: center;
252
+ width: 100%;
253
+ overflow-x: auto;
254
+ overflow-y: visible;
255
+ position: relative;
256
+ }
257
+
258
+ .default .sui-tabs-list {
259
+ margin-top: -4px;
260
+ margin-bottom: calc(2rem - 4px);
261
+ padding: 4px 4px;
262
+ margin-left: -4px;
263
+ }
264
+
265
+ .center .sui-tabs-list {
266
+ justify-content: center;
267
+ }
268
+
269
+ .right .sui-tabs-list {
270
+ justify-content: flex-end;
271
+ }
272
+
273
+ .sui-tab-header {
274
+ margin-top: 0 !important;
275
+ display: flex;
276
+ flex-direction: row;
277
+ gap: .5rem;
278
+ cursor: pointer;
279
+ position: relative;
280
+ min-width: fit-content;
281
+ }
282
+
283
+ .default .sui-tab-header {
284
+ border-radius: 0.5rem;
285
+ height: 40px;
286
+ padding: 0.5rem 0.75rem;
287
+ transition: all .15s ease;
288
+ font-size: 0.875em;
289
+ outline: 2px solid transparent;
290
+ outline-offset: 2px;
291
+ }
292
+
293
+ .sui-tab-header * {
294
+ pointer-events: none;
295
+ }
296
+
297
+ .default .sui-tab-header:focus-visible {
298
+ outline: 2px solid hsl(var(--text-normal));
299
+ outline-offset: 2px;
300
+ }
301
+
302
+ .default .sui-tab-header:hover {
303
+ background-color: hsla(var(--default-flat-active)) !important;
304
+ }
305
+
306
+ .default .sui-tab-header.active {
307
+ background-color: hsla(var(--primary-flat-active)) !important;
308
+ }
309
+
310
+ .default .sui-tab-header.success.active {
311
+ background-color: hsla(var(--success-flat-active)) !important;
312
+ }
313
+
314
+ .default .sui-tab-header.warning.active {
315
+ background-color: hsla(var(--warning-flat-active)) !important;
316
+ }
317
+
318
+ .default .sui-tab-header.danger.active {
319
+ background-color: hsla(var(--danger-flat-active)) !important;
320
+ }
321
+
322
+ .starlight .sui-tabs-list {
323
+ margin-bottom: 1rem;
324
+ gap: 0;
325
+ }
326
+
327
+ .starlight .sui-tabs-list::before {
328
+ content: "";
329
+ position: absolute;
330
+ bottom: 0;
331
+ left: 0;
332
+ width: 100%;
333
+ height: 2px;
334
+ background-color: hsl(var(--border));
335
+ }
336
+
337
+ .starlight .sui-tab-header {
338
+ padding: 0.25rem 1.25rem;
339
+ color: hsl(var(--text-muted));
340
+ }
341
+
342
+ .starlight .sui-tab-header.active {
343
+ font-weight: 600;
344
+ color: hsl(var(--text-normal));
345
+ }
346
+
347
+ .starlight .sui-tab-header.active::after {
348
+ content: "";
349
+ width: 100%;
350
+ height: 2px;
351
+ background-color: hsl(var(--primary-base));
352
+ position: absolute;
353
+ bottom: 0;
354
+ left: 0;
355
+ z-index: 15;
356
+ }
357
+
358
+ .starlight .sui-tab-header:focus-visible::after {
359
+ height: calc(100% - 2px);
360
+ width: calc(100% - 2px);
361
+ bottom: 1px;
362
+ left: 1px;
363
+ border: 2px solid hsl(var(--primary-base));
364
+ background-color: transparent;
365
+ outline: 1px solid hsl(var(--text-normal));
366
+ }
367
+
368
+ .default .sui-tab-header.active {
369
+ background-color: hsla(var(--primary-flat-active)) !important;
370
+ }
371
+
372
+ .sui-tabs-content {
373
+ width: 100%;
374
+ margin: 0 !important;
375
+ }
376
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as TabItem } from './TabItem.astro';
2
+ export { default as Tabs } from './Tabs.astro';
@@ -2,15 +2,45 @@
2
2
  import type { HTMLAttributes } from 'astro/types';
3
3
  import { generateID } from '../utils/generateID';
4
4
 
5
+ /**
6
+ * Props for the textarea component
7
+ */
5
8
  interface Props extends HTMLAttributes<'textarea'> {
9
+ /**
10
+ * The label of the textarea.
11
+ */
6
12
  label?: string;
13
+ /**
14
+ * The placeholder of the textarea.
15
+ */
7
16
  placeholder?: string;
17
+ /**
18
+ * Whether the textarea is required. Defaults to `false`.
19
+ */
8
20
  isRequired?: boolean;
21
+ /**
22
+ * Whether the textarea should take up the full width of its container. Defaults to `false`.
23
+ */
9
24
  fullWidth?: boolean;
25
+ /**
26
+ * Whether the textarea should take up the full height of its container. Defaults to `false`.
27
+ */
10
28
  fullHeight?: boolean;
29
+ /**
30
+ * Whether the textarea should be resizable. Defaults to `false`.
31
+ */
11
32
  resize?: boolean;
33
+ /**
34
+ * The name attribute for the textarea. Useful for form submission.
35
+ */
12
36
  name?: string;
37
+ /**
38
+ * Whether the textarea is disabled. Defaults to `false`.
39
+ */
13
40
  disabled?: boolean;
41
+ /**
42
+ * The default value of the textarea.
43
+ */
14
44
  defaultValue?: string;
15
45
  }
16
46
 
@@ -1,8 +1,7 @@
1
1
  ---
2
- import type { ComponentProps } from 'astro/types';
3
- import Button from './Button.astro';
2
+ import Button, { type Props as ButtonProps } from './Button.astro';
4
3
 
5
- interface Props extends ComponentProps<typeof Button> {}
4
+ interface Props extends ButtonProps {}
6
5
 
7
6
  const props = Astro.props;
8
7
  ---
@@ -26,6 +25,10 @@ const props = Astro.props;
26
25
  </script>
27
26
 
28
27
  <style is:global>
28
+ #sui-theme-toggle, #sui-theme-toggle * {
29
+ color: hsl(var(--text-normal));
30
+ }
31
+
29
32
  #sui-theme-toggle #dark-content, #sui-theme-toggle #light-content {
30
33
  display: none;
31
34
  width: fit-content;
@@ -1,5 +1,11 @@
1
1
  ---
2
+ /**
3
+ * Props for the Toast component.
4
+ */
2
5
  interface Props {
6
+ /**
7
+ * The position of the toaster. All toasts will originate from this position.
8
+ */
3
9
  position?:
4
10
  | 'top-left'
5
11
  | 'top-right'
@@ -7,9 +13,21 @@ interface Props {
7
13
  | 'bottom-left'
8
14
  | 'bottom-right'
9
15
  | 'bottom-center';
16
+ /**
17
+ * The duration of the toast in milliseconds. Defaults to 4000 (4 seconds).
18
+ */
10
19
  duration?: number;
20
+ /**
21
+ * Whether the toast has a close button. Defaults to false.
22
+ */
11
23
  closeButton?: boolean;
24
+ /**
25
+ * The offset of the toaster from the edge of the screen in pixels. Defaults to 32.
26
+ */
12
27
  offset?: number;
28
+ /**
29
+ * The gap between toasts in pixels. Defaults to 8.
30
+ */
13
31
  gap?: number;
14
32
  }
15
33
 
@@ -53,6 +71,65 @@ const {
53
71
 
54
72
  let activeToasts: string[] = [];
55
73
 
74
+ let lastActiveElement: HTMLElement | null = null;
75
+
76
+ const revertFocusBackToLastActiveElement = () => {
77
+ if (lastActiveElement) {
78
+ lastActiveElement.focus();
79
+ lastActiveElement = null;
80
+ }
81
+ };
82
+
83
+ /**
84
+ * Callback wrapper that allows for pausing, continuing and clearing a timer. Based on https://stackoverflow.com/a/20745721.
85
+ * @param callback The callback to be called.
86
+ * @param delay The delay in milliseconds.
87
+ */
88
+ class Timer {
89
+ private id: NodeJS.Timeout | null;
90
+ private started: Date | null;
91
+ private remaining: number;
92
+ private running: boolean;
93
+ private callback: () => any;
94
+
95
+ constructor(callback: () => any, delay: number) {
96
+ this.id = null;
97
+ this.started = null;
98
+ this.remaining = delay;
99
+ this.running = false;
100
+ this.callback = callback;
101
+
102
+ this.start();
103
+ }
104
+
105
+ start = () => {
106
+ this.running = true;
107
+ this.started = new Date();
108
+ this.id = setTimeout(this.callback, this.remaining);
109
+ };
110
+
111
+ pause = () => {
112
+ if (!this.id || !this.started || !this.running) return;
113
+
114
+ this.running = false;
115
+ clearTimeout(this.id);
116
+ this.remaining -= new Date().getTime() - this.started.getTime();
117
+ };
118
+
119
+ getTimeLeft = () => {
120
+ if (this.running) {
121
+ this.pause();
122
+ this.start();
123
+ }
124
+
125
+ return this.remaining;
126
+ };
127
+
128
+ getStateRunning = () => {
129
+ return this.running;
130
+ };
131
+ }
132
+
56
133
  function removeToast(toastID: string) {
57
134
  const toastEl = document.getElementById(toastID);
58
135
 
@@ -70,7 +147,11 @@ const {
70
147
 
71
148
  const toastContainer = document.createElement('div');
72
149
  const toastID = generateID('toast');
150
+ toastContainer.tabIndex = 0;
151
+ toastContainer.ariaLive = 'polite';
152
+ toastContainer.role = 'alert';
73
153
  toastContainer.id = toastID;
154
+ toastContainer.ariaLabel = `${props.title} (F8)`;
74
155
  toastContainer.classList.add('sui-toast-container', props.type, `${props.closeButton || props.persistent && "closeable"}`, `${props.persistent && 'persistent'}`);
75
156
 
76
157
  const toastHeader = document.createElement('div');
@@ -106,6 +187,8 @@ const {
106
187
  closeIconContainer.classList.add('close-icon-container');
107
188
  closeIconContainer.addEventListener('click', () => removeToast(toastID));
108
189
  closeIconContainer.innerHTML = getIconString('x-mark', 'close-icon', 24, 24);
190
+ closeIconContainer.tabIndex = 0;
191
+ closeIconContainer.ariaLabel = 'Close toast';
109
192
 
110
193
  toastHeader.appendChild(closeIconContainer);
111
194
  }
@@ -133,11 +216,43 @@ const {
133
216
  activeToasts.push(toastID);
134
217
 
135
218
  if (!props.persistent) {
136
- setTimeout(
219
+ let timer = new Timer(
137
220
  () => removeToast(toastID),
138
221
  props.duration || (toastParent.dataset.duration ? parseInt(toastParent.dataset.duration) : 4000)
139
222
  );
223
+
224
+ const timerPauseWrapper = () => {
225
+ toastContainer.classList.add('paused');
226
+ timer.pause();
227
+ };
228
+
229
+ const timerStartWrapper = () => {
230
+ toastContainer.classList.remove('paused');
231
+ timer.start();
232
+ };
233
+
234
+ toastContainer.addEventListener('mouseenter', timerPauseWrapper);
235
+ toastContainer.addEventListener('focusin', timerPauseWrapper);
236
+
237
+ toastContainer.addEventListener('mouseleave', timerStartWrapper);
238
+ toastContainer.addEventListener('focusout', () => {
239
+ let focusedOrHasFocused = toastContainer.matches(':focus-within');
240
+
241
+ if (!focusedOrHasFocused) {
242
+ revertFocusBackToLastActiveElement();
243
+ }
244
+
245
+ timerStartWrapper();
246
+ });
140
247
  }
248
+
249
+ toastContainer.addEventListener('keydown', (e) => {
250
+ if (e.key === 'Escape') {
251
+ e.preventDefault();
252
+ removeToast(toastID);
253
+ revertFocusBackToLastActiveElement();
254
+ }
255
+ });
141
256
  }
142
257
 
143
258
  document.addEventListener('createtoast', (e) => {
@@ -147,6 +262,21 @@ const {
147
262
 
148
263
  createToast(event.detail);
149
264
  });
265
+
266
+ window.addEventListener('keydown', (e) => {
267
+ if (e.key === 'F8') {
268
+ e.preventDefault();
269
+
270
+ const oldestToast = activeToasts[0];
271
+
272
+ if (oldestToast) {
273
+ lastActiveElement = document.activeElement as HTMLElement;
274
+
275
+ const toastEl = document.getElementById(oldestToast);
276
+ if (toastEl) toastEl?.focus();
277
+ }
278
+ }
279
+ });
150
280
  </script>
151
281
  <style>
152
282
  #sui-toaster {
@@ -235,6 +365,10 @@ const {
235
365
  animation: toast-progress forwards linear;
236
366
  }
237
367
 
368
+ .sui-toast-container.paused .sui-toast-progress-bar {
369
+ animation-play-state: paused;
370
+ }
371
+
238
372
  .sui-toast-container.success .sui-toast-progress-bar {
239
373
  background-color: hsl(var(--success-base));
240
374
  }
@@ -262,6 +396,11 @@ const {
262
396
  background-color: hsl(var(--default-base));
263
397
  }
264
398
 
399
+ .close-icon-container:focus-visible {
400
+ outline: 2px solid hsl(var(--text-normal));
401
+ outline-offset: 2px;
402
+ }
403
+
265
404
  .sui-toast-container.closing {
266
405
  animation: toast-closing .25s ease forwards;
267
406
  }