-
Notifications
You must be signed in to change notification settings - Fork 2.1k
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
feat(select): Convert JS to TypeScript #4386
Conversation
Codecov Report
@@ Coverage Diff @@
## feat/typescript #4386 +/- ##
===================================================
+ Coverage 98.68% 98.69% +<.01%
===================================================
Files 94 95 +1
Lines 5987 6032 +45
Branches 805 797 -8
===================================================
+ Hits 5908 5953 +45
Misses 78 78
Partials 1 1
Continue to review full report at Codecov.
|
All 621 screenshot tests passed for commit bc8bf0d vs. |
…nts/material-components-web into feat/typescript--select
All 621 screenshot tests passed for commit 7a7d960 vs. |
packages/mdc-list/index.ts
Outdated
@@ -33,8 +33,8 @@ class MDCList extends MDCComponent<MDCListFoundation> { | |||
this.foundation_.setVerticalOrientation(value); | |||
} | |||
|
|||
get listElements(): Element[] { | |||
return [].slice.call(this.root_.querySelectorAll(strings.ENABLED_ITEMS_SELECTOR)); | |||
get listElements(): HTMLElement[] { |
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.
is it guaranteed that this will always be HTMLElement? I think so, but just wondering what you were thinking.
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 think it was because we're calling .focus()
on these items in a few places, and I assumed .mdc-list-item
would always be an HTMLElement
anyway, so I thought we could avoid type assertions by just declaring it an HTMLElement
.
However, it looks like it's pretty easy to change this back to Element
if you prefer (take a look at commit 91f5975).
I figured it's probably OK to return a more specific subtype, as long as our arguments are still Element
for Closure users.
WDYT?
All 624 screenshot tests passed for commit f6de964 vs. |
All 624 screenshot tests passed for commit 4fa0e79 vs. |
All 624 screenshot tests passed for commit dc34eb6 vs. |
packages/mdc-list/index.ts
Outdated
@@ -135,7 +135,7 @@ class MDCList extends MDCComponent<MDCListFoundation> { | |||
} | |||
}, | |||
focusItemAtIndex: (index) => { | |||
const element = this.listElements[index] as HTMLElement; | |||
const element = this.listElements[index] as HTMLElement | null; |
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.
Wouldn't this technically be undefined
, not null
, if it didn't exist? What does this fix?
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.
You are technically correct! 😀 Fixed.
There's a subtle but important difference between a type assertion with and without | null
(or | undefined
):
// This tells the TypeScript compiler that `element` will
// NEVER be null or undefined, so it won't yell at you
// if you try to access a property on `element`, which is dangerous.
const element = this.listElements[index] as HTMLElement;
// This tells tsc that `element` might not have a value, so it should
// yell at you if you try to access a property without a truthy check.
const element = this.listElements[index] as HTMLElement | undefined;
In this specific case, it doesn't matter quite as much because we already have a truthy check below (if (element) { ... }
), but if we ever refactor this code, we could easily forget that element
might be undefined
.
Personally, I'd rather be semantically accurate by declaring that the type might be undefined
.
@@ -24,6 +24,9 @@ | |||
import {MDCList} from '@material/list/index'; | |||
import {MDCMenuSurface} from '@material/menu-surface/index'; | |||
|
|||
export type MenuItemEvent = CustomEvent<MenuItemEventDetail>; | |||
export type DefaultMenuItemEvent = CustomEvent<DefaultMenuItemEventDetail>; |
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 guess I overlooked this in the original Menu PR but I found the Default
here confusing. Unsure whether #4407 could leave room for resolving this.
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 had a hard time figuring out what to call this type, so I'm definitely open to suggestions.
MenuItemEvent
is only used by foundation and adapterDefaultMenuItemEvent
is only used by the component, and has an additional property on itsdetail
object
What would you prefer to call it?
packages/mdc-select/types.ts
Outdated
index: number; | ||
} | ||
|
||
// TODO(acdvorak): Every component should export its own factory and event types. |
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.
Define "every"? This is pretty particular to text field and select sub-components.
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.
A number of components need to instantiate ripples, icons, etc. I figured we might as well just make it a standard pattern to always export a factory type for every component so that we don't end up with duplicate factory definitions in lots of different components.
packages/mdc-select/index.ts
Outdated
element.addEventListener('blur', this.handleBlur_); | ||
|
||
MOUSEDOWN_TOUCHSTART_EVENTS.forEach((evtType) => { | ||
element.addEventListener(evtType, this.handleClick_ as EventListener); |
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.
since element can potentially be null, I think we need a guard here.
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.
The component is only valid if nativeInput or selectedText is present, and the code above declares that selectedText will be defined if nativeInput isn't (there's also a comment above it).
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.
element
should never be null:
// One of these elements must be non-null.
const element = this.nativeControl_ || this.selectedText_!;
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.
Should we then throw an error if both are missing? It seems like a helpful user error to know why the component wouldn't be working. I guess it is obvious if you don't have a select/list, just throwing out ideas.
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.
Yeah, that sounds like a good idea. I'll do that and add a unit test.
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.
Done
packages/mdc-select/index.ts
Outdated
*/ | ||
private getNormalizedXCoordinate_(evt: MouseEvent): number { | ||
const targetClientRect = (evt.target as Element).getBoundingClientRect(); | ||
const xCoordinate = evt.clientX; // TODO(acdvorak): How should this be typed? |
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.
isn't this inferred to be a number already?
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.
The problem is that this method actually takes a MouseEvent | TouchEvent
, and clientX
isn't defined on TouchEvent
. I'm actually not sure how this code works at all right now.
type PointerEventType = 'mousedown' | 'touchstart';
const POINTER_EVENTS: PointerEventType[] = ['mousedown', 'touchstart'];
class MDCSelect {
private handleClick_!: SpecificEventListener<PointerEventType>; // assigned in initialize()
initialSyncWithDOM() {
this.handleClick_ = (evt) => {
if (this.selectedText_) {
this.selectedText_.focus();
}
this.foundation_.handleClick(this.getNormalizedXCoordinate_(evt));
};
POINTER_EVENTS.forEach((evtType) => {
element.addEventListener(evtType, this.handleClick_ as EventListener);
});
}
private getNormalizedXCoordinate_(evt: MouseEvent | TouchEvent): number {
const targetClientRect = (evt.target as Element).getBoundingClientRect();
const xCoordinate = evt.clientX; // TODO(acdvorak): How should this be typed?
return xCoordinate - targetClientRect.left;
}
}
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.
OH! Wait I think there is a bug for this...I don't think it works.
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.
Having a hard time finding it, but I do believe this was an issue in either React or Web or both.
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.
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.
Didn't we fix this somewhere to dig out touches[0]
, which does have clientX?
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.
The above issue I linked to is text field. So I think we also need to fix for select.
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.
Good call! A type guard should do the trick:
class MDCSelect {
private getNormalizedXCoordinate_(evt: MouseEvent | TouchEvent): number {
const targetClientRect = (evt.target as Element).getBoundingClientRect();
const xCoordinate = this.isTouchEvent_(evt) ? evt.touches[0].clientX : evt.clientX;
return xCoordinate - targetClientRect.left;
}
private isTouchEvent_(evt: MouseEvent | TouchEvent): evt is TouchEvent {
return Boolean((evt as TouchEvent).touches);
}
}
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.
Done
All 624 screenshot tests passed for commit ebbbbb9 vs. |
All 624 screenshot tests passed for commit a236050 vs. |
@@ -336,14 +239,111 @@ class MDCSelect extends MDCComponent<MDCSelectFoundation> implements RippleCapab | |||
super.destroy(); | |||
} | |||
|
|||
get value(): string { |
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.
wow I wish Github would just move the initialize method lol
All 624 screenshot tests passed for commit 1e8b7e2 vs. |
private nativeControl_!: HTMLSelectElement | null; // assigned in initialize() | ||
private selectedText_!: HTMLElement | null; // assigned in initialize() | ||
|
||
private targetElement_!: HTMLElement; // assigned in initialize() |
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.
good clean up idea!
All 624 screenshot tests passed for commit a0cce8c vs. |
return xCoordinate - targetClientRect.left; | ||
} | ||
|
||
private isTouchEvent_(evt: MouseEvent | TouchEvent): evt is TouchEvent { |
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.
what if we changed this to just getXCoordinateFromEvent
? Then we just encapsulate this this.isTouchEvent_(evt) ? evt.touches[0].clientX : evt.clientX;
logic...cause we still end up having to cast it anyways inside this method.
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.
As discussed offline, it would need to look like this:
private getNormalizedXCoordinate_(evt: MouseEvent | TouchEvent): number {
const targetClientRect = (evt.target as Element).getBoundingClientRect();
return this.getClientXFromEvent_(evt) - targetClientRect.left;
}
private getClientXFromEvent_(evt: MouseEvent | TouchEvent): number {
const touchEvt = evt as TouchEvent;
const mouseEvt = evt as MouseEvent;
if (touchEvt.touches) {
return touchEvt.touches[0].clientX;
}
return mouseEvt.clientX;
}
Matt and I prefer the isTouchEvent_()
style, so we're going to leave it as-is.
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.
Looks good!
Addressed Ken's comments; approved by Matt
Refs #4225