@studiocms/ui 0.0.1 → 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.
Files changed (37) hide show
  1. package/README.md +28 -544
  2. package/package.json +11 -6
  3. package/src/components/Button.astro +303 -269
  4. package/src/components/Card.astro +37 -13
  5. package/src/components/Center.astro +2 -2
  6. package/src/components/Checkbox.astro +99 -29
  7. package/src/components/Divider.astro +15 -8
  8. package/src/components/Dropdown/Dropdown.astro +102 -41
  9. package/src/components/Dropdown/dropdown.ts +111 -23
  10. package/src/components/Footer.astro +137 -0
  11. package/src/components/Input.astro +42 -14
  12. package/src/components/Modal/Modal.astro +84 -30
  13. package/src/components/Modal/modal.ts +43 -9
  14. package/src/components/RadioGroup.astro +153 -29
  15. package/src/components/Row.astro +16 -7
  16. package/src/components/SearchSelect.astro +278 -222
  17. package/src/components/Select.astro +260 -127
  18. package/src/components/Sidebar/Double.astro +12 -12
  19. package/src/components/Sidebar/Single.astro +6 -6
  20. package/src/components/Sidebar/helpers.ts +53 -7
  21. package/src/components/Tabs/TabItem.astro +47 -0
  22. package/src/components/Tabs/Tabs.astro +376 -0
  23. package/src/components/Tabs/index.ts +2 -0
  24. package/src/components/Textarea.astro +56 -15
  25. package/src/components/ThemeToggle.astro +14 -8
  26. package/src/components/Toast/Toaster.astro +171 -31
  27. package/src/components/Toggle.astro +89 -21
  28. package/src/components/User.astro +34 -15
  29. package/src/components/index.ts +24 -22
  30. package/src/components.ts +2 -0
  31. package/src/css/colors.css +65 -65
  32. package/src/css/resets.css +0 -1
  33. package/src/integration.ts +18 -0
  34. package/src/layouts/RootLayout.astro +1 -2
  35. package/src/types/index.ts +1 -1
  36. package/src/utils/ThemeHelper.ts +135 -117
  37. package/src/utils/create-resolver.ts +30 -0
