-
Notifications
You must be signed in to change notification settings - Fork 77
coding conventions
This is a living document defining our best practices and reasoning for authoring Calcite Components.
Generally adhere to and follow these best practices for authoring components.
We follow Stencil's suggested component structure. See their style guide for more details.
Calcite Components broadly targets two groups of projects inside Esri:
- Sites like esri.com and developers.arcgis.com.
- Apps like ArcGIS Online, Vector Tile Style Editor, Workforce, ArcGIS Hub etc...
Components should present the minimum possible implementation to be usable by both sites and apps and leave as much as possible to users.
It is generally agreed on that components should not:
- Make network requests. Authentication and the exact environment of the request is difficult to manage and better left to the specific application or site.
- Manage routing or manipulate the URL. Managing the URL is the domain of the specific site or app.
- Implement any feature which can easily be achieved with simple CSS and HTML. E.x. it was decided that
<calcite-switch>
should not supporttext
orposition
properties because those could be easily duplicated with CSS (ref) - Implement any component which might replace a browser feature, without adding functionality that goes above and beyond what browser defaults would provide.
However components are allowed to:
- Use or implement
localStorage
if there is a specific use case. - Communicate with other components if a specific use case exists.
Discussed In:
- Should tabs support syncing and loading from localstorage . Yes because such feature are difficult to implement for Sites and would require lots of additional JavaScript work on the part of teams and authors
- Should switch support a label. No because label place
All public events should be documented with JSDoc.
Event names should be treated like global variables since they can collide with any other event names and global variables. As such follow these guidelines when naming events.
- Name events list
Component + Event name
for example thechange
event on<calcite-tabs>
should be namedcalciteTabsChange
. - Always prefix event names with
calcite
and never use an event name used by existing DOM standards https://developer.mozilla.org/en-US/docs/Web/Events. - For example:
- Bad:
change
- Good:
calciteTabChange
- Bad:
- If an existing event can be listened to, don't create a new custom event. For example, there is no need to create a
calciteButtonClick
event because a standardclick
event will still be fired from the element. - For consistency, use
calcite<ComponentName>Change
for value change events.
Discussed In:
If you need to use events to pass information inside your components for example to communicate between parents and children make sure you call event.stopPropagation();
and event.preventDefault();
to prevent the event from reaching outside the component.
Also, make sure to add the @internal
JSDoc tag to hide an event from the generated doc or @private
to hide it from both the doc and generated type declarations.
Only attach additional data to your event if that data cannot be determined from the state of the component. This is because events also get a reference to the component that fired the event. For example you do not need to pass anything exposed as a @Prop()
in the event details.
@Listen("calciteCustomEvent") customEventHandler(
event: CustomEvent
) {
console.log(event.target.prop); // event.target is the component that fired the event.
}
<calcite-tab-nav>
is also an example of this. The event.details.tab
item contains the index of the selected tab or the tab name which cannot be easily determined from the state of <calcite-tab-nav>
in some cases so it makes sense to include in the event.
When a component handles events for its own interaction (e.g., moving between list items, closing an open menu), if the event is tied to default browser behavior (e.g., space key scrolling the page), Event.preventDefault()
must be called to avoid mixed behavior.
class SomeInputTypeComponent {
handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
/* clear text/close popover */
event.preventDefault(); // let browser or other components know that the event has been handled
}
// ...
}
}
For composite components or components that support children (either light or shadow DOM), they may need to check if an event has been canceled (Event.defaultPrevented
) before handling it.
class CompositeOrParentComponent {
handleKeyDown(event: KeyboardEvent): void {
if (
event.key === "Escape" &&
!event.defaultPrevented // check if child component has already handled this
) {
/* close */
event.preventDefault(); // let browser or other components know that the event has been handled
}
// ...
}
}
Pointer events should be used in favor of mouse events to maximize device compatibility.
There are a few ways to add event listeners within our components:
-
@Listen
decorator- automatically cleaned up by component lifecycle
- can easily specify different event listener options
- does not provide event type information
- event name is not type checked
- JSX event listener props
- automatically cleaned up by component lifecycle
- cannot specify event listener options (some events may have a matching capture prop)
- provides event type information
- event name is type checked
-
addListener
- not removed by the component lifecycle, so the listener needs to be explicitly removed to prevent memory leaks
- provides total flexibility regarding event listener options
- provides event type information
- event name is not type checked
1 and 2 should be used whenever possible (which one you use will depend on convenience). 3 should only be used whenever 1 and 2 are not possible or ideal.
Private/internal properties should be annotated accordingly to avoid exposing them in the doc and/or API. You can do this by using the @private
/@internal
JSDoc tags.
It is recommended to reflect properties that fit the following criteria:
- are static or will not be updated frequently during the component lifespan (e.g., a number that represents a range min or max would be reflected, but a number that represents a value that will constantly be updated by the user would not)
- value represents non-rich data or booleans/numbers/strings that are not used as content (e.g., a string that represents a mode would be reflected, but a string that represents a placeholder, title or summary would not)
- are public and belong to a public component
- required for internal styling or would make internal styling easier
Doing so will give developers more flexibility when querying the DOM. This is important in framework environments where we can't safely assume components will have their attributes set vs properties.
Due to a bug in Stencil, ref
should be set as the last property in JSX to ensure the node's attributes/properties are up to date.
<div
class={CSS.foo}
// ...
tabIndex={0}
// eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530)
ref={this.storeSomeElementRef}
/>
Components with focusable content, must implement the following pattern:
interface FocusableComponent {
setFocus(focusId?: FocusId): Promise<void>; // focusId should be supported if there is more than one supported focus target
}
type FocusId = string;
Note: Implementations can use the focusElement
helper to handle focusing both native and calcite components.
Examples:
Because most components utilize shadow DOM, there is far less concern over naming collisions in a global CSS namespace. In addition, it's better for file transfer times and easier to write if class names are shorter. For these reasons, full BEM is not necessary. Instead, we can omit the "Block", and use the host instead. Consider the following BEM markup:
<div class="card">
<h3 class="card__title card__title--large">Title</h3>
<p class="card__text">Text</p>
</div>
In a component using shadow DOM, this should instead be written as:
<Host>
<h3 class="title title--large">Title</h3>
<p class="text">Text</p>
</Host>
Notice .card
does not appear anywhere. We would then apply styles to the host element itself:
:host {
// card styles here
}
.title {
}
.title--large {
}
.text {
}
Modifier classes on the "block" (host element) can often be written by reflecting the prop and selecting it directly via an attribute selector:
:host([kind="success"]) {
}
This builds a nice symmetry between the styling and the public API of a component.
- https://github.com/ArcGIS/calcite-components/issues/28
- https://github.com/ArcGIS/calcite-components/pull/24#discussion_r287462934
- https://github.com/ArcGIS/calcite-components/pull/24#issuecomment-495788683
- https://github.com/ArcGIS/calcite-components/pull/24#issuecomment-497962263
If a component needs assets, they should be placed under a assets/<component-name>
subdirectory. For example,
my-component/
assets/
my-component/
asset.json
my-component.e2e.ts
my-component.tsx
my-component.scss
...
The component's metadata should then include the following metadata prop assetsDirs: ["assets"]
.
import { Component, Host, h } from "@stencil/core";
@Component({
tag: "calcite-test",
shadow: true,
assetsDirs: ["assets"],
})
export class MyComponent {
/* ... */
}
Afterwards, any asset path references must use the getAssetPath
utility, using the assets
directory as the root.
const assetPath = getAssetPath(`./assets/my-component/asset.json`);
This is required in order to have a unified assets folder in the distributable.
Stencil has the capability to build and distribute a large variety of outputs based on our needs. You can read more about this in the output targets documentation.
As a best practice we should follow Ionic's configuration and generate a bundle
for each component. Stencil will then generate a loader that will dynamically load the components used on the page.
Note: This is highly likely to change as we move closer to our first release and as Stencil improves their documentation around their specific methods and build processes.
Each root component should have a corresponding bundle entry in stencil.config.ts
.
Many times it is necessary for components to have a id="something"
attribute for things like <label>
and various aria-*
properties. To safely generate a unique id for a component but to also allow a user supplied id
attribute to work follow the following pattern:
import { guid } from "../../utils/guid";
@Component({
tag: "calcite-example",
styleUrl: "example.scss",
shadow: true,
})
export class Example {
// ...
guid: string = `calcite-example-${guid()}`;
render() {
const id = this.el.id || this.guid;
return <Host id={id}></Host>;
}
// ...
}
This will create a unique id attribute like id="calcite-example-51af-0941-54ae-22c14d441beb"
which should have a VERY low collision change since guid()
generates IDs with window.crypto.getRandomValues
. If a user supplies an id
this will respect the users id
.
Stencil provide the capability to render web components on the server and seamlessly hydrate them on the client. This is handled by the dist-hydrate-script
output target in stencil.config.ts
.
This generates a hydrate
directory which exposes renderToString()
(for the server) and hydrateDocument()
for the client.
Since many of the same lifecycle methods are called on the client and server you may need to differentiate any code that relies on browser APIs like so:
import { Build } from "@stencil/core";
if (Build.isBrowser) {
// client side
} else {
// server side
}
Checking if the necessary APIs are present is also acceptable:
const elements = this.el.shadowRoot
? this.el.shadowRoot.querySelector("slot").assignedElements()
: [];
To ensure that all components are compatible for prerendering a prerender build is done as part of npm test
.
Ensure all components clean up their resources.
When using setTimeout()
, make sure that you clear the timeout using clearTimeout()
in cases where the same timeout may be called again before the first timeout has finished or if the handler is no longer needed. For example, the handler may no longer need to be called if the component was disconnected from the DOM.
Example:
menuFocusTimeout: number;
focusMenu(): void => {
clearTimeout(this.menuFocusTimeout);
this.menuFocusTimeout = window.setTimeout(() => focusElement(this.menuEl), 100);
}
Avoid setting z-index ad hoc and instead use a contextual z-index layer from the Tailwind z-index extension. This will ensure proper layering across components.
There are utilities for common workflows in src/utils
.
The globalAttributes
util was specifically made to access the lang
global attribute when set on a Calcite component. However, it can be extended to allow additional global attributes by adding to the allowedGlobalAttributes
array. The util is used in calcite-pagination
, which you can use as a reference.
-
Import the interface and watch/unwatch methods
import { GlobalAttrComponent, watchGlobalAttributes, unwatchGlobalAttributes, } from "../../utils/globalAttributes";
-
Implement the interface
export class ComponentName implements GlobalAttrComponent {
-
Add
globalAttributes
state@State() globalAttributes = {};
-
Add connect/disconnect callbacks
connectedCallback(): void { watchGlobalAttributes(this, ["lang"]); } disconnectedCallback(): void { unwatchGlobalAttributes(this); }
-
Use the state to access
lang
(or another global attribute that may be allowed in the future).const lang = this.globalAttributes["lang"] || document.documentElement.lang || "en";
BigDecimal
is a number util that helps with arbitrary precision arithmetic. The util is adopted from a Stack Overflow answer with some small changes. There are some usage examples in number.spec.ts
.
In order to support certain architectures, parent components might need to handle custom elements that wrap their expected child items within shadow DOM that would prevent discovery when querying the DOM.
For such cases, the following pattern will enable developers to create custom child/item components and have them work seamlessly with parent components.
- Must provide a
customItemSelectors
property to allow querying for custom elements in addition to their expected children. - An interface for the class (used by custom item classes) and element (used by parent component APIs) must be created in the parent's
interfaces.d.ts
file, where the necessary child APIs must be extracted.
type ChildComponentLike = Pick<
Components.CalciteChild,
"required" | "props" | "from" | "parent"
>;
type ChildComponentLikeElement = ChilcComponentLike & HTMLElement;
@Prop() selectedItem: HTMLChildComponentElement | ChildComponentLikeElement;
export class CustomItem implements ChildComponentLike {
private childComponentEl: HTMLChildComponentLikeElement;
@Prop() required: boolean;
@Prop() props: string;
@Prop() from: number;
@Method() async parent(): Promise<string> {
await this.childComponentEl.parent();
}
render(): VNode {
return (
<Host>
<child-component
required={this.required}
props={this.props}
from={this.from}
ref={(el) => (this.childComponentEl = el)}
/>
</Host>
);
}
}
- Must implement the element interface expected by the parent (e.g.,
ChildComponentLike
).
- This pattern should be applied sparingly and on a case-by-case basis.
- We can refine this pattern as we go on, but additional modifications needed to handle the custom items workflow will be considered out-of-scope and thus not supported.
- Until we have documentation covering creating custom elements,
customItemSelectors
must be made internal and anyChildComponentLike
types must be excluded from the doc. - Please refer to https://github.com/Esri/calcite-design-system/pull/7608/ as an example on how this pattern is applied.