-
-
Notifications
You must be signed in to change notification settings - Fork 945
Sparkles #14780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Sparkles #14780
Changes from all commits
e7c5eea
666a0e7
1e8673e
5998b44
7eb8a02
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,34 +56,40 @@ | |
| </template> | ||
| </div> | ||
|
|
||
| <!-- Submit button: shows only a forward-arrow icon; the aria-label | ||
| cycles through four instructional states as the sequence is built. --> | ||
| <button | ||
| type="submit" | ||
| class="submit-button" | ||
| :class="[ | ||
| $computedClass({ | ||
| ':hover': submitEnabled | ||
| ? { | ||
| backgroundColor: $themeTokens.primaryDark, | ||
| } | ||
| : {}, | ||
| }), | ||
| { pulsing: submitPulsing }, | ||
| { bouncing: arrowBouncing }, | ||
| ]" | ||
| data-testid="submit-button" | ||
| :aria-disabled="!submitEnabled ? 'true' : undefined" | ||
| :aria-label="submitButtonAriaLabel" | ||
| :style="submitButtonStyle" | ||
| > | ||
| <KIcon | ||
| data-testid="submit-icon" | ||
| class="submit-icon" | ||
| icon="forward" | ||
| :color="submitEnabled ? $themeTokens.textInverted : $themePalette.grey.v_300" | ||
| <div class="submit-container"> | ||
| <!-- Submit button: shows only a forward-arrow icon; the aria-label | ||
| cycles through four instructional states as the sequence is built. --> | ||
| <SubmitBurstAnimation | ||
| v-if="burstVisible" | ||
| class="submit-burst" | ||
| /> | ||
| </button> | ||
| <button | ||
| type="submit" | ||
| class="submit-button" | ||
| :class="[ | ||
| $computedClass({ | ||
| ':hover': submitEnabled | ||
| ? { | ||
| backgroundColor: $themeTokens.primaryDark, | ||
| } | ||
| : {}, | ||
| }), | ||
| { pulsing: submitPulsing }, | ||
| { bouncing: arrowBouncing }, | ||
| ]" | ||
| data-testid="submit-button" | ||
| :aria-disabled="!submitEnabled ? 'true' : undefined" | ||
| :aria-label="submitButtonAriaLabel" | ||
| :style="submitButtonStyle" | ||
| > | ||
| <KIcon | ||
| data-testid="submit-icon" | ||
| class="submit-icon" | ||
| icon="forward" | ||
| :color="submitEnabled ? $themeTokens.textInverted : $themePalette.grey.v_300" | ||
| /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </form> | ||
|
|
||
|
|
@@ -100,6 +106,7 @@ | |
| import { picturePasswordStrings } from 'kolibri-common/strings/picturePasswords'; | ||
| import useKResponsiveElement from 'kolibri-design-system/lib/composables/useKResponsiveElement'; | ||
| import PicturePasswordOption from './PicturePasswordOption'; | ||
| import SubmitBurstAnimation from './animations/SubmitBurstAnimation'; | ||
|
|
||
| // Pre-compute once at module scope — PICTURE_PASSWORD_SET is static JSON so | ||
| // there is no benefit to re-deriving this array on every component mount. | ||
|
|
@@ -111,7 +118,7 @@ | |
| export default { | ||
| name: 'PicturePasswordGrid', | ||
|
|
||
| components: { PicturePasswordOption }, | ||
| components: { PicturePasswordOption, SubmitBurstAnimation }, | ||
|
|
||
| setup(props, { emit }) { | ||
| const $themeTokens = themeTokens(); | ||
|
|
@@ -286,6 +293,7 @@ | |
| ); | ||
|
|
||
| const arrowBouncing = ref(false); | ||
| const burstVisible = ref(false); | ||
|
|
||
| /** | ||
| * @public | ||
|
|
@@ -294,26 +302,43 @@ | |
| */ | ||
| const playSuccessAnimation = () => { | ||
| const STAGGER = 150; | ||
| const DURATION = 380; | ||
| const ICON_BOUNCE_DURATION = 380; | ||
| const BURST_DURATION = 1100; | ||
| const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||
| const stagger = reduce ? 0 : STAGGER; | ||
| const dur = reduce ? 0 : DURATION; | ||
| const iconDuration = reduce ? 0 : ICON_BOUNCE_DURATION; | ||
| const burstDuration = reduce ? 0 : BURST_DURATION; | ||
| const iconCount = sequence.value.length; | ||
|
|
||
| if (!iconCount) { | ||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| return new Promise(resolve => { | ||
| for (let i = 0; i < sequence.value.length; i++) { | ||
| for (let i = 0; i < iconCount; i++) { | ||
| const id = sequence.value[i]; | ||
| const isLast = i === sequence.value.length - 1; | ||
| const isLast = i === iconCount - 1; | ||
| window.setTimeout(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: When Consider skipping the burst entirely when if (isLast) {
arrowBouncing.value = true;
if (!reduce) {
burstVisible.value = true;
window.setTimeout(() => {
burstVisible.value = false;
}, 1100);
}
} |
||
| bouncingId.value = id; | ||
| if (isLast) arrowBouncing.value = true; | ||
| if (isLast) { | ||
| arrowBouncing.value = true; | ||
| if (!reduce) { | ||
| burstVisible.value = true; | ||
| window.setTimeout(() => { | ||
| burstVisible.value = false; | ||
| }, burstDuration); | ||
| } | ||
| } | ||
| window.setTimeout(() => { | ||
| if (bouncingId.value === id) bouncingId.value = null; | ||
| if (isLast) arrowBouncing.value = false; | ||
| }, dur); | ||
| }, iconDuration); | ||
| }, i * stagger); | ||
| } | ||
|
|
||
| window.setTimeout(() => resolve(), (sequence.value.length - 1) * stagger + dur); | ||
| const lastStart = (iconCount - 1) * stagger; | ||
| const animationDuration = Math.max(lastStart + iconDuration, lastStart + burstDuration); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: Using |
||
| window.setTimeout(() => resolve(), animationDuration); | ||
| }); | ||
| }; | ||
|
|
||
|
|
@@ -356,6 +381,7 @@ | |
| submitPulsing, | ||
| bouncingId, | ||
| arrowBouncing, | ||
| burstVisible, | ||
| handleSelect, | ||
| handleDisabledSelect, | ||
| handleSubmit, | ||
|
|
@@ -463,16 +489,30 @@ | |
| border-radius: 16px; | ||
| } | ||
|
|
||
| .submit-button { | ||
| .submit-container { | ||
| position: relative; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| } | ||
|
|
||
| .submit-button { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: |
||
| width: 100%; | ||
| height: 100%; | ||
| padding: 0; | ||
| border: 0; | ||
| border-radius: 8px; | ||
| transition: $core-time; | ||
| } | ||
|
|
||
| .submit-burst { | ||
| position: absolute; | ||
| top: 50%; | ||
| left: 50%; | ||
| z-index: 100; | ||
| transform: translate(-50%, -50%); | ||
| } | ||
|
|
||
| .submit-icon { | ||
| top: 0; | ||
| width: 40px; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { mount } from '@vue/test-utils'; | ||
| import SubmitBurstAnimation from '../animations/SubmitBurstAnimation.vue'; | ||
|
|
||
| describe('SubmitBurstAnimation', () => { | ||
| let getElementByIdSpy; | ||
|
|
||
| afterEach(() => { | ||
| if (getElementByIdSpy) { | ||
| getElementByIdSpy.mockRestore(); | ||
| getElementByIdSpy = null; | ||
| } | ||
| }); | ||
|
|
||
| it('renders the burst SVG', () => { | ||
| const mockPlayer = { | ||
| ready: jest.fn(callback => callback({ play: jest.fn() })), | ||
| destruct: jest.fn(), | ||
| }; | ||
| getElementByIdSpy = jest.spyOn(document, 'getElementById').mockReturnValue({ | ||
| svgatorPlayer: mockPlayer, | ||
| }); | ||
|
|
||
| const wrapper = mount(SubmitBurstAnimation); | ||
|
|
||
| expect(wrapper.find('svg').exists()).toBe(true); | ||
|
|
||
| wrapper.destroy(); | ||
| }); | ||
|
|
||
| it('calls player.destruct on unmount when available', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: Explicitly testing the teardown path (not just mount) closes the loop on the prior blocking finding — this test would have caught the original |
||
| const destruct = jest.fn(); | ||
| const mockPlayer = { | ||
| ready: jest.fn(callback => callback({ play: jest.fn() })), | ||
| destruct, | ||
| }; | ||
| getElementByIdSpy = jest.spyOn(document, 'getElementById').mockReturnValue({ | ||
| svgatorPlayer: mockPlayer, | ||
| }); | ||
|
|
||
| const wrapper = mount(SubmitBurstAnimation); | ||
|
|
||
| wrapper.destroy(); | ||
|
|
||
| expect(destruct).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure there's a good reason to pin the ecmaVersion - we can set it as
'latest'in the base kolibri-format configuration.