@@ -1,13 +1,20 @@
1
1
  class DropdownHelper {
2
- container: HTMLDivElement;
3
- toggleEl: HTMLDivElement;
4
- dropdown: HTMLUListElement;
2
+ private container: HTMLDivElement;
3
+ private toggleEl: HTMLDivElement;
4
+ private dropdown: HTMLUListElement;
5
+
6
+ private alignment: 'start' | 'center' | 'end';
7
+ private triggerOn: 'left' | 'right' | 'both';
8
+ private fullWidth = false;
9
+ private focusIndex = -1;
5
10
 
6
- alignment: 'start' | 'center' | 'end';
7
- triggerOn: 'left' | 'right' | 'both';
8
11
  active = false;
9
- fullWidth = false;
10
12
 
13
+ /**
14
+ * A helper function to interact with dropdowns.
15
+ * @param id The ID of the dropdown.
16
+ * @param fullWidth Whether the dropdown should be full width. Not needed normally.
17
+ */
11
18
  constructor(id: string, fullWidth?: boolean) {
12
19
  this.container = document.getElementById(`${id}-container`) as HTMLDivElement;
13
20
 
@@ -21,6 +28,44 @@ class DropdownHelper {
21
28
  this.toggleEl = document.getElementById(`${id}-toggle-btn`) as HTMLDivElement;
22
29
  this.dropdown = document.getElementById(`${id}-dropdown`) as HTMLUListElement;
23
30
 
31
+ if (fullWidth) this.fullWidth = true;
32
+
33
+ this.hideOnClickOutside(this.container);
34
+
35
+ this.initialBehaviorRegistration();
36
+ this.initialOptClickRegistration();
37
+ }
38
+
39
+ /**
40
+ * Registers a click callback for the dropdown options. Whenever one of the options
41
+ * is clicked, the callback will be called with the value of the option.
42
+ * @param func The callback function.
43
+ */
44
+ public registerClickCallback = (func: (value: string) => void) => {
45
+ const dropdownOpts = this.dropdown.querySelectorAll('li');
46
+
47
+ for (const opt of dropdownOpts) {
48
+ opt.removeEventListener('click', this.hide);
49
+
50
+ opt.addEventListener('click', () => {
51
+ func(opt.dataset.value || '');
52
+ this.hide();
53
+ });
54
+ }
55
+ };
56
+
57
+ /**
58
+ * Sets up all listeners for the dropdown.
59
+ */
60
+ private initialBehaviorRegistration = () => {
61
+ window.addEventListener('scroll', this.hide);
62
+ document.addEventListener('keydown', (e) => {
63
+ if (e.key === 'Escape') this.hide();
64
+ });
65
+ document.addEventListener('astro:before-preparation', () => {
66
+ this.dropdown.classList.remove('initialized');
67
+ });
68
+
24
69
  if (this.triggerOn === 'left') {
25
70
  this.toggleEl.addEventListener('click', this.toggle);
26
71
  } else if (this.triggerOn === 'both') {
@@ -36,31 +81,56 @@ class DropdownHelper {
36
81
  });
37
82
  }
38
83
 
39
- if (fullWidth) this.fullWidth = true;
84
+ this.toggleEl.addEventListener('keydown', (e) => {
85
+ if (!this.active) return;
40
86
 
41
- window.addEventListener('scroll', this.hide);
42
- document.addEventListener('astro:before-preparation', () => {
43
- this.dropdown.classList.remove('initialized');
44
- });
87
+ if (e.key === 'Enter') {
88
+ e.preventDefault();
45
89
 
46
- this.hideOnClickOutside(this.container);
90
+ const focused = this.dropdown.querySelector('li.focused') as HTMLLIElement;
47
91
 
48
- this.initialOptClickRegistration();
49
- }
92
+ if (!focused) {
93
+ this.hide();
94
+ return;
95
+ }
50
96
 
51
- public registerClickCallback = (func: (value: string) => void) => {
52
- const dropdownOpts = this.dropdown.querySelectorAll('li');
97
+ focused.click();
98
+ }
53
99
 
54
- for (const opt of dropdownOpts) {
55
- opt.removeEventListener('click', this.hide);
100
+ if (e.key === 'ArrowDown') {
101
+ e.preventDefault();
56
102
 
57
- opt.addEventListener('click', () => {
58
- func(opt.dataset.value || '');
59
- this.hide();
60
- });
61
- }
103
+ this.focusIndex =
104
+ this.focusIndex === this.dropdown.children.length - 1 ? 0 : this.focusIndex + 1;
105
+ }
106
+
107
+ if (e.key === 'ArrowUp') {
108
+ e.preventDefault();
109
+
110
+ this.focusIndex =
111
+ this.focusIndex === 0 ? this.dropdown.children.length - 1 : this.focusIndex - 1;
112
+ }
113
+
114
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
115
+ if (this.focusIndex > this.dropdown.children.length - 1) {
116
+ this.focusIndex = 0;
117
+ }
118
+
119
+ this.dropdown.querySelector('li.focused')?.classList.remove('focused');
120
+
121
+ const newFocus = this.dropdown.children[this.focusIndex] as HTMLLIElement;
122
+
123
+ if (!newFocus) return;
124
+
125
+ newFocus.classList.add('focused');
126
+ newFocus.focus();
127
+ }
128
+ });
62
129
  };
63
130
 
131
+ /**
132
+ * Registers callbacks to hide the dropdown when an option is clicked.
133
+ */
64
134
  private initialOptClickRegistration = () => {
65
135
  const dropdownOpts = this.dropdown.querySelectorAll('li');
66
136
 
@@ -69,6 +139,9 @@ class DropdownHelper {
69
139
  }
70
140
  };
71
141
 
