@studiocms/ui 0.1.0 → 0.3.1

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.
@@ -1,16 +1,31 @@
1
1
  ---
2
2
  import type { HTMLTag, Polymorphic } from 'astro/types';
3
3
 
4
+ /**
5
+ * Props for the card component.
6
+ */
4
7
  type Props<As extends HTMLTag = 'div'> = Omit<Polymorphic<{ as: As }>, 'as'> & {
8
+ /**
9
+ * The polymorphic component to render the card as. Defaults to `div`.
10
+ */
5
11
  as?: As;
6
- class?: string;
12
+ /**
13
+ * Whether the card should be full width. Defaults to `false`.
14
+ */
7
15
  fullWidth?: boolean;
16
+ /**
17
+ * Whether the card should be full height. Defaults to `false`.
18
+ */
8
19
  fullHeight?: boolean;
20
+ /**
21
+ * The variant of the card. Defaults to `default`.
22
+ */
23
+ variant?: 'default' | 'filled';
9
24
  };
10
25
 
11
- const { as: As = 'div', class: className, fullWidth, fullHeight, ...props } = Astro.props;
26
+ const { as: As = 'div', fullWidth, fullHeight, variant = 'default', ...props } = Astro.props;
12
27
  ---
13
- <As class="sui-card" class:list={[fullWidth && "full-w", fullHeight && "full-h", className]} {...props}>
28
+ <As class="sui-card" class:list={[fullWidth && "full-w", fullHeight && "full-h", variant]} {...props}>
14
29
  <div class="sui-card-header">
15
30
  <slot name="header" />
16
31
  </div>
@@ -30,6 +45,11 @@ const { as: As = 'div', class: className, fullWidth, fullHeight, ...props } = As
30
45
  height: fit-content;
31
46
  }
32
47
 
48
+ .sui-card.filled {
49
+ background-color: hsl(var(--background-step-3));
50
+ border: none;
51
+ }
52
+
33
53
  .sui-card.full-w {
34
54
  width: 100%;
35
55
  }
@@ -54,6 +74,10 @@ const { as: As = 'div', class: className, fullWidth, fullHeight, ...props } = As
54
74
  padding: 1rem;
55
75
  }
56
76
 
