@ssejal8/recipe-ui-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +10 -0
- package/.npmr +1 -0
- package/README.md +354 -0
- package/apps/recipe-web/package.json +31 -0
- package/apps/recipe-web/src/app.css +418 -0
- package/apps/recipe-web/src/app.d.ts +32 -0
- package/apps/recipe-web/src/app.html +25 -0
- package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
- package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
- package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
- package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
- package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
- package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
- package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
- package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
- package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
- package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
- package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
- package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
- package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
- package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
- package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
- package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
- package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
- package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
- package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
- package/apps/recipe-web/src/routes/+error.svelte +36 -0
- package/apps/recipe-web/src/routes/+layout.svelte +62 -0
- package/apps/recipe-web/src/routes/+page.svelte +352 -0
- package/apps/recipe-web/src/routes/+page.ts +107 -0
- package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
- package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
- package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
- package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
- package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
- package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
- package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
- package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
- package/apps/recipe-web/static/favicon.svg +8 -0
- package/apps/recipe-web/static/robots.txt +2 -0
- package/apps/recipe-web/svelte.config.js +35 -0
- package/apps/recipe-web/tsconfig.json +14 -0
- package/apps/recipe-web/vite.config.ts +23 -0
- package/package.json +29 -0
- package/packages/recipe-kit-stencil/LICENSE +21 -0
- package/packages/recipe-kit-stencil/README.md +185 -0
- package/packages/recipe-kit-stencil/package-lock.json +4772 -0
- package/packages/recipe-kit-stencil/package.json +91 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
- package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
- package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
- package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
- package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
- package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
- package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
- package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
- package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
- package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
- package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
- package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
- package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
- package/packages/recipe-kit-stencil/src/index.html +259 -0
- package/packages/recipe-kit-stencil/src/index.ts +6 -0
- package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
- package/packages/recipe-kit-stencil/tsconfig.json +24 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
font-family: var(--rk-font);
|
|
3
|
+
font-size: var(--rk-font-size, 15px);
|
|
4
|
+
color: var(--rk-ink, #211a16);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.backdrop {
|
|
8
|
+
position: fixed;
|
|
9
|
+
inset: 0;
|
|
10
|
+
z-index: var(--rk-z-modal, 1000);
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
background: rgba(24, 17, 13, 0.5);
|
|
16
|
+
backdrop-filter: blur(3px);
|
|
17
|
+
animation: rk-fade-in 140ms ease-out;
|
|
18
|
+
overflow-y: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.panel {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
width: 100%;
|
|
25
|
+
max-height: calc(100vh - 40px);
|
|
26
|
+
background: var(--rk-surface, #fff);
|
|
27
|
+
border-radius: var(--rk-radius-lg, 20px);
|
|
28
|
+
box-shadow: var(--rk-shadow-lg);
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
animation: rk-pop-in 180ms cubic-bezier(0.2, 0, 0.2, 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.panel:focus {
|
|
34
|
+
outline: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.size-sm {
|
|
38
|
+
max-width: 420px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.size-md {
|
|
42
|
+
max-width: 620px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.size-lg {
|
|
46
|
+
max-width: 880px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.header {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: flex-start;
|
|
52
|
+
gap: 16px;
|
|
53
|
+
padding: 22px 24px 16px;
|
|
54
|
+
border-bottom: 1px solid var(--rk-border, #ecdfd4);
|
|
55
|
+
flex: 0 0 auto;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.titles {
|
|
59
|
+
flex: 1 1 auto;
|
|
60
|
+
min-width: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.heading {
|
|
64
|
+
margin: 0;
|
|
65
|
+
font-family: var(--rk-font-display, serif);
|
|
66
|
+
font-size: 1.32em;
|
|
67
|
+
font-weight: 650;
|
|
68
|
+
letter-spacing: -0.015em;
|
|
69
|
+
line-height: 1.25;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.subheading {
|
|
73
|
+
margin: 5px 0 0;
|
|
74
|
+
font-size: 0.9em;
|
|
75
|
+
color: var(--rk-muted, #857166);
|
|
76
|
+
line-height: 1.5;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.close {
|
|
80
|
+
flex: 0 0 auto;
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
width: 34px;
|
|
85
|
+
height: 34px;
|
|
86
|
+
padding: 0;
|
|
87
|
+
border: 0;
|
|
88
|
+
border-radius: 50%;
|
|
89
|
+
background: var(--rk-surface-2, #fdf5ef);
|
|
90
|
+
color: var(--rk-ink-2, #52443c);
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
transition: background var(--rk-transition), color var(--rk-transition);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.close:hover {
|
|
96
|
+
background: var(--rk-danger-soft, #fdeae1);
|
|
97
|
+
color: var(--rk-danger, #c2410c);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.close:focus-visible {
|
|
101
|
+
outline: none;
|
|
102
|
+
box-shadow: var(--rk-focus-ring);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.close svg {
|
|
106
|
+
width: 16px;
|
|
107
|
+
height: 16px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.body {
|
|
111
|
+
padding: 20px 24px;
|
|
112
|
+
overflow-y: auto;
|
|
113
|
+
flex: 1 1 auto;
|
|
114
|
+
line-height: 1.6;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.footer {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
justify-content: flex-end;
|
|
121
|
+
gap: 10px;
|
|
122
|
+
padding: 16px 24px;
|
|
123
|
+
border-top: 1px solid var(--rk-border, #ecdfd4);
|
|
124
|
+
background: var(--rk-surface-2, #fdf5ef);
|
|
125
|
+
flex: 0 0 auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.footer.is-empty {
|
|
129
|
+
display: none;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@keyframes rk-fade-in {
|
|
133
|
+
from {
|
|
134
|
+
opacity: 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@keyframes rk-pop-in {
|
|
139
|
+
from {
|
|
140
|
+
opacity: 0;
|
|
141
|
+
transform: translateY(12px) scale(0.98);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@media (max-width: 560px) {
|
|
146
|
+
.backdrop {
|
|
147
|
+
padding: 0;
|
|
148
|
+
align-items: flex-end;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.panel {
|
|
152
|
+
border-radius: var(--rk-radius-lg, 20px) var(--rk-radius-lg, 20px) 0 0;
|
|
153
|
+
max-height: 92vh;
|
|
154
|
+
max-width: none;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@media (prefers-reduced-motion: reduce) {
|
|
159
|
+
.backdrop,
|
|
160
|
+
.panel {
|
|
161
|
+
animation: none;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Component, Element, Event, EventEmitter, h, Host, Prop, State, Watch } from '@stencil/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An accessible dialog with a backdrop, Escape handling and focus restoration.
|
|
5
|
+
*
|
|
6
|
+
* The component is presentational: it emits `rkModalClose` and lets the host
|
|
7
|
+
* application own the `open` state.
|
|
8
|
+
*
|
|
9
|
+
* @slot - Dialog body.
|
|
10
|
+
* @slot header - Replaces the default heading row entirely.
|
|
11
|
+
* @slot footer - Action row pinned to the bottom of the dialog.
|
|
12
|
+
*/
|
|
13
|
+
@Component({
|
|
14
|
+
tag: 'rk-modal',
|
|
15
|
+
styleUrl: 'rk-modal.css',
|
|
16
|
+
shadow: true,
|
|
17
|
+
})
|
|
18
|
+
export class RkModal {
|
|
19
|
+
@Element() host!: HTMLElement;
|
|
20
|
+
|
|
21
|
+
/** Controls visibility. */
|
|
22
|
+
@Prop() open = false;
|
|
23
|
+
|
|
24
|
+
/** Heading text for the default header. */
|
|
25
|
+
@Prop() heading = '';
|
|
26
|
+
|
|
27
|
+
/** Optional supporting line under the heading. */
|
|
28
|
+
@Prop() subheading?: string;
|
|
29
|
+
|
|
30
|
+
/** Dialog width preset. */
|
|
31
|
+
@Prop() size: 'sm' | 'md' | 'lg' = 'md';
|
|
32
|
+
|
|
33
|
+
/** Set to false to require an explicit action to dismiss. */
|
|
34
|
+
@Prop() dismissible = true;
|
|
35
|
+
|
|
36
|
+
/** Emitted when the user requests dismissal (X, backdrop or Escape). */
|
|
37
|
+
@Event({ eventName: 'rkModalClose' }) rkModalClose!: EventEmitter<void>;
|
|
38
|
+
|
|
39
|
+
@State() private hasFooter = false;
|
|
40
|
+
|
|
41
|
+
private previouslyFocused?: HTMLElement;
|
|
42
|
+
private panelEl?: HTMLElement;
|
|
43
|
+
|
|
44
|
+
/** Collapses the footer region when no action row was projected into it. */
|
|
45
|
+
private syncFooterSlot = () => {
|
|
46
|
+
this.hasFooter = Array.from(this.host.children).some(node => node.getAttribute('slot') === 'footer');
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
@Watch('open')
|
|
50
|
+
protected onOpenChange(isOpen: boolean, wasOpen: boolean) {
|
|
51
|
+
if (isOpen === wasOpen) return;
|
|
52
|
+
|
|
53
|
+
if (isOpen) {
|
|
54
|
+
this.previouslyFocused = document.activeElement as HTMLElement;
|
|
55
|
+
document.addEventListener('keydown', this.handleGlobalKeyDown);
|
|
56
|
+
this.lockScroll(true);
|
|
57
|
+
// Wait a frame so the panel exists before moving focus into it.
|
|
58
|
+
requestAnimationFrame(() => this.focusFirst());
|
|
59
|
+
} else {
|
|
60
|
+
document.removeEventListener('keydown', this.handleGlobalKeyDown);
|
|
61
|
+
this.lockScroll(false);
|
|
62
|
+
this.previouslyFocused?.focus?.();
|
|
63
|
+
this.previouslyFocused = undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
componentWillLoad() {
|
|
68
|
+
this.syncFooterSlot();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
componentDidLoad() {
|
|
72
|
+
if (this.open) this.onOpenChange(true, false);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
disconnectedCallback() {
|
|
76
|
+
document.removeEventListener('keydown', this.handleGlobalKeyDown);
|
|
77
|
+
this.lockScroll(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private lockScroll(locked: boolean) {
|
|
81
|
+
if (typeof document === 'undefined') return;
|
|
82
|
+
document.documentElement.style.overflow = locked ? 'hidden' : '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private focusableElements(): HTMLElement[] {
|
|
86
|
+
if (!this.panelEl) return [];
|
|
87
|
+
const selector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
88
|
+
// Slotted content lives in the light DOM, so both trees have to be queried.
|
|
89
|
+
const inShadow = Array.from(this.panelEl.querySelectorAll<HTMLElement>(selector));
|
|
90
|
+
const inLight = Array.from(this.host.querySelectorAll<HTMLElement>(selector));
|
|
91
|
+
return [...inShadow, ...inLight].filter(el => el.offsetParent !== null || el === document.activeElement);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private focusFirst() {
|
|
95
|
+
const [first] = this.focusableElements();
|
|
96
|
+
(first ?? this.panelEl)?.focus?.();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private handleGlobalKeyDown = (event: KeyboardEvent) => {
|
|
100
|
+
if (!this.open) return;
|
|
101
|
+
|
|
102
|
+
if (event.key === 'Escape' && this.dismissible) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
this.rkModalClose.emit();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (event.key === 'Tab') {
|
|
109
|
+
const focusable = this.focusableElements();
|
|
110
|
+
if (focusable.length === 0) return;
|
|
111
|
+
const first = focusable[0];
|
|
112
|
+
const last = focusable[focusable.length - 1];
|
|
113
|
+
const active = (this.host.shadowRoot?.activeElement ?? document.activeElement) as HTMLElement;
|
|
114
|
+
|
|
115
|
+
if (event.shiftKey && active === first) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
last.focus();
|
|
118
|
+
} else if (!event.shiftKey && active === last) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
first.focus();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
private handleBackdrop = (event: MouseEvent) => {
|
|
126
|
+
if (!this.dismissible) return;
|
|
127
|
+
if (event.target === event.currentTarget) this.rkModalClose.emit();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
render() {
|
|
131
|
+
if (!this.open) return <Host aria-hidden="true" />;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Host>
|
|
135
|
+
<div class="backdrop" part="backdrop" onClick={this.handleBackdrop}>
|
|
136
|
+
<div
|
|
137
|
+
class={{ panel: true, [`size-${this.size}`]: true }}
|
|
138
|
+
part="panel"
|
|
139
|
+
role="dialog"
|
|
140
|
+
aria-modal="true"
|
|
141
|
+
aria-label={this.heading || 'Dialog'}
|
|
142
|
+
tabindex={-1}
|
|
143
|
+
ref={el => (this.panelEl = el)}
|
|
144
|
+
>
|
|
145
|
+
<header class="header" part="header">
|
|
146
|
+
<slot name="header">
|
|
147
|
+
<div class="titles">
|
|
148
|
+
<h2 class="heading" part="heading">
|
|
149
|
+
{this.heading}
|
|
150
|
+
</h2>
|
|
151
|
+
{this.subheading && <p class="subheading">{this.subheading}</p>}
|
|
152
|
+
</div>
|
|
153
|
+
</slot>
|
|
154
|
+
|
|
155
|
+
{this.dismissible && (
|
|
156
|
+
<button class="close" part="close" type="button" aria-label="Close dialog" onClick={() => this.rkModalClose.emit()}>
|
|
157
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
158
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
159
|
+
</svg>
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
162
|
+
</header>
|
|
163
|
+
|
|
164
|
+
<div class="body" part="body">
|
|
165
|
+
<slot />
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<footer class={{ footer: true, 'is-empty': !this.hasFooter }} part="footer">
|
|
169
|
+
<slot name="footer" onSlotchange={this.syncFooterSlot} />
|
|
170
|
+
</footer>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</Host>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# rk-rating
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
<!-- Auto Generated Below -->
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
A star rating that works as a read-only display or an interactive input.
|
|
11
|
+
|
|
12
|
+
## Properties
|
|
13
|
+
|
|
14
|
+
| Property | Attribute | Description | Type | Default |
|
|
15
|
+
| ----------- | ------------ | ------------------------------------------------------- | --------- | ----------- |
|
|
16
|
+
| `count` | `count` | Optional count of ratings, rendered as "(24)". | `number` | `undefined` |
|
|
17
|
+
| `label` | `label` | Accessible label for the group. | `string` | `'Rating'` |
|
|
18
|
+
| `max` | `max` | Number of stars. | `number` | `5` |
|
|
19
|
+
| `readonly` | `readonly` | When true the stars are display-only and not focusable. | `boolean` | `false` |
|
|
20
|
+
| `showValue` | `show-value` | Renders the numeric value next to the stars. | `boolean` | `false` |
|
|
21
|
+
| `size` | `size` | Star size in pixels. | `number` | `18` |
|
|
22
|
+
| `value` | `value` | Current rating. Supports halves when `readonly`. | `number` | `0` |
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Events
|
|
26
|
+
|
|
27
|
+
| Event | Description | Type |
|
|
28
|
+
| ---------------- | ----------------------------------------- | --------------------- |
|
|
29
|
+
| `rkRatingChange` | Emitted when the user picks a new rating. | `CustomEvent<number>` |
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Shadow Parts
|
|
33
|
+
|
|
34
|
+
| Part | Description |
|
|
35
|
+
| --------- | ----------- |
|
|
36
|
+
| `"stars"` | |
|
|
37
|
+
| `"value"` | |
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
----------------------------------------------
|
|
41
|
+
|
|
42
|
+
*Built with [StencilJS](https://stenciljs.com/)*
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
font-family: var(--rk-font);
|
|
4
|
+
--rk-star-size: 18px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.stars {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: 2px;
|
|
11
|
+
border-radius: var(--rk-radius-sm, 8px);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.stars:not(.is-readonly) {
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.stars:focus-visible {
|
|
19
|
+
outline: none;
|
|
20
|
+
box-shadow: var(--rk-focus-ring);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.star-wrap {
|
|
24
|
+
display: inline-flex;
|
|
25
|
+
padding: 1px;
|
|
26
|
+
transition: transform var(--rk-transition);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.stars:not(.is-readonly) .star-wrap:hover {
|
|
30
|
+
transform: scale(1.15);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.star {
|
|
34
|
+
width: var(--rk-star-size);
|
|
35
|
+
height: var(--rk-star-size);
|
|
36
|
+
display: block;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.star-bg {
|
|
40
|
+
fill: none;
|
|
41
|
+
stroke: var(--rk-border-strong, #d9c6b8);
|
|
42
|
+
stroke-width: 1.6;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.star-fg {
|
|
46
|
+
fill: var(--rk-warning, #a16207);
|
|
47
|
+
stroke: var(--rk-warning, #a16207);
|
|
48
|
+
stroke-width: 1.2;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.value {
|
|
52
|
+
margin-left: 7px;
|
|
53
|
+
font-size: 0.85em;
|
|
54
|
+
font-weight: 600;
|
|
55
|
+
color: var(--rk-ink-2, #52443c);
|
|
56
|
+
font-variant-numeric: tabular-nums;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.count {
|
|
60
|
+
margin-left: 4px;
|
|
61
|
+
font-weight: 500;
|
|
62
|
+
color: var(--rk-muted, #857166);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@media (prefers-reduced-motion: reduce) {
|
|
66
|
+
.star-wrap,
|
|
67
|
+
.stars:not(.is-readonly) .star-wrap:hover {
|
|
68
|
+
transition: none;
|
|
69
|
+
transform: none;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Component, Event, EventEmitter, h, Host, Prop, State } from '@stencil/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A star rating that works as a read-only display or an interactive input.
|
|
5
|
+
*/
|
|
6
|
+
@Component({
|
|
7
|
+
tag: 'rk-rating',
|
|
8
|
+
styleUrl: 'rk-rating.css',
|
|
9
|
+
shadow: true,
|
|
10
|
+
})
|
|
11
|
+
export class RkRating {
|
|
12
|
+
/** Current rating. Supports halves when `readonly`. */
|
|
13
|
+
@Prop({ mutable: true }) value = 0;
|
|
14
|
+
|
|
15
|
+
/** Number of stars. */
|
|
16
|
+
@Prop() max = 5;
|
|
17
|
+
|
|
18
|
+
/** When true the stars are display-only and not focusable. */
|
|
19
|
+
@Prop() readonly = false;
|
|
20
|
+
|
|
21
|
+
/** Star size in pixels. */
|
|
22
|
+
@Prop() size = 18;
|
|
23
|
+
|
|
24
|
+
/** Accessible label for the group. */
|
|
25
|
+
@Prop() label = 'Rating';
|
|
26
|
+
|
|
27
|
+
/** Renders the numeric value next to the stars. */
|
|
28
|
+
@Prop() showValue = false;
|
|
29
|
+
|
|
30
|
+
/** Optional count of ratings, rendered as "(24)". */
|
|
31
|
+
@Prop() count?: number;
|
|
32
|
+
|
|
33
|
+
/** Emitted when the user picks a new rating. */
|
|
34
|
+
@Event({ eventName: 'rkRatingChange' }) rkRatingChange!: EventEmitter<number>;
|
|
35
|
+
|
|
36
|
+
@State() private hovered = 0;
|
|
37
|
+
|
|
38
|
+
private select(next: number) {
|
|
39
|
+
if (this.readonly) return;
|
|
40
|
+
// Clicking the active star clears the rating, which is the usual affordance.
|
|
41
|
+
const resolved = next === this.value ? 0 : next;
|
|
42
|
+
this.value = resolved;
|
|
43
|
+
this.rkRatingChange.emit(resolved);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private handleKeyDown = (event: KeyboardEvent) => {
|
|
47
|
+
if (this.readonly) return;
|
|
48
|
+
const step = event.key === 'ArrowRight' || event.key === 'ArrowUp' ? 1 : event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 0;
|
|
49
|
+
if (step === 0) return;
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
const next = Math.min(this.max, Math.max(0, Math.round(this.value) + step));
|
|
52
|
+
this.value = next;
|
|
53
|
+
this.rkRatingChange.emit(next);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
render() {
|
|
57
|
+
const active = this.hovered || this.value;
|
|
58
|
+
const stars = Array.from({ length: Math.max(1, Math.round(this.max)) }, (_, index) => index + 1);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Host style={{ '--rk-star-size': `${this.size}px` }}>
|
|
62
|
+
<div
|
|
63
|
+
class={{ stars: true, 'is-readonly': this.readonly }}
|
|
64
|
+
part="stars"
|
|
65
|
+
role={this.readonly ? 'img' : 'slider'}
|
|
66
|
+
aria-label={`${this.label}: ${this.value} out of ${this.max}`}
|
|
67
|
+
aria-valuenow={this.readonly ? null : String(this.value)}
|
|
68
|
+
aria-valuemin={this.readonly ? null : '0'}
|
|
69
|
+
aria-valuemax={this.readonly ? null : String(this.max)}
|
|
70
|
+
tabindex={this.readonly ? null : 0}
|
|
71
|
+
onKeyDown={this.handleKeyDown}
|
|
72
|
+
onMouseLeave={() => (this.hovered = 0)}
|
|
73
|
+
>
|
|
74
|
+
{stars.map(star => {
|
|
75
|
+
// Fractional fill only matters in read-only display mode.
|
|
76
|
+
const fill = this.readonly ? Math.min(1, Math.max(0, this.value - (star - 1))) : star <= active ? 1 : 0;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<span
|
|
80
|
+
class="star-wrap"
|
|
81
|
+
key={star}
|
|
82
|
+
onMouseEnter={() => !this.readonly && (this.hovered = star)}
|
|
83
|
+
onClick={() => this.select(star)}
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
>
|
|
86
|
+
<svg class="star" viewBox="0 0 24 24">
|
|
87
|
+
<path
|
|
88
|
+
class="star-bg"
|
|
89
|
+
d="M12 2.6l2.86 5.98 6.54.87-4.8 4.5 1.2 6.45L12 17.3l-5.8 3.1 1.2-6.45-4.8-4.5 6.54-.87z"
|
|
90
|
+
/>
|
|
91
|
+
<clipPath id={`clip-${star}`}>
|
|
92
|
+
<rect x="0" y="0" width={24 * fill} height="24" />
|
|
93
|
+
</clipPath>
|
|
94
|
+
<path
|
|
95
|
+
class="star-fg"
|
|
96
|
+
clip-path={`url(#clip-${star})`}
|
|
97
|
+
d="M12 2.6l2.86 5.98 6.54.87-4.8 4.5 1.2 6.45L12 17.3l-5.8 3.1 1.2-6.45-4.8-4.5 6.54-.87z"
|
|
98
|
+
/>
|
|
99
|
+
</svg>
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
|
|
104
|
+
{this.showValue && (
|
|
105
|
+
<span class="value" part="value">
|
|
106
|
+
{this.value ? this.value.toFixed(1) : '—'}
|
|
107
|
+
{typeof this.count === 'number' && <span class="count">({this.count})</span>}
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</Host>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# rk-recipe-card
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
<!-- Auto Generated Below -->
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The primary recipe tile used across discovery, favorites and search results.
|
|
11
|
+
|
|
12
|
+
All data arrives as individual primitive properties rather than one object
|
|
13
|
+
prop, so the component works with plain HTML attributes in any framework.
|
|
14
|
+
|
|
15
|
+
## Properties
|
|
16
|
+
|
|
17
|
+
| Property | Attribute | Description | Type | Default |
|
|
18
|
+
| ----------------------- | --------------- | ------------------------------------------------------------------------- | ------------------- | ----------- |
|
|
19
|
+
| `area` | `area` | Cuisine / region, e.g. "Italian". | `string` | `undefined` |
|
|
20
|
+
| `category` | `category` | Category, e.g. "Dessert". | `string` | `undefined` |
|
|
21
|
+
| `compact` | `compact` | Renders a compact horizontal layout, for dense lists and the planner. | `boolean` | `false` |
|
|
22
|
+
| `favorite` | `favorite` | Whether this recipe is currently favorited. | `boolean` | `false` |
|
|
23
|
+
| `hideFavorite` | `hide-favorite` | Hides the favorite button entirely (e.g. inside the planner). | `boolean` | `false` |
|
|
24
|
+
| `href` | `href` | Real href used for accessibility, SEO and middle-click / open-in-new-tab. | `string` | `undefined` |
|
|
25
|
+
| `image` | `image` | Thumbnail URL. | `string` | `undefined` |
|
|
26
|
+
| `minutes` | `minutes` | Total time in minutes. Hidden when omitted. | `number` | `undefined` |
|
|
27
|
+
| `name` | `name` | Recipe title. | `string` | `''` |
|
|
28
|
+
| `origin` | `origin` | `custom` recipes are user-authored and get an "Owned" badge. | `"api" \| "custom"` | `'api'` |
|
|
29
|
+
| `recipeId` _(required)_ | `recipe-id` | Stable identifier echoed back in every event payload. | `string` | `undefined` |
|
|
30
|
+
| `servings` | `servings` | Number of servings. Hidden when omitted. | `number` | `undefined` |
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## Events
|
|
34
|
+
|
|
35
|
+
| Event | Description | Type |
|
|
36
|
+
| ------------------ | -------------------------------------------------------------------- | --------------------------------------- |
|
|
37
|
+
| `rkCardSelect` | Emitted on primary activation. The host app performs the navigation. | `CustomEvent<RkRecipeCardSelectDetail>` |
|
|
38
|
+
| `rkFavoriteToggle` | Emitted when the favorite button is pressed. | `CustomEvent<RkFavoriteToggleDetail>` |
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Slots
|
|
42
|
+
|
|
43
|
+
| Slot | Description |
|
|
44
|
+
| ----------- | ---------------------------------------------------------------- |
|
|
45
|
+
| | Extra metadata rendered under the title (badges, tags, ratings). |
|
|
46
|
+
| `"actions"` | Buttons rendered in the card footer (e.g. "Add to plan"). |
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Shadow Parts
|
|
50
|
+
|
|
51
|
+
| Part | Description |
|
|
52
|
+
| ------------------- | ----------- |
|
|
53
|
+
| `"body"` | |
|
|
54
|
+
| `"card"` | |
|
|
55
|
+
| `"extra"` | |
|
|
56
|
+
| `"favorite-button"` | |
|
|
57
|
+
| `"footer"` | |
|
|
58
|
+
| `"media"` | |
|
|
59
|
+
| `"meta"` | |
|
|
60
|
+
| `"owned-badge"` | |
|
|
61
|
+
| `"title"` | |
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
----------------------------------------------
|
|
65
|
+
|
|
66
|
+
*Built with [StencilJS](https://stenciljs.com/)*
|