142
+ /**
143
+ * A function to toggle the dropdown.
144
+ */
72
145
  public toggle = () => {
73
146
  if (this.active) {
74
147
  this.hide();
@@ -78,13 +151,22 @@ class DropdownHelper {
78
151
  this.show();
79
152
  };
80
153
 
154
+ /**
155
+ * A function to hide the dropdown.
156
+ */
81
157
  public hide = () => {
82
158
  this.dropdown.classList.remove('active');
83
159
  this.active = false;
160
+ this.focusIndex = -1;
161
+
162
+ this.dropdown.querySelector('li.focused')?.classList.remove('focused');
84
163
 
85
164
  setTimeout(() => this.dropdown.classList.remove('above', 'below'), 200);
86
165
  };
87
166
 
167
+ /**
168
+ * A function to show the dropdown.
169
+ */
88
170
  public show = () => {
89
171
  const isMobile = window.matchMedia('screen and (max-width: 840px)').matches;
90
172
 
@@ -144,11 +226,17 @@ class DropdownHelper {
144
226
  CustomRect.right <= (window.innerWidth || document.documentElement.clientWidth)
145
227
  ) {
146
228
  this.dropdown.classList.add('active', 'below');
229
+ this.focusIndex = -1;
147
230
  } else {
148
231
  this.dropdown.classList.add('active', 'above');
232
+ this.focusIndex = this.dropdown.children.length;
149
233
  }
150
234
  };
151
235
 
236
+ /**
237
+ * A jQuery-like function to hide the dropdown when clicking outside of it.
238
+ * @param element The element to hide when clicking outside of it.
239
+ */
152
240
  private hideOnClickOutside = (element: HTMLElement) => {
153
241
  const outsideClickListener = (event: MouseEvent) => {
154
242
  if (!event.target) return;
@@ -0,0 +1,137 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+
4
+ interface FooterLink {
5
+ /**
6
+ * The label of the link.
7
+ */
8
+ label: string;
9
+ /**
10
+ * The href of the link.
11
+ */
12
+ href: string;
13
+ }
14
+
15
+ /**
16
+ * The props for the footer component.
17
+ */
18
+ interface Props extends HTMLAttributes<'footer'> {
19
+ /**
20
+ * The links to display in the footer.
21
+ */
22
+ links: {
23
+ [label: string]: FooterLink[];
24
+ };
25
+ /**
26
+ * The copyright text to display in the footer.
27
+ */
28
+ copyright: string;
29
+ }
30
+
31
+ const { copyright, links, ...props } = Astro.props;
32
+ ---
33
+
34
+ <footer {...props}>
35
+ <div class="upper">
36
+ <div>
37
+ <slot name="brand" />
38
+ </div>
39
+ <div class="links">
40
+ {Object.keys(links).map((groupLabel) => (
41
+ <ul>
42
+ <li class="sui-footer-link-label">{groupLabel}</li>
43
+ {links[groupLabel]!.map((item) => (
44
+ <li>
45
+ <a href={item.href}>{item.label}</a>
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ ))}
50
+ </div>
51
+ </div>
52
+ <hr class="separator" />
53
+ <div class="lower">
54
+ <span class="copyright-span">{copyright}</span>
55
+ <slot name="socials" />
56
+ </div>
57
+ </footer>
58
+ <style>
59
+ footer {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 2rem;
63
+ background-color: hsl(var(--background-step-1));
64
+ padding: 2rem 10vw;
65
+ color: hsl(var(--text-normal)) !important;
66
+ }
67
+
68
+ .upper, .lower {
69
+ display: flex;
70
+ flex-direction: row;
71
+ justify-content: space-between;
72
+ }
73
+
74
+ .links {
75
+ display: flex;
76
+ justify-content: flex-end;
77
+ flex-direction: row;
78
+ flex-wrap: wrap;
79
+ gap: 2rem;
80
+ }
81
+
82
+ .links ul {
83
+ list-style-type: none;
84
+ margin: 0 !important;
85
+ padding: 0 !important;
86
+ }
87
+
88
+ .links ul li, .links ul li * {
89
+ color: hsl(var(--text-normal)) !important;
90
+ width: fit-content;
91
+ }
92
+
93
+ .links ul li:has(a):hover {
94
+ text-decoration: underline;
95
+ }
96
+
97
+ .sui-footer-link-label {
98
+ font-size: 1.125em;
99
+ font-weight: 700;
100
+ }
101
+
102
+ .separator {
103
+ height: 1px;
104
+ width: 100%;
105
+ border: none;
106
+ background: hsl(var(--border));
107
+ }
108
+
109
+ .lower {
110
+ align-items: center;
111
+ flex-wrap: wrap;
112
+ gap: 1rem;
113
+ }
114
+
115
+ @media screen and (max-width: 1440px) {
116
+ footer {
117
+ padding: 2rem;
118
+ }
119
+ }
120
+
121
+ @media screen and (max-width: 1280px) {
122
+ .upper {
123
+ flex-direction: column;
124
+ gap: 2rem;
125
+ }
126
+
127
+ .links {
128
+ justify-content: flex-start;
129
+ }
130
+ }
131
+
132
+ @media screen and (max-width: 640px) {
133
+ .links ul {
134
+ width: calc(50% - 1rem);
135
+ }
136
+ }
137
+ </style>
@@ -2,16 +2,43 @@
2
2
  import type { HTMLAttributes } from 'astro/types';
3
3
  import { generateID } from '../utils/generateID';
4
4
 
5
+ /**
6
+ * The props for the input component.
7
+ */
5
8
  interface Props extends HTMLAttributes<'input'> {
9
+ /**
10
+ * The label of the input.
11
+ */
6
12
  label?: string;
13
+ /**
14
+ * The type of the input. Defaults to `text`.
15
+ */
7
16
  type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url';
17
+ /**
18
+ * The placeholder of the input.
19
+ */
8
20
  placeholder?: string;
21
+ /**
22
+ * Whether the input is required. Defaults to `false`.
23
+ */
9
24
  isRequired?: boolean;
25
+ /**
26
+ * The name attribute for the input. Useful for form submission.
27
+ */
10
28
  name?: string;
29
+ /**
30
+ * Whether the input is disabled. Defaults to `false`.
31
+ */
11
32
  disabled?: boolean;
33
+ /**
34
+ * The default value of the input.
35
+ */
12
36
  defaultValue?: string;
37
+ /**
38
+ * Additional classes to apply to the input.
39
+ */
13
40
  class?: string;
14
- };
41
+ }
15
42
 
16
43
  const {
17
44
  label,
@@ -26,16 +53,18 @@ const {
26
53
  } = Astro.props;
27
54
  ---
28
55
 
29
- <label for={name} class="input-label" class:list={[disabled && "disabled"]}>
30
- <span class="label">
31
- {label} <span class="req-star">{isRequired && "*"}</span>
32
- </span>
56
+ <label for={name} class="sui-input-label" class:list={[disabled && "disabled"]}>
57
+ {label && (
58
+ <span class="label">
59
+ {label} <span class="req-star">{isRequired && "*"}</span>
60
+ </span>
61
+ )}
33
62
  <input
34
63
  placeholder={placeholder}
35
64
  name={name}
36
65
  id={name}
37
66
  type={type}
38
- class="input"
67
+ class="sui-input"
39
68
  class:list={[className]}
40
69
  required={isRequired}
41
70
  disabled={disabled}
@@ -44,15 +73,14 @@ const {
44
73
  />
45
74
  </label>
46
75
  <style>
47
- .input-label {
76
+ .sui-input-label {
48
77
  width: 100%;
49
78
  display: flex;
50
79
  flex-direction: column;
51
80
  gap: .25rem;
52
- margin-top: .5rem;
53
81
  }
54
82
 
55
- .input-label.disabled {
83
+ .sui-input-label.disabled {
56
84
  opacity: 0.5;
57
85
  pointer-events: none;
58
86
  color: hsl(var(--text-muted));
@@ -62,7 +90,7 @@ const {
62
90
  font-size: 14px;
63
91
  }
64
92
 
65
- .input {
93
+ .sui-input {
66
94
  padding: .5rem 1rem;
67
95
  border-radius: 8px;
68
96
  border: 1px solid hsl(var(--border));
@@ -71,18 +99,18 @@ const {
71
99
  transition: all .15s ease;
72
100
  }
73
101
 
74
- .input:hover {
102
+ .sui-input:hover {
75
103
  background: hsl(var(--background-step-3));
76
104
  }
77
105
 
78
- .input:active,
79
- .input:focus {
106
+ .sui-input:active,
107
+ .sui-input:focus {
80
108
  border: 1px solid hsl(var(--primary-base));
81
109
  outline: none;
82
110
  background: hsl(var(--background-step-2));
83
111
  }
84
112
 
85
- .disabled .input:active {
113
+ .disabled .sui-input:active {
86
114
  border: 1px solid hsl(var(--border));
87
115
  }
88
116
 
@@ -1,57 +1,104 @@
1
1
  ---
2
+ import type { StudioCMSColorway } from 'src/utils/colors';
2
3
  import Icon from '../../utils/Icon.astro';
3
4
  import Button from '../Button.astro';
4
5
 
5
- type ModalButton = 'cancel' | 'confirm';
6
+ interface ButtonType {
7
+ label: string;
8
+ color: StudioCMSColorway;
9
+ }
6
10
 
11
+ /**
12
+ * The props for the Modal component.
13
+ */
7
14
  interface Props {
15
+ /**
16
+ * The ID of the modal. Required due to the helper.
17
+ */
8
18
  id: string;
19
+ /**
20
+ * The size of the modal. Defaults to `md`.
21
+ */
9
22
  size?: 'sm' | 'md' | 'lg';
23
+ /**
24
+ * Whether the modal is dismissable. Defaults to `true`.
25
+ */
10
26
  dismissable?: boolean;
11
- buttons?: ModalButton[];
27
+ /**
28
+ * The cancel button of the modal. If a string is given, the button will have the danger color and flat variant,
29
+ * and the string will be the label. If ButtonType is given, the button will have the color and variant specified.
30
+ */
31
+ cancelButton?: string | ButtonType;
32
+ /**
33
+ * The action button of the modal. If a string is given, the button will have the primary color and solid variant,
34
+ * and the string will be the label. If ButtonType is given, the button will have the color and variant specified.
35
+ */
36
+ actionButton?: string | ButtonType;
37
+ /**
38
+ * Whether the modal is a form. Defaults to `false`. When set to true, the modal helpers submit callback will include
39
+ * the form data.
40
+ */
12
41
  isForm?: boolean;
13
- };
14
-
15
- const { id, size = 'md', dismissable = true, isForm = false, buttons = [] } = Astro.props;
42
+ }
43
+
44
+ const {
45
+ id,
46
+ size = 'md',
47
+ dismissable = true,
48
+ isForm = false,
49
+ cancelButton,
50
+ actionButton,
51
+ } = Astro.props;
16
52
  ---
17
- <!-- Should: Be centered on mobile, scroll happens inside, blur bg -->
18
53
  <dialog
19
54
  popover
20
55
  id={id}
21
56
  data-dismissable={`${dismissable}`}
22
- data-buttons={buttons.join(";")}
23
- class="modal"
57
+ data-has-action-button={actionButton}
58
+ data-has-cancel-button={cancelButton}
59
+ class="sui-modal"
24
60
  class:list={[size]}
25
- data-form={`${isForm}`}
61
+ data-form={isForm}
26
62
  >
27
- <div class="modal-header">
63
+ <div class="sui-modal-header">
28
64
  <slot name="header" />
29
- {(dismissable || buttons.length === 0) && (
65
+ {(dismissable || (!cancelButton && !actionButton)) && (
30
66
  <button class="x-mark-container" id={`${id}-btn-x`}>
31
67
  <Icon name="x-mark" width={24} height={24} class={'dismiss-icon'} />
32
68
  </button>
33
69
  )}
34
70
  </div>
35
71
  <form id={`${id}-form-element`}>
36
- <div class="modal-body">
72
+ <div class="sui-modal-body">
37
73
  <slot />
38
74
  </div>
39
- <div class="modal-footer">
40
- {buttons.includes('cancel') && (
41
- <Button id={`${id}-btn-cancel`} color='danger' variant='flat' type={isForm ? 'reset' : 'button'}>
42
- Cancel
75
+ <div class="sui-modal-footer">
76
+ {cancelButton && (
77
+ <Button
78
+ id={`${id}-btn-cancel`}
79
+ color={typeof cancelButton === 'string' ? 'danger' : cancelButton.color}
80
+ variant='flat'
81
+ type={isForm ? 'reset' : 'button'}
82
+ >
83
+ {typeof cancelButton === 'string' ? cancelButton : cancelButton.label}
43
84
  </Button>
44
85
  )}
45
- {buttons.includes('confirm') && (
46
- <Button id={`${id}-btn-confirm`} type={'submit'} color='primary' variant='solid' type={isForm ? 'submit' : 'button'}>
47
- Confirm
86
+ {actionButton && (
87
+ <Button
88
+ id={`${id}-btn-confirm`}
89
+ type={'submit'}
90
+ color={typeof actionButton === 'string' ? 'primary' : actionButton.color}
91
+ variant='solid'
92
+ type={isForm ? 'submit' : 'button'}
93
+ >
94
+ {typeof actionButton === 'string' ? actionButton : actionButton.label}
48
95
  </Button>
49
96
  )}
50
97
  </div>
51
98
  </form>
52
99
  </dialog>
53
100
  <style>
54
- .modal {
101
+ .sui-modal {
55
102
  border: 1px solid hsl(var(--border));
56
103
  border-radius: .5rem;
57
104
  padding: 1.5rem;
@@ -59,35 +106,37 @@ const { id, size = 'md', dismissable = true, isForm = false, buttons = [] } = As
59
106
  animation: hide .25s ease;
60
107
  overflow: visible;
61
108
  margin: auto;
109
+ z-index: 50;
110
+ max-width: calc(100% - 4rem);
62
111
  }
63
112
 
64
- .modal.sm {
113
+ .sui-modal.sm {
65
114
  width: 384px;
66
115
  }
67
116
 
68
- .modal.md {
117
+ .sui-modal.md {
69
118
  width: 448px;
70
119
  }
71
120
 
72
- .modal.lg {
121
+ .sui-modal.lg {
73
122
  width: 608px;
74
123
  }
75
124
 
76
- .modal[open] {
125
+ .sui-modal[open] {
77
126
  animation: show .25s ease-in-out;
78
127
  }
79
128
 
80
- html:has(.modal[open]),
81
- body:has(.modal[open]) {
129
+ html:has(.sui-modal[open]),
130
+ body:has(.sui-modal[open]) {
82
131
  overflow: hidden;
83
132
  }
84
133
 
85
- .modal[open]::backdrop {
134
+ .sui-modal[open]::backdrop {
86
135
  background-color: rgba(0, 0, 0, 0.75);
87
136
  animation: backdrop .3s ease-in-out forwards;
88
137
  }
89
138
 
90
- .modal-header:has(*) {
139
+ .sui-modal-header:has(*) {
91
140
  margin-bottom: 1rem;
92
141
 
93
142
  display: flex;
@@ -115,11 +164,16 @@ const { id, size = 'md', dismissable = true, isForm = false, buttons = [] } = As
115
164
  background-color: hsl(var(--default-base));
116
165
  }
117
166
 
118
- .modal-footer {
167
+ .x-mark-container:focus-visible {
168
+ outline: 2px solid hsl(var(--text-normal));
169
+ outline-offset: 2px;
170
+ }
171
+
172
+ .sui-modal-footer {
119
173
  display: none;
120
174
  }
121
175
 
122
- .modal-footer:has(*) {
176
+ .sui-modal-footer:has(*) {
123
177
  display: flex;
124
178
  flex-direction: row;
125
179
  gap: 1rem;