ember-primitives 0.49.0 → 0.50.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 (103) hide show
  1. package/bin/index.mjs +271 -0
  2. package/declarations/components/rating/public-types.d.ts +0 -4
  3. package/declarations/components/rating/public-types.d.ts.map +1 -1
  4. package/declarations/components/rating/rating.d.ts +9 -1
  5. package/declarations/components/rating/rating.d.ts.map +1 -1
  6. package/declarations/components/rating/stars.d.ts.map +1 -1
  7. package/declarations/components/rating/state.d.ts +4 -0
  8. package/declarations/components/rating/state.d.ts.map +1 -1
  9. package/declarations/components/rating/utils.d.ts +0 -1
  10. package/declarations/components/rating/utils.d.ts.map +1 -1
  11. package/dist/components/rating.js +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/{rating-CjBVsX6q.js → rating-BrIiwDLw.js} +21 -17
  14. package/dist/rating-BrIiwDLw.js.map +1 -0
  15. package/package.json +6 -2
  16. package/src/-private.ts +4 -0
  17. package/src/color-scheme.ts +165 -0
  18. package/src/components/-private/typed-elements.gts +13 -0
  19. package/src/components/-private/utils.ts +16 -0
  20. package/src/components/accordion/content.gts +34 -0
  21. package/src/components/accordion/header.gts +36 -0
  22. package/src/components/accordion/item.gts +55 -0
  23. package/src/components/accordion/public.ts +64 -0
  24. package/src/components/accordion/trigger.gts +32 -0
  25. package/src/components/accordion.gts +195 -0
  26. package/src/components/avatar.gts +108 -0
  27. package/src/components/dialog.gts +234 -0
  28. package/src/components/external-link.gts +14 -0
  29. package/src/components/form.gts +75 -0
  30. package/src/components/heading.gts +36 -0
  31. package/src/components/keys.gts +53 -0
  32. package/src/components/layout/hero.css +5 -0
  33. package/src/components/layout/hero.gts +17 -0
  34. package/src/components/layout/sticky-footer.css +9 -0
  35. package/src/components/layout/sticky-footer.gts +40 -0
  36. package/src/components/link.gts +172 -0
  37. package/src/components/menu.gts +373 -0
  38. package/src/components/one-time-password/buttons.gts +31 -0
  39. package/src/components/one-time-password/input.gts +198 -0
  40. package/src/components/one-time-password/otp.gts +130 -0
  41. package/src/components/one-time-password/utils.ts +201 -0
  42. package/src/components/one-time-password.gts +2 -0
  43. package/src/components/popover.gts +248 -0
  44. package/src/components/portal-targets.gts +136 -0
  45. package/src/components/portal.gts +194 -0
  46. package/src/components/progress.gts +154 -0
  47. package/src/components/rating/public-types.ts +44 -0
  48. package/src/components/rating/range.gts +22 -0
  49. package/src/components/rating/rating.gts +228 -0
  50. package/src/components/rating/stars.gts +60 -0
  51. package/src/components/rating/state.gts +144 -0
  52. package/src/components/rating/utils.ts +7 -0
  53. package/src/components/rating.gts +5 -0
  54. package/src/components/scroller.gts +179 -0
  55. package/src/components/shadowed.gts +110 -0
  56. package/src/components/switch.gts +103 -0
  57. package/src/components/tabs.gts +519 -0
  58. package/src/components/toggle-group.gts +265 -0
  59. package/src/components/toggle.gts +81 -0
  60. package/src/components/violations.css +105 -0
  61. package/src/components/violations.css.ts +1 -0
  62. package/src/components/visually-hidden.css +14 -0
  63. package/src/components/visually-hidden.gts +15 -0
  64. package/src/components/zoetrope/index.gts +358 -0
  65. package/src/components/zoetrope/styles.css +40 -0
  66. package/src/components/zoetrope/types.ts +65 -0
  67. package/src/components/zoetrope.ts +3 -0
  68. package/src/dom-context.gts +245 -0
  69. package/src/floating-ui/component.gts +186 -0
  70. package/src/floating-ui/middleware.ts +13 -0
  71. package/src/floating-ui/modifier.ts +183 -0
  72. package/src/floating-ui.ts +2 -0
  73. package/src/head.gts +37 -0
  74. package/src/helpers/body-class.ts +94 -0
  75. package/src/helpers/link.ts +125 -0
  76. package/src/helpers/service.ts +25 -0
  77. package/src/helpers.ts +2 -0
  78. package/src/iframe.ts +31 -0
  79. package/src/index.ts +43 -0
  80. package/src/load.gts +77 -0
  81. package/src/narrowing.ts +7 -0
  82. package/src/on-resize.ts +64 -0
  83. package/src/proper-links.ts +140 -0
  84. package/src/qp.ts +107 -0
  85. package/src/resize-observer.ts +132 -0
  86. package/src/service.ts +103 -0
  87. package/src/store.ts +72 -0
  88. package/src/styles.css.ts +5 -0
  89. package/src/tabster.ts +54 -0
  90. package/src/template-registry.ts +44 -0
  91. package/src/test-support/a11y.ts +50 -0
  92. package/src/test-support/dom.ts +112 -0
  93. package/src/test-support/otp.ts +64 -0
  94. package/src/test-support/rating.ts +144 -0
  95. package/src/test-support/routing.ts +62 -0
  96. package/src/test-support/zoetrope.ts +51 -0
  97. package/src/test-support.gts +6 -0
  98. package/src/type-utils.ts +1 -0
  99. package/src/utils.ts +75 -0
  100. package/src/viewport/in-viewport.gts +128 -0
  101. package/src/viewport/viewport.ts +122 -0
  102. package/src/viewport.ts +2 -0
  103. package/dist/rating-CjBVsX6q.js.map +0 -1
