@rishab3355/chefora-ui
v1.0.0
Published
Glassmorphic web components for the Chefora recipe finder and meal planner. Framework-agnostic, built with StencilJS.
Maintainers
Readme
@rishab3355/chefora-ui
Glassmorphic web components for Chefora, a recipe finder and meal planner. Built with StencilJS, so they are standard custom elements and work in any framework — or none.
npm install @rishab3355/chefora-uiQuick start
The package exports a single defineCustomElements() that registers every
component. Call it on the client only, before your app hydrates.
import { defineCustomElements } from '@rishab3355/chefora-ui';
defineCustomElements();<chef-recipe-card id="card"></chef-recipe-card>
<script type="module">
// `recipe` is an OBJECT prop, so it must be set as a property.
// An HTML attribute would arrive as the string "[object Object]".
document.getElementById('card').recipe = {
id: 1,
title: 'Burst Tomato & Basil Rigatoni',
imageUrl: 'https://example.com/rigatoni.jpg',
cuisineName: 'Italian',
totalTimeMinutes: 25,
servings: 4,
difficulty: 'Easy',
rating: 4.9
};
document.getElementById('card').addEventListener('chefora-favorite-toggle', (e) => {
console.log(e.detail); // { id: 1, favorite: true }
});
</script>Components
<chef-glass-panel>
The layout primitive — a correctly-styled surface with named holes in it.
| Prop | Type | Default | Notes |
| --------------- | --------------------------------------------- | ----------- | ------------------------------------ |
| variant | 'flat' \| 'blurred' | 'flat' | See the blur budget below. |
| padding | 'none' \| 'compact' \| 'regular' \| 'roomy' | 'regular' | |
| sticky | boolean | false | Sticks to its scroll container. |
| sticky-offset | string | '0px' | Top offset when sticky. |
| as-article | boolean | false | Renders an <article> landmark. |
| label | string | — | Accessible name; needs as-article. |
Slots: header, actions, default, footer. A row you do not fill collapses
itself, so a content-only panel has no empty bordered strip.
Parts: panel, header, content, footer.
<chef-recipe-card>
| Prop | Type | Default | Notes |
| --------------- | ---------------- | ------- | ------------------------------------------------- |
| recipe | RecipeCardData | — | Object — set as a property, not an attribute. |
| favorite | boolean | false | Kept separate from recipe.favorite. |
| loading | boolean | false | Renders a skeleton. |
| compact | boolean | false | Denser layout for rails. |
| hide-favorite | boolean | false | For anonymous visitors. |
Events: chefora-favorite-toggle → { id, favorite } · chefora-card-open → { id, slug }
Slots: badges, actions, default (the title — provided in the light DOM so
it is present in server-rendered HTML).
Parts: card, media, body, title, favorite, plan.
Add show-plan to surface a + Plan quick action, which emits
chefora-plan-add → { id, slug }.
<chef-filter-chip>
| Prop | Type | Default | Notes |
| ----------- | --------- | ------- | -------------------------------------------------- |
| label | string | '' | Visible text, and the fallback accessible name. |
| value | string | — | Identity in event payloads. Falls back to label. |
| selected | boolean | false | Reflected, so light-DOM CSS can target it. |
| removable | boolean | false | Adds a separate remove button. |
| count | number | — | Trailing facet count. 0 renders, not hidden. |
Events: chefora-chip-toggle → { value, selected } · chefora-chip-remove → { value }
⚠ The remove affordance is a second focusable button with its own aria-label, not a glyph
inside the toggle. Inside, it would be both unlabeled and unreachable by keyboard.
<chef-rating-stars>
| Prop | Type | Default | Notes |
| ------------ | -------------------------- | ------- | ------------------------------------- |
| value | number \| string \| null | 0 | Accepts a string — JSON BigDecimal. |
| readonly | boolean | true | Display-only. |
| size | 'sm' \| 'md' \| 'lg' | 'md' | |
| show-value | boolean | false | Renders the number beside the stars. |
Events: chefora-rating-change → { value } (interactive mode only)
Readonly renders role="img" with one label — a screen reader says "4.6 out of 5" rather than
reading ten star glyphs. Interactive renders a real radiogroup, so arrow-key navigation and a
single tab stop come from the platform.
<chef-day-slot>
| Prop | Type | Default | Notes |
| ----------- | ------------------------------------ | ---------- | ---------------------------------------------- |
| day | number | 1 | ISO: 1 = Monday … 7 = Sunday. |
| slot-name | 'BREAKFAST' \| 'LUNCH' \| 'DINNER' | 'DINNER' | ⚠ not slot — that is a global attribute. |
| recipe | RecipeCardData | — | Object prop. Absent ⇒ empty slot. |
| compact | boolean | false | For a dashboard strip. |
| today | boolean | false | Marks the current day. |
Events: chefora-slot-add · chefora-slot-remove · chefora-slot-open, each →
{ day, slotName, recipeId }
A filled slot renders its open and remove actions as siblings: interactive content cannot nest, so a remove button inside a clickable card is invalid HTML and activates unpredictably.
<chef-quantity-stepper>
| Prop | Type | Default | Notes |
| ------------ | --------- | ------------ | ------------------------------ |
| value | number | 1 | Clamped into [min, max]. |
| min max | number | 1 / 99 | |
| label | string | 'Quantity' | Accessible name for the field. |
| show-label | boolean | false | Renders the label visibly too. |
Events: chefora-quantity-change → { value, delta } — never emitted for a no-op at a bound.
Uses type="text" + inputmode="numeric", not type="number": number inputs silently discard
non-numeric text, mutate on scroll, and have unstyleable spinners.
<chef-search-bar>
| Prop | Type | Default | Notes |
| -------------- | --------- | ------- | --------------------------------------------- |
| value | string | '' | |
| placeholder | string | … | Not the accessible name — see label. |
| label | string | … | The real accessible name. |
| loading | boolean | false | Spinner + a polite live-region announcement. |
| filter-count | number | — | Shows a Filters button with a badge when > 0. |
Events: chefora-search-submit · chefora-search-input → { value } · chefora-filters-open
Slots: trailing.
A real <form> around <input type="search">, so Enter-to-submit, the browser's clear affordance
and search history all come from the platform.
<chef-category-tile>
| Prop | Type | Default | Notes |
| ---------- | ------------------ | ------- | -------------------------------------------- |
| category | CategoryTileData | — | Object prop. Absent ⇒ skeleton. |
| href | string | — | Renders a real <a>; omit for a <button>. |
Events: chefora-category-open → { slug, name }
With href it is a link, so middle-click and open-in-new-tab keep working.
<chef-empty-state>
| Prop | Type | Default | Notes |
| ------------ | -------------------- | --------- | ----------------------------------------- |
| variant | 'empty' \| 'error' | 'empty' | error is role="alert" with a retry. |
| heading | string | … | Variant-appropriate default. |
| message | string | — | |
| show-retry | boolean | — | Overrides the variant default either way. |
Events: chefora-retry
Slots: action, illustration, default.
Theming
Every colour, radius, font and spacing value is read as
var(--ch-*, <fallback>), where the fallback is the real Chefora value. So the
components look right out of the box, and custom properties pierce the shadow
boundary — define the tokens anywhere above them and the whole library re-themes
with no ::part plumbing:
:root {
--ch-accent-solid: #cb3a00;
--ch-ground: #faf2e9;
--ch-r-card: 22px;
}For structural changes, use the part hooks: chef-recipe-card::part(card).
Notes for framework consumers
Event names are kebab-case on purpose. Svelte 5 rejects camelCase handler
attributes (onchefFavoriteToggle is invalid syntax), and DOM dispatch is
case-sensitive, so a camelCase event would have no working declarative listener
in either place. In Svelte, attach them with an action:
<chef-recipe-card use:attachEvents={{ 'chefora-favorite-toggle': onToggle }} {recipe}
></chef-recipe-card>Server-side rendering. The package is import-safe on the server — importing it
in Node does not touch HTMLElement, and defineCustomElements() no-ops when
customElements is undefined. Registration still belongs on the client, before
hydration rather than in a mount hook, so the first paint of a hard navigation
does not take the un-upgraded path.
Reserve the box. Before an element upgrades, :host rules do not apply —
the shadow root does not exist yet — so size it from the host app's own CSS or
the grid will reflow when the elements upgrade:
chef-recipe-card {
display: block;
min-height: 320px;
contain: layout paint;
}Hiding un-upgraded elements — carefully. It is tempting to add
chef-recipe-card:not(:defined) { opacity: 0 } to avoid a flash of unstyled
content. Do not do that on its own: if the JavaScript never arrives — a CDN
failure, an offline user, a blocked bundle — :defined never matches, and the
content is hidden permanently. A blank page is much worse than a brief
flash.
Gate it on something that is guaranteed to end. Set a class when registration finishes, and set it on a timer as well, so the hiding always stops:
html:not(.ce-ready) chef-recipe-card:not(:defined) {
/* `visibility`, not `display` — the reserved box must keep its space. */
visibility: hidden;
}<!-- A CLASSIC script, not a module: it has to run even when module loading is
what failed. -->
<script>
setTimeout(() => document.documentElement.classList.add('ce-ready'), 1000);
</script>Promise.all([customElements.whenDefined('chef-recipe-card')]).finally(() =>
document.documentElement.classList.add('ce-ready')
);Bundlers. sideEffects is an array, not false — the stylesheet is an
import-time side effect, and a blanket false lets a bundler tree-shake it away.
Accessibility
Cards are keyboard-operable (Enter and Space), the favourite button carries
aria-pressed and a descriptive label, and every colour pairing meets WCAG 2.2
AA against the design's cream ground. The skeleton shimmer respects
prefers-reduced-motion.
License
MIT © rishab3355
