vira 31.9.5 → 31.10.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.
@@ -8,6 +8,7 @@ export * from './vira-card.element.js';
8
8
  export * from './vira-checkbox.element.js';
9
9
  export * from './vira-collapsible-card.element.js';
10
10
  export * from './vira-collapsible-wrapper.element.js';
11
+ export * from './vira-drawer.element.js';
11
12
  export * from './vira-dropdown.element.js';
12
13
  export * from './vira-error.element.js';
13
14
  export * from './vira-form.element.js';
@@ -8,6 +8,7 @@ export * from './vira-card.element.js';
8
8
  export * from './vira-checkbox.element.js';
9
9
  export * from './vira-collapsible-card.element.js';
10
10
  export * from './vira-collapsible-wrapper.element.js';
11
+ export * from './vira-drawer.element.js';
11
12
  export * from './vira-dropdown.element.js';
12
13
  export * from './vira-error.element.js';
13
14
  export * from './vira-form.element.js';
@@ -0,0 +1,23 @@
1
+ import { type PartialWithUndefined } from '@augment-vir/common';
2
+ /**
3
+ * A drawer element that slides up from the bottom of the page using the built-in `dialog` element.
4
+ *
5
+ * @category Elements
6
+ */
7
+ export declare const ViraDrawer: import("element-vir").DeclarativeElementDefinition<"vira-drawer", {
8
+ open: boolean;
9
+ } & PartialWithUndefined<{
10
+ /** If this isn't set, make sure to use the drawer title slot to fill it in. */
11
+ drawerTitle: string;
12
+ }>, {
13
+ dialogElement: HTMLDialogElement | undefined;
14
+ contentElement: HTMLDivElement | undefined;
15
+ previousOpenValue: undefined | boolean;
16
+ /** Cleans up all listeners that have been attached. */
17
+ cleanupListeners: undefined | (() => void);
18
+ isDragging: boolean;
19
+ dragStartY: number;
20
+ dragCurrentY: number;
21
+ }, {
22
+ drawerClose: import("element-vir").DefineEvent<void>;
23
+ }, "vira-drawer-dragging", "vira-drawer-backdrop-filter" | "vira-drawer-max-height", readonly ["drawerTitle"], readonly []>;
@@ -0,0 +1,295 @@
1
+ import { assertWrap } from '@augment-vir/assert';
2
+ import { colorCss } from '@electrovir/color';
3
+ import { css, defineElementEvent, html, listen, nothing, onDomCreated } from 'element-vir';
4
+ import { themeDefaultKey } from 'theme-vir';
5
+ import { listenToGlobal } from 'typed-event-target';
6
+ import { X24Icon } from '../icons/icon-svgs/24/x-24.icon.js';
7
+ import { viraAnimationDurations } from '../styles/durations.js';
8
+ import { viraFormCssVars } from '../styles/form-styles.js';
9
+ import { noNativeFormStyles, noNativeSpacing } from '../styles/native-styles.js';
10
+ import { viraShadows } from '../styles/shadows.js';
11
+ import { noUserSelect } from '../styles/user-select.js';
12
+ import { viraTheme } from '../styles/vira-color-theme.js';
13
+ import { defineViraElement } from '../util/define-vira-element.js';
14
+ import { ViraIcon } from './vira-icon.element.js';
15
+ const globalEventsToCloseDrawerOn = [
16
+ 'pagehide',
17
+ 'pageshow',
18
+ 'popstate',
19
+ ];
20
+ /** Minimum downward drag distance (in pixels) required to close the drawer. */
21
+ const dragCloseThreshold = 30;
22
+ /**
23
+ * A drawer element that slides up from the bottom of the page using the built-in `dialog` element.
24
+ *
25
+ * @category Elements
26
+ */
27
+ export const ViraDrawer = defineViraElement()({
28
+ tagName: 'vira-drawer',
29
+ events: {
30
+ drawerClose: defineElementEvent(),
31
+ },
32
+ state() {
33
+ return {
34
+ dialogElement: undefined,
35
+ contentElement: undefined,
36
+ previousOpenValue: undefined,
37
+ /** Cleans up all listeners that have been attached. */
38
+ cleanupListeners: undefined,
39
+ isDragging: false,
40
+ dragStartY: 0,
41
+ dragCurrentY: 0,
42
+ };
43
+ },
44
+ cleanup({ state }) {
45
+ state.cleanupListeners?.();
46
+ },
47
+ hostClasses: {
48
+ 'vira-drawer-dragging': ({ state }) => state.isDragging,
49
+ },
50
+ slotNames: ['drawerTitle'],
51
+ cssVars: {
52
+ 'vira-drawer-backdrop-filter': 'blur(3px)',
53
+ 'vira-drawer-max-height': '80dvh',
54
+ },
55
+ styles: ({ cssVars, hostClasses }) => css `
56
+ :host {
57
+ display: contents;
58
+ }
59
+
60
+ ${hostClasses['vira-drawer-dragging'].selector} {
61
+ ${noUserSelect};
62
+ }
63
+
64
+ h1 {
65
+ ${noNativeSpacing};
66
+ }
67
+
68
+ dialog {
69
+ ${colorCss(viraTheme.colors[themeDefaultKey])}
70
+ border: none;
71
+ padding: 0;
72
+ overflow: hidden;
73
+ position: fixed;
74
+ inset: auto 0 0 0;
75
+ margin: 0;
76
+ width: 100%;
77
+ max-width: 100%;
78
+ max-height: ${cssVars['vira-drawer-max-height'].value};
79
+ border-radius: 16px 16px 0 0;
80
+ ${viraShadows.modal}
81
+ transition: transform ${viraAnimationDurations['vira-pretty-animation-duration']
82
+ .value} ease;
83
+
84
+ &[open] {
85
+ display: flex;
86
+ flex-direction: column;
87
+ }
88
+
89
+ &::backdrop {
90
+ background: ${viraFormCssVars['vira-form-modal-backdrop-color'].value};
91
+ backdrop-filter: ${cssVars['vira-drawer-backdrop-filter'].value};
92
+ }
93
+
94
+ & .drawer-content-wrapper {
95
+ overflow: hidden;
96
+ display: flex;
97
+ flex-direction: column;
98
+
99
+ & .drag-handle-wrapper {
100
+ display: flex;
101
+ justify-content: center;
102
+ padding: 8px 0 0;
103
+ cursor: grab;
104
+ touch-action: none;
105
+
106
+ &:active {
107
+ cursor: grabbing;
108
+ }
109
+
110
+ & .drag-handle {
111
+ width: 36px;
112
+ height: 4px;
113
+ border-radius: 2px;
114
+ background-color: ${viraFormCssVars['vira-form-secondary-body-foreground']
115
+ .value};
116
+ opacity: 0.5;
117
+ }
118
+ }
119
+
120
+ & .header {
121
+ padding: 8px 24px 16px;
122
+ display: flex;
123
+ gap: 16px;
124
+ align-items: flex-start;
125
+
126
+ & .header-text-wrapper {
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 4px;
130
+ align-self: center;
131
+ flex-grow: 1;
132
+
133
+ & h1 {
134
+ font-size: 20px;
135
+ }
136
+ }
137
+
138
+ & button.close {
139
+ ${noNativeFormStyles};
140
+ cursor: pointer;
141
+ padding: 4px;
142
+ border-radius: ${viraFormCssVars['vira-form-radius'].value};
143
+
144
+ &:hover {
145
+ background-color: ${viraFormCssVars['vira-form-selection-hover-color']
146
+ .value};
147
+ }
148
+
149
+ & ${ViraIcon} {
150
+ display: flex;
151
+ }
152
+ }
153
+ }
154
+
155
+ & .body {
156
+ padding: 0 24px 24px;
157
+ overflow: auto;
158
+ overscroll-behavior: contain;
159
+ }
160
+ }
161
+ }
162
+ `,
163
+ render({ inputs, state, updateState, events, dispatch, slotNames }) {
164
+ if (state.dialogElement && inputs.open !== state.dialogElement.open) {
165
+ if (inputs.open) {
166
+ state.dialogElement.showModal();
167
+ }
168
+ else {
169
+ state.dialogElement.close();
170
+ }
171
+ }
172
+ if (state.previousOpenValue !== inputs.open) {
173
+ state.cleanupListeners?.();
174
+ updateState({
175
+ previousOpenValue: inputs.open,
176
+ });
177
+ if (inputs.open) {
178
+ const removers = globalEventsToCloseDrawerOn.map((eventName) => listenToGlobal(eventName, () => {
179
+ dispatch(new events.drawerClose());
180
+ }));
181
+ updateState({
182
+ cleanupListeners: () => {
183
+ removers.forEach((remover) => remover());
184
+ },
185
+ });
186
+ }
187
+ }
188
+ function close() {
189
+ if (inputs.open) {
190
+ state.cleanupListeners?.();
191
+ dispatch(new events.drawerClose());
192
+ }
193
+ }
194
+ if (state.dialogElement) {
195
+ if (state.isDragging) {
196
+ const dragOffset = Math.max(0, state.dragCurrentY - state.dragStartY);
197
+ state.dialogElement.style.transform = `translateY(${String(dragOffset)}px)`;
198
+ state.dialogElement.style.transition = 'none';
199
+ }
200
+ else {
201
+ state.dialogElement.style.transform = '';
202
+ state.dialogElement.style.transition = '';
203
+ }
204
+ }
205
+ return html `
206
+ <dialog
207
+ ${onDomCreated((element) => {
208
+ updateState({
209
+ dialogElement: assertWrap.instanceOf(element, HTMLDialogElement),
210
+ });
211
+ })}
212
+ ${listen('close', () => {
213
+ close();
214
+ })}
215
+ ${listen('mousedown', (event) => {
216
+ if (state.contentElement &&
217
+ !event.composedPath().includes(state.contentElement)) {
218
+ close();
219
+ }
220
+ })}
221
+ >
222
+ <div
223
+ class="drawer-content-wrapper"
224
+ ${onDomCreated((element) => {
225
+ updateState({
226
+ contentElement: assertWrap.instanceOf(element, HTMLDivElement),
227
+ });
228
+ })}
229
+ >
230
+ <div
231
+ class="drag-handle-wrapper"
232
+ ${listen('dblclick', () => {
233
+ close();
234
+ })}
235
+ ${listen('pointerdown', (event) => {
236
+ updateState({
237
+ isDragging: true,
238
+ dragStartY: event.clientY,
239
+ dragCurrentY: event.clientY,
240
+ });
241
+ function handlePointerMove(moveEvent) {
242
+ updateState({
243
+ dragCurrentY: moveEvent.clientY,
244
+ });
245
+ }
246
+ function handlePointerUp(upEvent) {
247
+ const dragDistance = upEvent.clientY - state.dragStartY;
248
+ updateState({
249
+ isDragging: false,
250
+ dragStartY: 0,
251
+ dragCurrentY: 0,
252
+ });
253
+ if (dragDistance > dragCloseThreshold) {
254
+ close();
255
+ }
256
+ listenerRemovers.forEach((remover) => remover());
257
+ }
258
+ const listenerRemovers = [
259
+ listenToGlobal('pointermove', handlePointerMove),
260
+ listenToGlobal('pointerup', handlePointerUp),
261
+ ];
262
+ })}
263
+ >
264
+ <div class="drag-handle"></div>
265
+ </div>
266
+ <div class="header">
267
+ <div class="header-text-wrapper">
268
+ <h1>
269
+ <slot name=${slotNames.drawerTitle}>${inputs.drawerTitle}</slot>
270
+ </h1>
271
+ </div>
272
+ <button
273
+ class="close"
274
+ aria-label="Close"
275
+ ${listen('click', () => {
276
+ state.dialogElement?.close();
277
+ })}
278
+ >
279
+ <${ViraIcon.assign({
280
+ icon: X24Icon,
281
+ })}></${ViraIcon}>
282
+ </button>
283
+ </div>
284
+ ${inputs.open
285
+ ? html `
286
+ <div class="body">
287
+ <slot></slot>
288
+ </div>
289
+ `
290
+ : nothing}
291
+ </div>
292
+ </dialog>
293
+ `;
294
+ },
295
+ });
@@ -1,7 +1,7 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
2
  import { type AttributeValues } from 'element-vir';