@@ -0,0 +1,198 @@
1
+ import Component from "@glimmer/component";
2
+ import { warn } from "@ember/debug";
3
+ import { isDestroyed, isDestroying } from "@ember/destroyable";
4
+ import { on } from "@ember/modifier";
5
+ import { buildWaiter } from "@ember/test-waiters";
6
+
7
+ import {
8
+ autoAdvance,
9
+ getCollectiveValue,
10
+ handleNavigation,
11
+ handlePaste,
12
+ selectAll,
13
+ } from "./utils.ts";
14
+
15
+ import type { TOC } from "@ember/component/template-only";
16
+ import type { WithBoundArgs } from "@glint/template";
17
+
18
+ const DEFAULT_LENGTH = 6;
19
+
20
+ function labelFor(inputIndex: number, labelFn: undefined | ((index: number) => string)) {
21
+ if (labelFn) {
22
+ return labelFn(inputIndex);
23
+ }
24
+
25
+ return `Please enter OTP character ${inputIndex + 1}`;
26
+ }
27
+
28
+ const waiter = buildWaiter("ember-primitives:OTPInput:handleChange");
29
+
30
+ const Fields: TOC<{
31
+ /**
32
+ * Any attributes passed to this component will be applied to each input.
33
+ */
34
+ Element: HTMLInputElement;
35
+ Args: {
36
+ fields: unknown[];
37
+ labelFn: (index: number) => string;
38
+ handleChange: (event: Event) => void;
39
+ };
40
+ }> = <template>
41
+ {{#each @fields as |_field i|}}
42
+ <label>
43
+ <span class="ember-primitives__sr-only">{{labelFor i @labelFn}}</span>
44
+ <input
45
+ name="code{{i}}"
46
+ type="text"
47
+ inputmode="numeric"
48
+ autocomplete="off"
49
+ ...attributes
50
+ {{on "click" selectAll}}
51
+ {{on "paste" handlePaste}}
52
+ {{on "input" autoAdvance}}
53
+ {{on "input" @handleChange}}
54
+ {{on "keydown" handleNavigation}}
55
+ />
56
+ </label>
57
+ {{/each}}
58
+ </template>;
59
+
60
+ export class OTPInput extends Component<{
61
+ /**
62
+ * The collection of individual OTP inputs are contained by a fieldset.
63
+ * Applying the `disabled` attribute to this fieldset will disable
64
+ * all of the inputs, if that's desired.
65
+ */
66
+ Element: HTMLFieldSetElement;
67
+ Args: {
68
+ /**
69
+ * How many characters the one-time-password field should be
70
+ * Defaults to 6
71
+ */
72
+ length?: number;
73
+
74
+ /**
75
+ * To Customize the label of the input fields, you may pass a function.
76
+ * By default, this is `Please enter OTP character ${index + 1}`.
77
+ */
78
+ labelFn?: (index: number) => string;
79
+
80
+ /**
81
+ * If passed, this function will be called when the <Input> changes.
82
+ * All fields are considered one input.
83
+ */
84
+ onChange?: (
85
+ data: {
86
+ /**
87
+ * The text from the collective `<Input>`
88
+ *
89
+ * `code` _may_ be shorter than `length`
90
+ * if the user has not finished typing / pasting their code
91
+ */
92
+ code: string;
93
+ /**
94
+ * will be `true` if `code`'s length matches the passed `@length` or the default of 6
95
+ */
96
+ complete: boolean;
97
+ },
98
+ /**
99
+ * The last input event received
100
+ */
101
+ event: Event,
102
+ ) => void;
103
+ };
104
+ Blocks: {
105
+ /**
106
+ * Optionally, you may control how the Fields are rendered, with proceeding text,
107
+ * additional attributes added, etc.
108
+ *
109
+ * This is how you can add custom validation to each input field.
110
+ */
111
+ default?: [fields: WithBoundArgs<typeof Fields, "fields" | "handleChange" | "labelFn">];
112
+ };
113
+ }> {
114
+ /**
115
+ * This is debounced, because we bind to each input,
116
+ * but only want to emit one change event if someone pastes
117
+ * multiple characters
118
+ */
119
+ handleChange = (event: Event) => {
120
+ if (!this.args.onChange) return;
121
+
122
+ if (!this.#token) {
123
+ this.#token = waiter.beginAsync();
124
+ }
125
+
126
+ if (this.#frame) {
127
+ cancelAnimationFrame(this.#frame);
128
+ }
129
+
130
+ // We use requestAnimationFrame to be friendly to rendering.
131
+ // We don't know if onChange is going to want to cause paints
132
+ // (it's also how we debounce, under the assumption that "paste" behavior
133
+ // would be fast enough to be quicker than individual frames
134
+ // (see logic in autoAdvance)
135
+ // )
136
+ this.#frame = requestAnimationFrame(() => {
137
+ waiter.endAsync(this.#token);
138
+
139
+ if (isDestroyed(this) || isDestroying(this)) return;
140
+ if (!this.args.onChange) return;
141
+
142
+ const value = getCollectiveValue(event.target, this.length);
143
+
144
+ if (value === undefined) {
145
+ warn(`Value could not be determined for the OTP field. was it removed from the DOM?`, {
146
+ id: "ember-primitives.OTPInput.missing-value",
147
+ });
148
+
149
+ return;
150
+ }
151
+
152
+ this.args.onChange({ code: value, complete: value.length === this.length }, event);
153
+ });
154
+ };
155
+
156
+ #token: unknown;
157
+ #frame: number | undefined;
158
+
159
+ get length() {
160
+ return this.args.length ?? DEFAULT_LENGTH;
161
+ }
162
+
163
+ get fields() {
164
+ // We only need to iterate a number of times,
165
+ // so we don't care about the actual value or
166
+ // referential integrity here
167
+ return new Array<undefined>(this.length);
168
+ }
169
+
170
+ <template>
171
+ <fieldset ...attributes>
172
+ {{#let
173
+ (component Fields fields=this.fields handleChange=this.handleChange labelFn=@labelFn)
174
+ as |CurriedFields|
175
+ }}
176
+ {{#if (has-block)}}
177
+ {{yield CurriedFields}}
178
+ {{else}}
179
+ <CurriedFields />
180
+ {{/if}}
181
+ {{/let}}
182
+
183
+ <style>
184
+ .ember-primitives__sr-only {
185
+ position: absolute;
186
+ width: 1px;
187
+ height: 1px;
188
+ padding: 0;
189
+ margin: -1px;
190
+ overflow: hidden;
191
+ clip: rect(0, 0, 0, 0);
192
+ white-space: nowrap;
193
+ border-width: 0;
194
+ }
195
+ </style>
196
+ </fieldset>
197
+ </template>
198
+ }
@@ -0,0 +1,130 @@
1
+ import { assert } from "@ember/debug";
2
+ import { fn, hash } from "@ember/helper";
3
+ import { on } from "@ember/modifier";
4
+ import { buildWaiter } from "@ember/test-waiters";
5
+
6
+ import { Reset, Submit } from "./buttons.gts";
7
+ import { OTPInput } from "./input.gts";
8
+
9
+ import type { TOC } from "@ember/component/template-only";
10
+ import type { WithBoundArgs } from "@glint/template";
11
+
12
+ const waiter = buildWaiter("ember-primitives:OTP:handleAutoSubmitAttempt");
13
+
14
+ const handleFormSubmit = (submit: (data: { code: string }) => void, event: SubmitEvent) => {
15
+ event.preventDefault();
16
+
17
+ assert(
18
+ "[BUG]: handleFormSubmit was not attached to a form. Please open an issue.",
19
+ event.currentTarget instanceof HTMLFormElement,
20
+ );
21
+
22
+ const formData = new FormData(event.currentTarget);
23
+
24
+ let code = "";
25
+
26
+ for (const [key, value] of formData.entries()) {
27
+ if (key.startsWith("code")) {
28
+ // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string
29
+ code += value;
30
+ }
31
+ }
32
+
33
+ submit({
34
+ code,
35
+ });
36
+ };
37
+
38
+ function handleChange(
39
+ autoSubmit: boolean | undefined,
40
+ data: { code: string; complete: boolean },
41
+ event: Event,
42
+ ) {
43
+ if (!autoSubmit) return;
44
+ if (!data.complete) return;
45
+
46
+ assert(
47
+ "[BUG]: event target is not a known element type",
48
+ event.target instanceof HTMLElement || event.target instanceof SVGElement,
49
+ );
50
+
51
+ const form = event.target.closest("form");
52
+
53
+ assert("[BUG]: Cannot handle event when <OTP> Inputs are not rendered within their <form>", form);
54
+
55
+ const token = waiter.beginAsync();
56
+ const finished = () => {
57
+ waiter.endAsync(token);
58
+ form.removeEventListener("submit", finished);
59
+ };
60
+
61
+ form.addEventListener("submit", finished);
62
+
63
+ // NOTE: when calling .submit() the submit event handlers are not run
64
+ form.requestSubmit();
65
+ }
66
+
67
+ export const OTP: TOC<{
68
+ /**
69
+ * The overall OTP Input is in its own form.
70
+ * Modern UI/UX Patterns usually have this sort of field
71
+ * as its own page, thus within its own form.
72
+ *
73
+ * By default, only the 'submit' event is bound, and is
74
+ * what calls the `@onSubmit` argument.
75
+ */
76
+ Element: HTMLFormElement;
77
+ Args: {
78
+ /**
79
+ * How many characters the one-time-password field should be
80
+ * Defaults to 6
81
+ */
82
+ length?: number;
83
+
84
+ /**
85
+ * The on submit callback will give you the entered
86
+ * one-time-password code.
87
+ *
88
+ * It will be called when the user manually clicks the 'submit'
89
+ * button or when the full code is pasted and meats the validation
90
+ * criteria.
91
+ */
92
+ onSubmit: (data: { code: string }) => void;
93
+
94
+ /**
95
+ * Whether or not to auto-submit after the code has been pasted
96
+ * in to the collective "field". Default is true
97
+ */
98
+ autoSubmit?: boolean;
99
+ };
100
+ Blocks: {
101
+ default: [
102
+ {
103
+ /**
104
+ * The collective input field that the OTP code will be typed/pasted in to
105
+ */
106
+ Input: WithBoundArgs<typeof OTPInput, "length" | "onChange">;
107
+ /**
108
+ * Button with `type="submit"` to submit the form
109
+ */
110
+ Submit: typeof Submit;
111
+ /**
112
+ * Pre-wired button to reset the form
113
+ */
114
+ Reset: typeof Reset;
115
+ },
116
+ ];
117
+ };
118
+ }> = <template>
119
+ <form {{on "submit" (fn handleFormSubmit @onSubmit)}} ...attributes>
120
+ {{yield
121
+ (hash
122
+ Input=(component
123
+ OTPInput length=@length onChange=(if @autoSubmit (fn handleChange @autoSubmit))
124
+ )
125
+ Submit=Submit
126
+ Reset=Reset
127
+ )
128
+ }}
129
+ </form>
130
+ </template>;
@@ -0,0 +1,201 @@
1
+ import { assert } from '@ember/debug';
2
+
3
+ function getInputs(current: HTMLInputElement) {
4
+ const fieldset = current.closest('fieldset');
5
+
6
+ assert('[BUG]: fieldset went missing', fieldset);
7
+
8
+ return [...fieldset.querySelectorAll('input')];
9
+ }
10
+
11
+ function nextInput(current: HTMLInputElement) {
12
+ const inputs = getInputs(current);
13
+ const currentIndex = inputs.indexOf(current);
14
+
15
+ return inputs[currentIndex + 1];
16
+ }
17
+
18
+ export function selectAll(event: Event) {
19
+ const target = event.target;
20
+
21
+ assert(`selectAll is only meant for use with input elements`, target instanceof HTMLInputElement);
22
+
23
+ target.select();
24
+ }
25
+
26
+ export function handlePaste(event: Event) {
27
+ const target = event.target;
28
+
29
+ assert(
30
+ `handlePaste is only meant for use with input elements`,
31
+ target instanceof HTMLInputElement
32
+ );
33
+
34
+ const clipboardData = (event as ClipboardEvent).clipboardData;
35
+
36
+ assert(
37
+ `Could not get clipboardData while handling the paste event on OTP. Please report this issue on the ember-primitives repo with a reproduction. Thanks!`,
38
+ clipboardData
39
+ );
40
+
41
+ // This is typically not good to prevent paste.
42
+ // But because of the UX we're implementing,
43
+ // we want to split the pasted value across
44
+ // multiple text fields
45
+ event.preventDefault();
46
+
47
+ const value = clipboardData.getData('Text');
48
+ const digits = value;
49
+ let i = 0;
50
+ let currElement: HTMLInputElement | null = target;
51
+
52
+ while (currElement) {
53
+ currElement.value = digits[i++] || '';
54
+
55
+ const next = nextInput(currElement);
56
+
57
+ if (next instanceof HTMLInputElement) {
58
+ currElement = next;
59
+ } else {
60
+ break;
61
+ }
62
+ }
63
+
64
+ // We want to select the first field again
65
+ // so that if someone holds paste, or
66
+ // pastes again, they get the same result.
67
+ target.select();
68
+ }
69
+
70
+ export function handleNavigation(event: KeyboardEvent) {
71
+ switch (event.key) {
72
+ case 'Backspace':
73
+ return handleBackspace(event);
74
+ case 'ArrowLeft':
75
+ return focusLeft(event);
76
+ case 'ArrowRight':
77
+ return focusRight(event);
78
+ }
79
+ }
80
+
81
+ function focusLeft(event: Pick<Event, 'target'>) {
82
+ const target = event.target;
83
+
84
+ assert(`only allowed on input elements`, target instanceof HTMLInputElement);
85
+
86
+ const input = previousInput(target);
87
+
88
+ input?.focus();
89
+ requestAnimationFrame(() => {
90
+ input?.select();
91
+ });
92
+ }
93
+
94
+ function focusRight(event: Pick<Event, 'target'>) {
95
+ const target = event.target;
96
+
97
+ assert(`only allowed on input elements`, target instanceof HTMLInputElement);
98
+
99
+ const input = nextInput(target);
100
+
101
+ input?.focus();
102
+ requestAnimationFrame(() => {
103
+ input?.select();
104
+ });
105
+ }
106
+
107
+ const syntheticEvent = new InputEvent('input');
108
+
109
+ function handleBackspace(event: KeyboardEvent) {
110
+ if (event.key !== 'Backspace') return;
111
+
112
+ /**
113
+ * We have to prevent default because we
114
+ * - want to clear the whole field
115
+ * - have the focus behavior keep up with the key-repeat
116
+ * speed of the user's computer
117
+ */
118
+ event.preventDefault();
119
+
120
+ const target = event.target;
121
+
122
+ if (target && 'value' in target) {
123
+ if (target.value === '') {
124
+ focusLeft({ target });
125
+ } else {
126
+ target.value = '';
127
+ }
128
+ }
129
+
130
+ target?.dispatchEvent(syntheticEvent);
131
+ }
132
+
133
+ function previousInput(current: HTMLInputElement) {
134
+ const inputs = getInputs(current);
135
+ const currentIndex = inputs.indexOf(current);
136
+
137
+ return inputs[currentIndex - 1];
138
+ }
139
+
140
+ export const autoAdvance = (event: Event) => {
141
+ assert(
142
+ '[BUG]: autoAdvance called on non-input element',
143
+ event.target instanceof HTMLInputElement
144
+ );
145
+
146
+ const value = event.target.value;
147
+
148
+ if (value.length === 0) return;
149
+
150
+ if (value.length > 0) {
151
+ if ('data' in event && event.data && typeof event.data === 'string') {
152
+ event.target.value = event.data;
153
+ }
154
+
155
+ return focusRight(event);
156
+ }
157
+ };
158
+
159
+ export function getCollectiveValue(elementTarget: EventTarget | null, length: number) {
160
+ if (!elementTarget) return;
161
+
162
+ assert(
163
+ `[BUG]: somehow the element target is not HTMLElement`,
164
+ elementTarget instanceof HTMLElement
165
+ );
166
+
167
+ let parent: null | HTMLElement | ShadowRoot;
168
+
169
+ // TODO: should this logic be extracted?
170
+ // why is getting the target element within a shadow root hard?
171
+ if (!(elementTarget instanceof HTMLInputElement)) {
172
+ if (elementTarget.shadowRoot) {
173
+ parent = elementTarget.shadowRoot;
174
+ } else {
175
+ parent = elementTarget.closest('fieldset');
176
+ }
177
+ } else {
178
+ parent = elementTarget.closest('fieldset');
179
+ }
180
+
181
+ assert(`[BUG]: somehow the input fields were rendered without a parent element`, parent);
182
+
183
+ const elements = parent.querySelectorAll('input');
184
+
185
+ let value = '';
186
+
187
+ assert(
188
+ `found elements (${elements.length}) do not match length (${length}). Was the same OTP input rendered more than once?`,
189
+ elements.length === length
190
+ );
191
+
192
+ for (const element of elements) {
193
+ assert(
194
+ '[BUG]: how did the queried elements become a non-input element?',
195
+ element instanceof HTMLInputElement
196
+ );
197
+ value += element.value;
198
+ }
199
+
200
+ return value;
201
+ }
@@ -0,0 +1,2 @@
1
+ export { OTPInput } from "./one-time-password/input.gts";
2
+ export { OTP } from "./one-time-password/otp.gts";