-
Notifications
You must be signed in to change notification settings - Fork 92
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.
- Google Web Component Best Practices
- Custom Element Conformance - W3C Editor's Draft
- Internal Tooling and Guidelines for Building Components
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 supporttextorpositionproperties 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
localStorageif 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 namefor example thechangeevent on<calcite-tabs>should be namedcalciteTabsChange. - Always prefix event names with
calciteand 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
calciteButtonClickevent because a standardclickevent will still be fired from the element. - For consistency, use
calcite<ComponentName>Changefor 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:
-
@Listendecorator- automatically cleaned up by component lifecycle
- 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.
Public interaction events communicate state changes caused by user interaction.
Setting a component property or calling a programmatic API must update component state, but should not emit public interaction events unless that API explicitly documents an event contract.
A single user action can cause multiple observable state changes. Emit each public event whose corresponding state changed as part of that action.
Do not emit related events when the action does not change the state represented by that event.
For example, closing the selected tab closes that tab and changes selection. Emit the close event and the tab-selection events. Closing an unselected tab closes that tab but leaves selection unchanged. Emit the close event, but not selection events.
Lifecycle events that signal the start or completion of an asynchronous component transition may emit for both user-initiated and programmatic changes.
For example, consumers may rely on before-open, open, before-close, and close events to coordinate work with an open or close transition.
Internal events may be emitted as implementation details to coordinate component parts. They are not a public event contract.
For example, calcite-tab-title emits calciteInternalTabsActivate to coordinate selection with calcite-tab-nav. The internal event may be emitted for programmatic activation because the components still need to synchronize state. The public calciteTabsActivate event emits only for user-triggered activation.
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.
Property values should be meaningful. Avoid using values such as "default" for default values. Instead, use a more descriptive value.
For instance: placement: "start" | "end" = "start"; is preferred to placement: "default" | "end" = "default";
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.
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
To enhance maintainability and facilitate easier refactoring, it's beneficial to use a CSS lookup object for storing class names instead of hardcoding them within components. This approach not only helps prevent errors, such as typos in class names but also simplifies global updates to styling by centralizing class names in one location. Additionally, it proves useful in testing scenarios involving shadow DOM selectors.
Here’s how you can implement a CSS lookup object:
// resources.ts
export const CSS = {
card: "card",
title: "title",
titleLarge: "title--large",
text: "text",
};Usage in a component:
<div class={CSS.card}>
<h3 class={{ [CSS.title]: true, [CSS.titleLarge]: this.largeTitle }}>Title</h3>
<p class={CSS.text}>Text</p>
</div>For dynamic class generation, useful for mutually exclusive classes (e.g., scale), the object can also include functions:
// resources.ts
export const CSS = {
card: "card",
title: "title",
titleLarge: "title--large",
text: "text",
titleScale: (scale: Scale) => `title--scale-${scale}` as const,
};Using dynamic classes:
<div class={CSS.card}>
<h3
class={{
[CSS.title]: true,
[CSS.titleLarge]: this.largeTitle,
[CSS.titleScale(this.scale)]: true,
}}
>
Title
</h3>
<p class={CSS.text}>Text</p>
</div>Note: Ensure that class-generating functions are strongly typed to avoid runtime errors.
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.
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
globalAttributesstate@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
customItemSelectorsproperty 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.tsfile, 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,
customItemSelectorsmust be made internal and anyChildComponentLiketypes 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.
- Monorepo
- Global Config
- Coding Conventions
- Testing Conventions
- Patching Packages
- Deprecation Guidance
- Continuous Integration
- GitHub Secrets
- Troubleshoot Build Errors
- Tips and Tricks
- NPM FAQ
- Issue Verification
- Wiki Conventions
- Styling Conventions
- CSS Transitions
- Restricting User Input
- ItemHidden Property
- What Happens When You Scale Vector-Based Icons
- Internationalization
- Introduction
- Getting Started
- Naming Schema
- Semantic Tokens
- Component Tokens
- Web Platform
- Build
- Documentation
- Testing
- Contributing