3
3
  import { type ViraIconSvg } from '../icons/index.js';
4
- import { type ViraSelectOption } from '../util/vira-select-option.js';
4
+ import { type ViraSelectOption, type ViraSelectOptionGroup } from '../util/vira-select-option.js';
5
5
  /**
6
6
  * Similar to {@link ViraDropdown} but is, instead, simply a wrapper for `<select>` and nothing more.
7
7
  *
@@ -10,7 +10,7 @@ import { type ViraSelectOption } from '../util/vira-select-option.js';
10
10
  * @see https://electrovir.github.io/vira/book/elements/vira-select
11
11
  */
12
12
  export declare const ViraSelect: import("element-vir").DeclarativeElementDefinition<"vira-select", {
13
- options: ReadonlyArray<Readonly<ViraSelectOption>>;
13
+ options: ReadonlyArray<Readonly<ViraSelectOption> | Readonly<ViraSelectOptionGroup>>;
14
14
  /** The currently selected option value. */
15
15
  value: undefined | string;
16
16
  } & PartialWithUndefined<{
@@ -10,7 +10,20 @@ import { viraFormCssVars } from '../styles/form-styles.js';
10
10
  import { viraAnimationDurations } from '../styles/index.js';
11
11
  import { noNativeFormStyles } from '../styles/native-styles.js';
12
12
  import { defineViraElement } from '../util/define-vira-element.js';
13
+ import { isViraSelectOptionGroup, } from '../util/vira-select-option.js';
13
14
  import { ViraIcon } from './vira-icon.element.js';
15
+ function renderSelectOption(option, value) {
16
+ return html `
17
+ <option
18
+ ?selected=${option.value === value}
19
+ aria-label=${option.label}
20
+ ?disabled=${option.disabled}
21
+ value=${option.value}
22
+ >
23
+ ${option.label}
24
+ </option>
25
+ `;
26
+ }
14
27
  /**
15
28
  * Similar to {@link ViraDropdown} but is, instead, simply a wrapper for `<select>` and nothing more.
16
29
  *
@@ -262,24 +275,30 @@ export const ViraSelect = defineViraElement()({
262
275
  const selectElement = extractEventTarget(event, HTMLSelectElement);
263
276
  const newValue = selectElement.value;
264
277
  if (selectElement.value !== value) {
265
- selectElement.selectedIndex = inputs.options.findIndex((option) => option.value === value);
278
+ selectElement.selectedIndex = inputs.options
279
+ .flatMap((entry) => {
280
+ return isViraSelectOptionGroup(entry)
281
+ ? [...entry.options]
282
+ : [entry];
283
+ })
284
+ .findIndex((option) => option.value === value);
266
285
  }
267
286
  dispatch(new events.valueChange(newValue));
268
287
  })}
269
288
  ${attributes(inputs.attributePassthrough?.select)}
270
289
  >
271
290
  ${placeholderOptionTemplate}
272
- ${inputs.options.map((option) => {
273
- return html `
274
- <option
275
- ?selected=${option.value === value}
276
- aria-label=${option.label}
277
- ?disabled=${option.disabled}
278
- value=${option.value}
279
- >
280
- ${option.label}
281
- </option>
282
- `;
291
+ ${inputs.options.map((entry) => {
292
+ if (isViraSelectOptionGroup(entry)) {
293
+ return html `
294
+ <optgroup label=${entry.groupName}>
295
+ ${entry.options.map((option) => {
296
+ return renderSelectOption(option, value);
297
+ })}
298
+ </optgroup>
299
+ `;
300
+ }
301
+ return renderSelectOption(entry, value);
283
302
  })}
284
303
  </select>
285
304
  <!--
@@ -79,19 +79,19 @@ const tagColorVariantColors = {
79
79
  [ViraColorVariant.Plain]: {
80
80
  [ViraEmphasis.Standard]: {
81
81
  idle: {
82
- backgroundColor: viraTheme.inverse[themeDefaultKey].background,
83
- textColor: viraTheme.inverse[themeDefaultKey].foreground,
84
- borderColor: viraTheme.inverse[themeDefaultKey].background,
82
+ backgroundColor: viraTheme.colors[themeDefaultKey].foreground,
83
+ textColor: viraTheme.colors[themeDefaultKey].background,
84
+ borderColor: viraTheme.colors[themeDefaultKey].foreground,
85
85
  },
86
86
  hover: {
87
- backgroundColor: viraTheme.colors['vira-grey-behind-bg-non-body'].background,
88
- textColor: viraTheme.colors['vira-grey-behind-bg-non-body'].foreground,
89
- borderColor: viraTheme.colors['vira-grey-behind-bg-non-body'].background,
87
+ backgroundColor: viraTheme.colors['vira-grey-behind-bg-body'].background,
88
+ textColor: viraTheme.colors['vira-grey-behind-bg-body'].foreground,
89
+ borderColor: viraTheme.colors['vira-grey-behind-bg-body'].background,
90
90
  },
91
91
  active: {
92
- backgroundColor: viraTheme.inverse[themeDefaultKey].background,
93
- textColor: viraTheme.inverse[themeDefaultKey].foreground,
94
- borderColor: viraTheme.inverse[themeDefaultKey].background,
92
+ backgroundColor: viraTheme.colors[themeDefaultKey].foreground,
93
+ textColor: viraTheme.colors[themeDefaultKey].background,
94
+ borderColor: viraTheme.colors[themeDefaultKey].foreground,
95
95
  },
96
96
  },
97
97
  [ViraEmphasis.Subtle]: {
@@ -13,3 +13,19 @@ export type ViraSelectOption = {
13
13
  } & PartialWithUndefined<{
14
14
  disabled: boolean;
15
15
  }>;
16
+ /**
17
+ * A group of options for `ViraSelect`, rendered as an `<optgroup>`.
18
+ *
19
+ * @category Dropdown
20
+ * @category Elements
21
+ */
22
+ export type ViraSelectOptionGroup = {
23
+ groupName: string;
24
+ options: ReadonlyArray<Readonly<ViraSelectOption>>;
25
+ };
26
+ /**
27
+ * Type guard to determine if a `ViraSelect` options entry is a group.
28
+ *
29
+ * @category Internal
30
+ */
31
+ export declare function isViraSelectOptionGroup(entry: Readonly<ViraSelectOption> | Readonly<ViraSelectOptionGroup>): entry is Readonly<ViraSelectOptionGroup>;
@@ -1 +1,8 @@
1
- export {};
1
+ /**
2
+ * Type guard to determine if a `ViraSelect` options entry is a group.
3
+ *
4
+ * @category Internal
5
+ */
6
+ export function isViraSelectOptionGroup(entry) {
7
+ return 'groupName' in entry;
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vira",
3
- "version": "31.9.5",
3
+ "version": "31.10.0",
4
4
  "description": "A simple and highly versatile design system using element-vir.",
5
5
  "keywords": [
6
6
  "design",