77
+ .filled .sui-card-footer {
78
+ border: none;
79
+ }
80
+
57
81
  @media screen and (max-width: 840px) {
58
82
  .sui-card {
59
83
  width: 100%;
@@ -3,13 +3,37 @@ import Checkmark from '../icons/Checkmark.astro';
3
3
  import type { StudioCMSColorway } from '../utils/colors';
4
4
  import { generateID } from '../utils/generateID';
5
5
 
6
+ /**
7
+ * The props for the Checkbox component.
8
+ */
6
9
  interface Props {
10
+ /**
11
+ * The label of the checkbox.
12
+ */
7
13
  label: string;
14
+ /**
15
+ * The size of the checkbox. Defaults to `md`.
16
+ */
8
17
  size?: 'sm' | 'md' | 'lg';
18
+ /**
19
+ * The color of the checkbox. Defaults to `default`.
20
+ */
9
21
  color?: StudioCMSColorway;
22
+ /**
23
+ * Whether the checkbox is checked by default.
24
+ */
10
25
  defaultChecked?: boolean;
26
+ /**
27
+ * Whether the checkbox is disabled.
28
+ */
11
29
  disabled?: boolean;
30
+ /**
31
+ * The name of the checkbox.
32
+ */
12
33
  name?: string;
34
+ /**
35
+ * Whether the checkbox is required.
36
+ */
13
37
  isRequired?: boolean;
14
38
  }
15
39
 
@@ -38,7 +62,13 @@ const iconSizes = {
38
62
  size,
39
63
  ]}
40
64
  >
41
- <div class="sui-checkmark-container">
65
+ <div
66
+ class="sui-checkmark-container"
67
+ tabindex="0"
68
+ role="checkbox"
69
+ aria-checked={defaultChecked}
70
+ aria-labelledby={`label-${name}`}
71
+ >
42
72
  <Checkmark
43
73
  class={'sui-checkmark'}
44
74
  width={iconSizes[size]}
@@ -52,12 +82,45 @@ const iconSizes = {
52
82
  disabled={disabled}
53
83
  required={isRequired}
54
84
  class="sui-checkbox"
85
+ hidden
55
86
  />
56
87
  </div>
57
- <span>
88
+ <span id={`label-${name}`}>
58
89
  {label} <span class="req-star">{isRequired && "*"}</span>
59
90
  </span>
60
91
  </label>
92
+ <script>
93
+ const elements = document.querySelectorAll<HTMLDivElement>('.sui-checkmark-container');
94
+ const checkbox = document.querySelectorAll<HTMLInputElement>('.sui-checkbox');
95
+
96
+ for (const element of elements) {
97
+ if (element.dataset.initialized) continue;
98
+
99
+ element.dataset.initialized = 'true';
100
+
101
+ element.addEventListener('keydown', (e) => {
102
+ if (e.key !== 'Enter' && e.key !== ' ') return;
103
+
104
+ e.preventDefault();
105
+
106
+ const checkbox = element.querySelector<HTMLInputElement>('.sui-checkbox');
107
+
108
+ if (!checkbox) return;
109
+
110
+ checkbox.click();
111
+ });
112
+ }
113
+
114
+ for (const box of checkbox) {
115
+ if (box.dataset.initialized) continue;
116
+
117
+ box.dataset.initialized = 'true';
118
+
119
+ box.addEventListener('change', (e) => {
120
+ box.parentElement!.ariaChecked = (e.target as HTMLInputElement).checked ? 'true' : 'false';
121
+ });
122
+ }
123
+ </script>
61
124
  <style>
62
125
  .sui-checkmark-label {
63
126
  display: flex;
@@ -82,7 +145,13 @@ const iconSizes = {
82
145
  border: 2px solid hsl(var(--default-base));
83
146
  border-radius: .5rem;
84
147
  cursor: pointer;
85
- transition: all .15s ease;
148
+ transition: background-color .15s, border .15s, transform .15s;
149
+ transition-timing-function: ease;
150
+ }
151
+
152
+ .sui-checkmark-container:focus-visible {
153
+ outline: 2px solid hsl(var(--text-normal));
154
+ outline-offset: 2px;
86
155
  }
87
156
 
88
157
  .sui-checkmark-label:hover .sui-checkmark-container {
@@ -1,12 +1,18 @@
1
1
  ---
2
+ /**
3
+ * Props for the divider component.
4
+ */
2
5
  interface Props {
6
+ /**
7
+ * The background color of the divider, used to "hide" content behind the slot. Defaults to `background-base`.
8
+ */
3
9
  background?: 'background-base' | 'background-step-1' | 'background-step-2' | 'background-step-3';
4
10
  }
5
11
 
6
12
  const { background = 'background-base' } = Astro.props;
7
13
  ---
8
14
  <div class="sui-divider-container">
9
- <div class="sui-divider-line" />
15
+ <hr class="sui-divider-line" />
10
16
  <div class="sui-divider-content" style={`background-color: hsl(var(--${background}));`}>
11
17
  <slot />
12
18
  </div>
@@ -29,6 +35,7 @@ const { background = 'background-base' } = Astro.props;
29
35
  height: 1px;
30
36
  background-color: hsl(var(--border));
31
37
  z-index: 1;
38
+ border: none;
32
39
  }
33
40
 
34
41
  .sui-divider-content {
@@ -3,21 +3,63 @@ import Icon from '../../utils/Icon.astro';
3
3
  import type { StudioCMSColorway } from '../../utils/colors';
4
4
  import type { HeroIconName } from '../../utils/iconType';
5
5
 
6
+ /**
7
+ * An option in the dropdown.
8
+ */
6
9
  interface Option {
10
+ /**
11
+ * The label of the option.
12
+ */
7
13
  label: string;
14
+ /**
15
+ * The value of the option, returned by the helper when listened for.
16
+ */
8
17
  value: string;
18
+ /**
19
+ * Whether the option is disabled.
20
+ */
9
21
  disabled?: boolean;
22
+ /**
23
+ * The color of the option.
24
+ */
10
25
  color?: StudioCMSColorway;
26
+ /**
27
+ * The icon to display next to the option.
28
+ */
11
29
  icon?: HeroIconName;
30
+ /**
31
+ * The href to link to when the option is clicked. When given, the option will be rendered as an anchor tag.
32
+ */
12
33
  href?: string;
13
34
  }
14
35
 
36
+ /**
37
+ * The props for the Dropdown component.
38
+ */
15
39
  interface Props {
40
+ /**
41
+ * The options to display in the dropdown.
42
+ */
16
43
  options: Option[];
44
+ /**
45
+ * Whether the dropdown is disabled.
46
+ */
17
47
  disabled?: boolean;
48
+ /**
49
+ * The ID of the dropdown. Required because of the helper.
50
+ */
18
51
  id: string;
52
+ /**
53
+ * The alignment of the dropdown, defaults to `center`. Will not work on mobile due to size constraints.
54
+ */
19
55
  align?: 'start' | 'center' | 'end';
56
+ /**
57
+ * The type of click with which the dropdown is clicked. Defaults to `left`.
58
+ */
20
59
  triggerOn?: 'left' | 'right' | 'both';
60
+ /**
61
+ * The offset of the dropdown from the trigger element in pixels.
62
+ */
21
63
  offset?: number;
22
64
  }
23
65
 
@@ -42,12 +84,21 @@ const {
42
84
  <div class="sui-dropdown-toggle" id={`${id}-toggle-btn`}>
43
85
  <slot />
44
86
  </div>
45
- <ul class="sui-dropdown" class:list={[align]} role="listbox" id={`${id}-dropdown`} transition:persist transition:persist-props>
87
+ <ul
88
+ class="sui-dropdown"
89
+ class:list={[align]}
90
+ role="listbox" id={`${id}-dropdown`}
91
+ transition:persist
92
+ transition:persist-props
93
+ aria-labelledby={`${id}-toggle-btn`}
94
+ >
46
95
  {options.map(({ value, disabled, color, label, icon, href }) => (
47
96
  <li
48
97
  class="sui-dropdown-option"
49
98
  data-value={value}
50
99
  class:list={[disabled && "disabled", icon && "has-icon", color, href && "has-href"]}
100
+ role="option"
101
+ aria-selected="false"
51
102
  >
52
103
  {icon && (
53
104
  <Icon width={24} height={24} name={icon} />
@@ -197,7 +248,7 @@ const {
197
248
  user-select: none;
198
249
  }
199
250
 
200
- .sui-dropdown-option:hover {
251
+ .sui-dropdown-option:hover, .sui-dropdown-option:focus, .sui-dropdown-option.focused {
201
252
  background-color: hsl(var(--background-step-3));
202
253
  }
203
254
 
@@ -208,6 +259,8 @@ const {
208
259
  .sui-dropdown-link {
209
260
  padding: .5rem .75rem;
210
261
  width: 100%;
262
+ text-decoration: none;
263
+ color: hsl(var(--text-normal));
211
264
  }
212
265
 
213
266
  .sui-dropdown-option.primary {
@@ -6,9 +6,15 @@ class DropdownHelper {
6
6
  private alignment: 'start' | 'center' | 'end';
7
7
  private triggerOn: 'left' | 'right' | 'both';
8
8
  private fullWidth = false;
9
+ private focusIndex = -1;
9
10
 
10
11
  active = false;
11
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
+ */
12
18
  constructor(id: string, fullWidth?: boolean) {
13
19
  this.container = document.getElementById(`${id}-container`) as HTMLDivElement;
14
20
 
@@ -22,6 +28,44 @@ class DropdownHelper {
22
28
  this.toggleEl = document.getElementById(`${id}-toggle-btn`) as HTMLDivElement;
23
29
  this.dropdown = document.getElementById(`${id}-dropdown`) as HTMLUListElement;
24
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
+
25
69
  if (this.triggerOn === 'left') {
26
70
  this.toggleEl.addEventListener('click', this.toggle);
27
71
  } else if (this.triggerOn === 'both') {
@@ -37,31 +81,56 @@ class DropdownHelper {
37
81
  });
38
82
  }
39
83
 
40
- if (fullWidth) this.fullWidth = true;
84
+ this.toggleEl.addEventListener('keydown', (e) => {
85
+ if (!this.active) return;
41
86
 
42
- window.addEventListener('scroll', this.hide);
43
- document.addEventListener('astro:before-preparation', () => {
44
- this.dropdown.classList.remove('initialized');
45
- });
87
+ if (e.key === 'Enter') {
88
+ e.preventDefault();
46
89
 
47
- this.hideOnClickOutside(this.container);
90
+ const focused = this.dropdown.querySelector('li.focused') as HTMLLIElement;
48
91
 
49
- this.initialOptClickRegistration();
50
- }
92
+ if (!focused) {
93
+ this.hide();
94
+ return;
95
+ }
51
96
 
52
- public registerClickCallback = (func: (value: string) => void) => {
53
- const dropdownOpts = this.dropdown.querySelectorAll('li');
97
+ focused.click();
98
+ }
54
99
 
55
- for (const opt of dropdownOpts) {
56
- opt.removeEventListener('click', this.hide);
100
+ if (e.key === 'ArrowDown') {
101
+ e.preventDefault();
57
102
 
58
- opt.addEventListener('click', () => {
59
- func(opt.dataset.value || '');
60
- this.hide();
61
- });
62
- }
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
+ });
63
129
  };
64
130
 
131
+ /**
132
+ * Registers callbacks to hide the dropdown when an option is clicked.
133
+ */
65
134
  private initialOptClickRegistration = () => {
66
135
  const dropdownOpts = this.dropdown.querySelectorAll('li');
67
136
 
@@ -70,6 +139,9 @@ class DropdownHelper {
70
139
  }
71
140
  };
72
141
 
142
+ /**
143
+ * A function to toggle the dropdown.
144
+ */
73
145
  public toggle = () => {
74
146
  if (this.active) {
75
147
  this.hide();
@@ -79,13 +151,22 @@ class DropdownHelper {
79
151
  this.show();
80
152
  };
81
153
 
154
+ /**
155
+ * A function to hide the dropdown.
156
+ */
82
157
  public hide = () => {
83
158
  this.dropdown.classList.remove('active');
84
159
  this.active = false;
160
+ this.focusIndex = -1;
161
+
162
+ this.dropdown.querySelector('li.focused')?.classList.remove('focused');
85
163
 
86
164
  setTimeout(() => this.dropdown.classList.remove('above', 'below'), 200);
87
165
  };
88
166
 
167
+ /**
168
+ * A function to show the dropdown.
169
+ */
89
170
  public show = () => {
90
171
  const isMobile = window.matchMedia('screen and (max-width: 840px)').matches;
91
172
 
@@ -145,11 +226,17 @@ class DropdownHelper {
145
226
  CustomRect.right <= (window.innerWidth || document.documentElement.clientWidth)
146
227
  ) {
147
228
  this.dropdown.classList.add('active', 'below');
229
+ this.focusIndex = -1;
148
230
  } else {
149
231
  this.dropdown.classList.add('active', 'above');
232
+ this.focusIndex = this.dropdown.children.length;
150
233
  }
151
234
  };
152
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
+ */
153
240
  private hideOnClickOutside = (element: HTMLElement) => {
154
241
  const outsideClickListener = (event: MouseEvent) => {
155
242
  if (!event.target) return;
@@ -1,13 +1,30 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types';
3
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
+ */
4
18
  interface Props extends HTMLAttributes<'footer'> {
19
+ /**
20
+ * The links to display in the footer.
21
+ */
5
22
  links: {
6
- [label: string]: {
7
- label: string;
8
- href: string;
9
- }[];
23
+ [label: string]: FooterLink[];
10
24
  };
25
+ /**
26
+ * The copyright text to display in the footer.
27
+ */
11
28
  copyright: string;
12
29
  }
13
30
 
@@ -2,14 +2,41 @@
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
 
@@ -8,12 +8,36 @@ interface ButtonType {
8
8
  color: StudioCMSColorway;
9
9
  }
10
10
 
11
+ /**
12
+ * The props for the Modal component.
13
+ */
11
14
  interface Props {
15
+ /**
16
+ * The ID of the modal. Required due to the helper.
17
+ */
12
18
  id: string;
19
+ /**
20
+ * The size of the modal. Defaults to `md`.
21
+ */
13
22
  size?: 'sm' | 'md' | 'lg';
23
+ /**
24
+ * Whether the modal is dismissable. Defaults to `true`.
25
+ */
14
26
  dismissable?: boolean;
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
+ */
15
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
+ */
16
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
+ */
17
41
  isForm?: boolean;
18
42
  }
19
43
 
@@ -34,7 +58,7 @@ const {
34
58
  data-has-cancel-button={cancelButton}
35
59
  class="sui-modal"
36
60
  class:list={[size]}
37
- data-form={`${isForm}`}
61
+ data-form={isForm}
38
62
  >
39
63
  <div class="sui-modal-header">
40
64
  <slot name="header" />
@@ -83,6 +107,7 @@ const {
83
107
  overflow: visible;
84
108
  margin: auto;
85
109
  z-index: 50;
110
+ max-width: calc(100% - 4rem);
86
111
  }
87
112
 
88
113
  .sui-modal.sm {
@@ -139,6 +164,11 @@ const {
139
164
  background-color: hsl(var(--default-base));
140
165
  }
141
166
 
167
+ .x-mark-container:focus-visible {
168
+ outline: 2px solid hsl(var(--text-normal));
169
+ outline-offset: 2px;
170
+ }
171
+
142
172
  .sui-modal-footer {
143
173
  display: none;
144
174
  }