vira 33.5.1 → 33.7.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.
@@ -33,6 +33,7 @@ export * from './vira-link.element.js';
33
33
  export * from './vira-modal.element.js';
34
34
  export * from './vira-progress.element.js';
35
35
  export * from './vira-relative-time.element.js';
36
+ export * from './vira-slider.element.js';
36
37
  export * from './vira-tabs.element.js';
37
38
  export * from './vira-tag.element.js';
38
39
  export * from './vira-text-area.element.js';
@@ -33,6 +33,7 @@ export * from './vira-link.element.js';
33
33
  export * from './vira-modal.element.js';
34
34
  export * from './vira-progress.element.js';
35
35
  export * from './vira-relative-time.element.js';
36
+ export * from './vira-slider.element.js';
36
37
  export * from './vira-tabs.element.js';
37
38
  export * from './vira-tag.element.js';
38
39
  export * from './vira-text-area.element.js';
@@ -0,0 +1,35 @@
1
+ import { type PartialWithUndefined } from '@augment-vir/common';
2
+ /**
3
+ * Inputs for {@link ViraSlider}.
4
+ *
5
+ * @category Internal
6
+ */
7
+ export type ViraSliderInputs = {
8
+ value: number;
9
+ } & PartialWithUndefined<{
10
+ /** Defaults to `0`. */
11
+ min: number;
12
+ /** Defaults to `100`. */
13
+ max: number;
14
+ /** Defaults to `1`. */
15
+ step: number;
16
+ isDisabled: boolean;
17
+ /** The slider's accessible name. It is not rendered visibly. */
18
+ label: string;
19
+ }>;
20
+ /**
21
+ * A themed replacement for `<input type="range">`. The track, fill, and thumb are drawn with Vira's
22
+ * form colors, so they follow the current theme (including dark mode) where a browser's native
23
+ * range styling does not. An invisible native range input sits on top, so keyboard, pointer, and
24
+ * screen reader behavior are the browser's own.
25
+ *
26
+ * `valueChange` fires on every change while dragging. The slider is controlled: it only moves when
27
+ * `value` is updated in response.
28
+ *
29
+ * @category Input
30
+ * @category Elements
31
+ * @see https://electrovir.github.io/vira/book/elements/vira-slider
32
+ */
33
+ export declare const ViraSlider: import("element-vir").DeclarativeElementDefinition<"vira-slider", Readonly<ViraSliderInputs>, {}, {
34
+ valueChange: import("element-vir").DefineElementEvent<number>;
35
+ }, "vira-slider-", "vira-slider-track-height" | "vira-slider-thumb-size" | "vira-slider-track-color" | "vira-slider-fill-color" | "vira-slider-thumb-color" | "vira-slider-thumb-hover-color" | "vira-slider-thumb-active-color", readonly [], readonly []>;
@@ -0,0 +1,179 @@
1
+ import { extractEventTarget } from '@augment-vir/web';
2
+ import { classMap, css, defineElementEvent, html, ifDefined, listen } from 'element-vir';
3
+ import { viraDisabledStyles } from '../styles/disabled.js';
4
+ import { createFocusStyles } from '../styles/focus.js';
5
+ import { viraFormCssVars } from '../styles/form-styles.js';
6
+ import { defineViraElement } from '../util/define-vira-element.js';
7
+ /**
8
+ * A themed replacement for `<input type="range">`. The track, fill, and thumb are drawn with Vira's
9
+ * form colors, so they follow the current theme (including dark mode) where a browser's native
10
+ * range styling does not. An invisible native range input sits on top, so keyboard, pointer, and
11
+ * screen reader behavior are the browser's own.
12
+ *
13
+ * `valueChange` fires on every change while dragging. The slider is controlled: it only moves when
14
+ * `value` is updated in response.
15
+ *
16
+ * @category Input
17
+ * @category Elements
18
+ * @see https://electrovir.github.io/vira/book/elements/vira-slider
19
+ */
20
+ export const ViraSlider = defineViraElement()({
21
+ tagName: 'vira-slider',
22
+ cssVars: {
23
+ 'vira-slider-track-height': '4px',
24
+ 'vira-slider-thumb-size': '16px',
25
+ 'vira-slider-track-color': viraFormCssVars['vira-form-border-color'].value,
26
+ 'vira-slider-fill-color': viraFormCssVars['vira-form-accent-primary-color'].value,
27
+ 'vira-slider-thumb-color': viraFormCssVars['vira-form-accent-primary-color'].value,
28
+ 'vira-slider-thumb-hover-color': viraFormCssVars['vira-form-accent-primary-hover-color'].value,
29
+ 'vira-slider-thumb-active-color': viraFormCssVars['vira-form-accent-primary-active-color'].value,
30
+ },
31
+ styles({ cssVars }) {
32
+ return css `
33
+ :host {
34
+ display: inline-flex;
35
+ width: 160px;
36
+ vertical-align: middle;
37
+ }
38
+
39
+ .slider {
40
+ position: relative;
41
+ flex-grow: 1;
42
+ height: ${cssVars['vira-slider-thumb-size'].value};
43
+
44
+ &.disabled {
45
+ ${viraDisabledStyles};
46
+ }
47
+ }
48
+
49
+ .track,
50
+ .fill {
51
+ position: absolute;
52
+ top: 50%;
53
+ left: 0;
54
+ height: ${cssVars['vira-slider-track-height'].value};
55
+ transform: translateY(-50%);
56
+ border-radius: ${cssVars['vira-slider-track-height'].value};
57
+ pointer-events: none;
58
+ }
59
+
60
+ .track {
61
+ width: 100%;
62
+ background-color: ${cssVars['vira-slider-track-color'].value};
63
+ }
64
+
65
+ .fill {
66
+ background-color: ${cssVars['vira-slider-fill-color'].value};
67
+ }
68
+
69
+ .thumb {
70
+ position: absolute;
71
+ top: 0;
72
+ width: ${cssVars['vira-slider-thumb-size'].value};
73
+ height: ${cssVars['vira-slider-thumb-size'].value};
74
+ box-sizing: border-box;
75
+ border-radius: 50%;
76
+ border: 2px solid ${viraFormCssVars['vira-form-background-color'].value};
77
+ background-color: ${cssVars['vira-slider-thumb-color'].value};
78
+ pointer-events: none;
79
+ }
80
+
81
+ input {
82
+ position: absolute;
83
+ inset: 0;
84
+ width: 100%;
85
+ height: 100%;
86
+ margin: 0;
87
+ padding: 0;
88
+ appearance: none;
89
+ background: transparent;
90
+ opacity: 0;
91
+ cursor: pointer;
92
+
93
+ /*
94
+ The invisible native thumb must match the drawn thumb's width so the browser
95
+ maps pointer positions to the same values the drawn thumb shows.
96
+ */
97
+ &::-webkit-slider-thumb {
98
+ appearance: none;
99
+ width: ${cssVars['vira-slider-thumb-size'].value};
100
+ height: ${cssVars['vira-slider-thumb-size'].value};
101
+ }
102
+ &::-moz-range-thumb {
103
+ width: ${cssVars['vira-slider-thumb-size'].value};
104
+ height: ${cssVars['vira-slider-thumb-size'].value};
105
+ border: none;
106
+ }
107
+
108
+ &:hover ~ .thumb {
109
+ background-color: ${cssVars['vira-slider-thumb-hover-color'].value};
110
+ }
111
+ &:active ~ .thumb {
112
+ background-color: ${cssVars['vira-slider-thumb-active-color'].value};
113
+ }
114
+ &:focus-visible:not(:active) ~ .thumb::after {
115
+ ${createFocusStyles({
116
+ elementBorderSize: '2px',
117
+ borderRadius: '50%',
118
+ noNesting: true,
119
+ })}
120
+ }
121
+ }
122
+ `;
123
+ },
124
+ events: {
125
+ valueChange: defineElementEvent(),
126
+ },
127
+ render({ inputs, dispatch, events, cssVars }) {
128
+ const min = inputs.min ?? 0;
129
+ const max = inputs.max ?? 100;
130
+ const fraction = max > min ? Math.min(1, Math.max(0, (inputs.value - min) / (max - min))) : 0;
131
+ const thumbOffset = css `calc((100% - ${cssVars['vira-slider-thumb-size'].value}) * ${fraction})`;
132
+ return html `
133
+ <div
134
+ class="slider ${classMap({
135
+ disabled: !!inputs.isDisabled,
136
+ })}"
137
+ >
138
+ <div class="track"></div>
139
+ <div
140
+ class="fill"
141
+ style=${css `
142
+ width: calc(
143
+ ${thumbOffset} + ${cssVars['vira-slider-thumb-size'].value} / 2
144
+ );
145
+ `}
146
+ ></div>
147
+ <input
148
+ type="range"
149
+ min=${min}
150
+ max=${max}
151
+ step=${inputs.step ?? 1}
152
+ aria-label=${ifDefined(inputs.label || undefined)}
153
+ ?disabled=${inputs.isDisabled}
154
+ .value=${String(inputs.value)}
155
+ ${listen('input', (event) => {
156
+ const inputElement = extractEventTarget(event, HTMLInputElement);
157
+ const newValue = Number(inputElement.value);
158
+ /**
159
+ * Snap the native input back to the controlled value; it only moves once
160
+ * the new value is assigned back in.
161
+ */
162
+ inputElement.value = String(inputs.value);
163
+ if (newValue !== inputs.value) {
164
+ dispatch(new events.valueChange({
165
+ detail: newValue,
166
+ }));
167
+ }
168
+ })}
169
+ />
170
+ <div
171
+ class="thumb"
172
+ style=${css `
173
+ left: ${thumbOffset};
174
+ `}
175
+ ></div>
176
+ </div>
177
+ `;
178
+ },
179
+ });
@@ -1,5 +1,6 @@
1
- import { type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
1
+ import { type MaybePromise, type PartialWithUndefined, type RequireOneOrNone } from '@augment-vir/common';
2
2
  import { LocalStorageClient } from '@electrovir/local-storage-client';
3
+ import { ListenTarget } from 'typed-event-target';
3
4
  /**
4
5
  * A user-facing selection of which theme to display. `Auto` follows the system color scheme; the
5
6
  * other values force a specific theme regardless of system preference.
@@ -11,6 +12,39 @@ export declare enum ViraThemeSelection {
11
12
  Dark = "dark",
12
13
  Auto = "auto"
13
14
  }
15
+ declare const ViraThemeAppliedEvent_base: (new (eventInitDict: {
16
+ bubbles?: boolean;
17
+ cancelable?: boolean;
18
+ composed?: boolean;
19
+ detail: ViraThemeSelection.Light | ViraThemeSelection.Dark;
20
+ }) => import("typed-event-target").TypedCustomEvent<ViraThemeSelection.Light | ViraThemeSelection.Dark, "vira-theme-applied">) & Pick<{
21
+ new (type: string, eventInitDict?: EventInit): Event;
22
+ prototype: Event;
23
+ readonly NONE: 0;
24
+ readonly CAPTURING_PHASE: 1;
25
+ readonly AT_TARGET: 2;
26
+ readonly BUBBLING_PHASE: 3;
27
+ }, "NONE" | "CAPTURING_PHASE" | "AT_TARGET" | "BUBBLING_PHASE" | "prototype"> & Pick<import("typed-event-target").TypedCustomEvent<ViraThemeSelection.Light | ViraThemeSelection.Dark, "vira-theme-applied">, "type">;
28
+ /**
29
+ * Fired by {@link ViraThemeClient} whenever its applied theme changes, including when the system
30
+ * color scheme flips while the selection is `Auto`. Not fired when a new selection leaves the
31
+ * applied theme the same (such as switching from `Dark` to `Auto` on a dark system).
32
+ *
33
+ * @category Internal
34
+ */
35
+ export declare class ViraThemeAppliedEvent extends ViraThemeAppliedEvent_base {
36
+ }
37
+ /**
38
+ * Persists the selection of a {@link ViraThemeClient}.
39
+ *
40
+ * @category Internal
41
+ */
42
+ export type ViraThemeStorage = {
43
+ /** The previously saved selection. `undefined` (nothing saved yet) is treated as `Auto`. */
44
+ init: ViraThemeSelection | undefined;
45
+ /** Called after the selection has already been applied, so it can be async. */
46
+ save(selection: ViraThemeSelection): MaybePromise<void>;
47
+ };
14
48
  /**
15
49
  * Used by {@link ViraThemeClient} to apply themes. By default, vira themes will be applied (via
16
50
  * {@link defaultApplyThemeCallback}).
@@ -38,6 +72,7 @@ export type ViraThemeClientParams = PartialWithUndefined<{
38
72
  * will be used.
39
73
  */
40
74
  applyTheme: ApplyThemeCallback;
75
+ }> & RequireOneOrNone<{
41
76
  /**
42
77
  * Override the LocalStorage store name used for theme persistence. Useful if a single page
43
78
  * hosts multiple isolated theme clients.
@@ -45,6 +80,11 @@ export type ViraThemeClientParams = PartialWithUndefined<{
45
80
  * @default 'vira-theme'
46
81
  */
47
82
  storeName: string;
83
+ /**
84
+ * Where to persist the selected theme. If not provided, the selection is persisted in
85
+ * LocalStorage under `storeName`.
86
+ */
87
+ storage: ViraThemeStorage;
48
88
  }>;
