@toolcase/web-components
v6.2.0
Published
Framework-free HTML5 Web Components (tc-*) with their own from-scratch toolcase styling and a Bootstrap-compatible class/grid API — drop into any stack without React, Vue, or Angular.
Maintainers
Readme
@toolcase/web-components
Framework-free HTML5 Web Components (tc-*) with their own from-scratch toolcase styling — no Bootstrap dependency, but a Bootstrap-compatible class and 12-column grid API. Drop them into plain HTML, React, Vue, Svelte, Angular, or any other stack — no framework required.
📖 Live demos: toolcase.kalevski.dev/web-components
Install
npm install @toolcase/web-componentsPeer dependencies
@toolcase/base 5.xreact >= 18— optional, only for the./reactand./react/componentsentries
Setup
import { register } from '@toolcase/web-components'
import '@toolcase/web-components/style.css'
register() // registers every tc-* element on window.customElementsSSR / Next.js / server-side rendering
All tc-* component classes extend HTMLElement, which does not exist in Node.js. A top-level import '@toolcase/web-components' in server-rendered code will throw ReferenceError: HTMLElement is not defined at module evaluation time, before any idempotency guard can run.
Node.js environments (Next.js SSR, RSC, prerender, Vitest with environment: 'node') automatically resolve the node export condition to a no-op stub that exports a safe register() — so require/import from server code will not throw.
Client-side registration must use a dynamic import inside useEffect or another client-only boundary, never a static top-level import in a component file that is also rendered on the server:
// Next.js app directory (client component) — safe pattern
'use client'
import { useEffect } from 'react'
useEffect(() => {
void import('@toolcase/web-components').then((m) => m.register())
}, [])The stylesheet import is also unsafe at the top level in RSC — put it in a client boundary or in _app.tsx / layout.tsx alongside the dynamic import.
Usage
<tc-button variant="primary">Save</tc-button>
<tc-alert variant="success" dismissible>Saved successfully.</tc-alert>
<tc-modal title="Confirm" id="confirm-modal">Are you sure?</tc-modal>React
tc-* tags work from plain JSX. Import the typings entry once to augment
React.JSX.IntrinsicElements:
import '@toolcase/web-components/react'<tc-form-input
label="Email"
value={email}
error={errors.email}
required
disabled={saving}
ontc-change={(e) => setEmail(e.detail.value)}
/>Three things are worth knowing, because React treats custom elements unlike anything else in JSX:
Event props are hyphenated:
ontc-change, notonTcChange. react-dom turns an unrecognisedon*prop on a custom element intoaddEventListener(key.slice(2), value)with no case conversion, so only the hyphenated form reaches the real event name. React's own synthetic events (onClick,onFocus, …) are unaffected.disabled={false}is safe — react-dom removes the attribute for a booleanfalse, and the property setter does the same on an upgraded element. No|| undefinedguard is needed. The exceptions are the tri-state attributes that default to on (dismissible,handle,blur-behind,autohide), where the string"false"is meaningful; their setters coerce it.Objects, arrays and callbacks must be assigned, not stringified.
useTcdoes that, and diffs so an unchanged value never re-triggers a render:import { useTc } from '@toolcase/web-components/react' const ref = useTc<HTMLElement>({ columns, rows }, { 'tc-sort-change': onSort }) return <tc-advanced-table ref={ref} />
Wrapper components
@toolcase/web-components/react/components ships one typed component per element
— camelCase attributes and handlers, JS-only props assigned for you, and a ref
typed as the element's own class:
import { TcFormInput } from '@toolcase/web-components/react/components'
;<TcFormInput
label="Portions"
type="number"
value={value}
options={options}
onTcChange={(e) => setValue(e.detail.value)}
/>They are types plus a thin factory — importing one pulls in no element
implementation, so register() is still what defines the elements.
Form controls
tc-input, tc-textarea, tc-select, tc-switch, tc-radio-group, and tc-checkbox-group are form-associated custom elements — they participate in <form> submission, reset, and validation via the ElementInternals API.
<form id="demo">
<tc-input name="username" required></tc-input>
<tc-select name="role">
<tc-option value="admin">Admin</tc-option>
<tc-option value="user">User</tc-option>
</tc-select>
<button type="submit">Submit</button>
</form>
<script>
const form = document.getElementById('demo')
form.addEventListener('submit', (e) => {
e.preventDefault()
const data = new FormData(form)
console.log(data.get('username'), data.get('role'))
})
// form.reset() clears all tc-* controls back to their initial values
// form.checkValidity() / form.reportValidity() honour tc-input[required] etc.
</script>Behaviour notes
- The
nameattribute on the outertc-*element is whatFormDatauses. The inner native control intentionally carries nonameto avoid double-submission. form.reset()restores each control to the value it had when first connected to the DOM (the HTML attribute value, or empty).- Validity is mirrored from the inner control:
tc-input[required]makesform.checkValidity()returnfalseuntil a value is entered. tc-checkbox-groupwith multiple selections uses aFormDataobject internally, sonew FormData(form).getAll('fieldname')returns the array of checked values.tc-radio-groupinner radio buttons use an internal name for native grouping; the user-facingnameattribute ontc-radio-groupis forwarded to the form entry viaElementInternals.- Browser support:
ElementInternals/formAssociatedrequires Chrome 77+, Firefox 98+, Safari 16.4+. In older browsers these controls degrade gracefully — they render correctly but their values are not included inFormDataand form reset/validation do not apply to them.