49
89
  declare const themeStorageShapes: {
50
90
  selectedTheme: import("object-shape-tester").Shape<{
@@ -53,18 +93,25 @@ declare const themeStorageShapes: {
53
93
  };
54
94
  /**
55
95
  * Tracks the user's {@link ViraThemeSelection} and bridges it to a consumer-supplied `applyTheme`
56
- * callback. Persists the selection in LocalStorage via an internal `LocalStorageClient`, and
57
- * listens for system color-scheme changes to re-apply the theme in auto mode without persisting.
96
+ * callback. Persists the selection through `storage` (LocalStorage by default), and listens for
97
+ * system color-scheme changes to re-apply the theme in auto mode without persisting. Listen for
98
+ * {@link ViraThemeAppliedEvent} to react to the displayed theme changing.
58
99
  *
59
100
  * The initial theme is applied during construction.
60
101
  *
61
102
  * @category Util
62
103
  */
63
- export declare class ViraThemeClient {
104
+ export declare class ViraThemeClient extends ListenTarget<ViraThemeAppliedEvent> {
64
105
  /** The callback that will be called to apply a new theme. */
65
106
  protected readonly applyThemeCallback: ApplyThemeCallback;
66
- /** Contains the user's last selected theme, saving and loading it to disk for persistence. */
67
- protected readonly localStorageClient: LocalStorageClient<typeof themeStorageShapes>;
107
+ /** The default storage, only created when no `storage` param is given. */
108
+ protected readonly localStorageClient: LocalStorageClient<typeof themeStorageShapes> | undefined;
109
+ /** Persists the user's selected theme. */
110
+ protected readonly storage: ViraThemeStorage;
111
+ /** The user's selected theme. */
112
+ protected selectedTheme: ViraThemeSelection;
113
+ /** The theme that is currently displayed. */
114
+ protected appliedTheme: Exclude<ViraThemeSelection, ViraThemeSelection.Auto> | undefined;
68
115
  /** A callback to remove the global theme preference listener. */
69
116
  protected readonly removeThemePreferenceListener: () => void;
70
117
  constructor(params?: Readonly<ViraThemeClientParams>);
@@ -73,11 +120,13 @@ export declare class ViraThemeClient {
73
120
  * get out of sync.
74
121
  */
75
122
  getCurrentTheme(): ViraThemeSelection;
123
+ /** The theme that is currently displayed, with `Auto` resolved from the system color scheme. */
124
+ getAppliedTheme(): Exclude<ViraThemeSelection, ViraThemeSelection.Auto>;
76
125
  /** Set the selected theme. */
77
126
  setSelectedTheme(selection: ViraThemeSelection): void;
78
127
  /** Cleanup internal state and listeners. */
79
128
  destroy(): void;
80
- /** Apply the currently selected theme. */
129
+ /** Apply the given selection and fire {@link ViraThemeAppliedEvent} if the result changed. */
81
130
  protected applySelection(selection: ViraThemeSelection): void;
82
131
  }
83
132
  export {};
@@ -2,7 +2,7 @@ import { assert } from '@augment-vir/assert';
2
2
  import { LocalStorageClient } from '@electrovir/local-storage-client';
3
3
  import { defineShape, enumShape } from 'object-shape-tester';
4
4
  import { applyColorThemeViaStyleElement } from 'theme-vir/dist/color-theme/apply-color-theme.js';
5
- import { listenTo } from 'typed-event-target';
5
+ import { defineTypedCustomEvent, ListenTarget, listenTo } from 'typed-event-target';
6
6
  import { viraTheme, viraThemeDarkOverride } from '../styles/vira-color-theme.js';
7
7
  /**
8
8
  * A user-facing selection of which theme to display. `Auto` follows the system color scheme; the
@@ -16,6 +16,15 @@ export var ViraThemeSelection;
16
16
  ViraThemeSelection["Dark"] = "dark";
17
17
  ViraThemeSelection["Auto"] = "auto";
18
18
  })(ViraThemeSelection || (ViraThemeSelection = {}));
19
+ /**
20
+ * Fired by {@link ViraThemeClient} whenever its applied theme changes, including when the system
21
+ * color scheme flips while the selection is `Auto`. Not fired when a new selection leaves the
22
+ * applied theme the same (such as switching from `Dark` to `Auto` on a dark system).
23
+ *
24
+ * @category Internal
25
+ */
26
+ export class ViraThemeAppliedEvent extends defineTypedCustomEvent()('vira-theme-applied') {
27
+ }
19
28
  /**
20
29
  * Default implementation of {@link ApplyThemeCallback}, which simply applies Vira themes.
21
30
  *
@@ -32,62 +41,94 @@ const themeStorageShapes = {
32
41
  };
33
42
  /**
34
43
  * Tracks the user's {@link ViraThemeSelection} and bridges it to a consumer-supplied `applyTheme`
35
- * callback. Persists the selection in LocalStorage via an internal `LocalStorageClient`, and
36
- * listens for system color-scheme changes to re-apply the theme in auto mode without persisting.
44
+ * callback. Persists the selection through `storage` (LocalStorage by default), and listens for
45
+ * system color-scheme changes to re-apply the theme in auto mode without persisting. Listen for
46
+ * {@link ViraThemeAppliedEvent} to react to the displayed theme changing.
37
47
  *
38
48
  * The initial theme is applied during construction.
39
49
  *
40
50
  * @category Util
41
51
  */
42
- export class ViraThemeClient {
52
+ export class ViraThemeClient extends ListenTarget {
43
53
  /** The callback that will be called to apply a new theme. */
44
54
  applyThemeCallback = defaultApplyThemeCallback;
45
- /** Contains the user's last selected theme, saving and loading it to disk for persistence. */
55
+ /** The default storage, only created when no `storage` param is given. */
46
56
  localStorageClient;
57
+ /** Persists the user's selected theme. */
58
+ storage;
59
+ /** The user's selected theme. */
60
+ selectedTheme;
61
+ /** The theme that is currently displayed. */
62
+ appliedTheme;
47
63
  /** A callback to remove the global theme preference listener. */
48
64
  removeThemePreferenceListener = listenTo(globalThis.matchMedia(darkSchemeMediaQuery), 'change', (event) => {
49
65
  assert.instanceOf(event, MediaQueryListEvent);
50
- if (this.getCurrentTheme() === ViraThemeSelection.Auto) {
51
- void this.applyThemeCallback({
52
- useDarkTheme: event.matches,
53
- });
66
+ if (this.selectedTheme === ViraThemeSelection.Auto) {
67
+ this.applySelection(ViraThemeSelection.Auto);
54
68
  }
55
69
  });
56
70
  constructor(params = {}) {
71
+ super();
57
72
  if (params.applyTheme) {
58
73
  this.applyThemeCallback = params.applyTheme;
59
74
  }
60
- this.localStorageClient = new LocalStorageClient(themeStorageShapes, {
61
- storeName: params.storeName || 'vira-theme',
62
- });
63
- this.applySelection(this.getCurrentTheme());
75
+ if (params.storage) {
76
+ this.storage = params.storage;
77
+ }
78
+ else {
79
+ const localStorageClient = new LocalStorageClient(themeStorageShapes, {
80
+ storeName: params.storeName || 'vira-theme',
81
+ });
82
+ this.localStorageClient = localStorageClient;
83
+ this.storage = {
84
+ init: localStorageClient.get.selectedTheme()?.theme,
85
+ save(theme) {
86
+ localStorageClient.set.selectedTheme({
87
+ theme,
88
+ });
89
+ },
90
+ };
91
+ }
92
+ this.selectedTheme = this.storage.init || ViraThemeSelection.Auto;
93
+ this.applySelection(this.selectedTheme);
64
94
  }
65
95
  /**
66
96
  * The currently selected theme. If you use multiple clients to set the same theme, this might
67
97
  * get out of sync.
68
98
  */
69
99
  getCurrentTheme() {
70
- return this.localStorageClient.get.selectedTheme()?.theme || ViraThemeSelection.Auto;
100
+ return this.selectedTheme;
101
+ }
102
+ /** The theme that is currently displayed, with `Auto` resolved from the system color scheme. */
103
+ getAppliedTheme() {
104
+ return this.appliedTheme || ViraThemeSelection.Light;
71
105
  }
72
106
  /** Set the selected theme. */
73
107
  setSelectedTheme(selection) {
108
+ this.selectedTheme = selection;
74
109
  this.applySelection(selection);
75
- this.localStorageClient.set.selectedTheme({
76
- theme: selection,
77
- });
110
+ void this.storage.save(selection);
78
111
  }
79
112
  /** Cleanup internal state and listeners. */
80
113
  destroy() {
81
114
  this.removeThemePreferenceListener();
82
- this.localStorageClient.destroy();
115
+ this.localStorageClient?.destroy();
116
+ super.destroy();
83
117
  }
84
- /** Apply the currently selected theme. */
118
+ /** Apply the given selection and fire {@link ViraThemeAppliedEvent} if the result changed. */
85
119
  applySelection(selection) {
86
120
  const useDarkTheme = selection === ViraThemeSelection.Dark ||
87
121
  (selection === ViraThemeSelection.Auto &&
88
122
  globalThis.matchMedia(darkSchemeMediaQuery).matches);
123
+ const appliedTheme = useDarkTheme ? ViraThemeSelection.Dark : ViraThemeSelection.Light;
89
124
  void this.applyThemeCallback({
90
125
  useDarkTheme,
91
126
  });
127
+ if (appliedTheme !== this.appliedTheme) {
128
+ this.appliedTheme = appliedTheme;
129
+ this.dispatch(new ViraThemeAppliedEvent({
130
+ detail: appliedTheme,
131
+ }));
132
+ }
92
133
  }
93
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vira",
3
- "version": "33.5.1",
3
+ "version": "33.7.0",
4
4
  "description": "A simple and highly versatile design system using element-vir.",
5
5
  "keywords": [
6
6
  "design",