@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.
Files changed (87) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.npmr +1 -0
  3. package/README.md +354 -0
  4. package/apps/recipe-web/package.json +31 -0
  5. package/apps/recipe-web/src/app.css +418 -0
  6. package/apps/recipe-web/src/app.d.ts +32 -0
  7. package/apps/recipe-web/src/app.html +25 -0
  8. package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
  9. package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
  10. package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
  11. package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
  12. package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
  13. package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
  14. package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
  15. package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
  16. package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
  17. package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
  18. package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
  19. package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
  20. package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
  21. package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
  22. package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
  23. package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
  24. package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
  25. package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
  26. package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
  27. package/apps/recipe-web/src/routes/+error.svelte +36 -0
  28. package/apps/recipe-web/src/routes/+layout.svelte +62 -0
  29. package/apps/recipe-web/src/routes/+page.svelte +352 -0
  30. package/apps/recipe-web/src/routes/+page.ts +107 -0
  31. package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
  32. package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
  33. package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
  34. package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
  35. package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
  36. package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
  37. package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
  38. package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
  39. package/apps/recipe-web/static/favicon.svg +8 -0
  40. package/apps/recipe-web/static/robots.txt +2 -0
  41. package/apps/recipe-web/svelte.config.js +35 -0
  42. package/apps/recipe-web/tsconfig.json +14 -0
  43. package/apps/recipe-web/vite.config.ts +23 -0
  44. package/package.json +29 -0
  45. package/packages/recipe-kit-stencil/LICENSE +21 -0
  46. package/packages/recipe-kit-stencil/README.md +185 -0
  47. package/packages/recipe-kit-stencil/package-lock.json +4772 -0
  48. package/packages/recipe-kit-stencil/package.json +91 -0
  49. package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
  50. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
  51. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
  52. package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
  53. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
  54. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
  55. package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
  56. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
  57. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
  58. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
  59. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
  60. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
  61. package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
  62. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
  63. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
  64. package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
  65. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
  66. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
  67. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
  68. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
  69. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
  70. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
  71. package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
  72. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
  73. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
  74. package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
  75. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
  76. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
  77. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
  78. package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
  79. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
  80. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
  81. package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
  82. package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
  83. package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
  84. package/packages/recipe-kit-stencil/src/index.html +259 -0
  85. package/packages/recipe-kit-stencil/src/index.ts +6 -0
  86. package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
  87. package/packages/recipe-kit-stencil/tsconfig.json +24 -0
@@ -0,0 +1,141 @@
1
+ :host {
2
+ display: block;
3
+ font-family: var(--rk-font);
4
+ font-size: var(--rk-font-size, 15px);
5
+ color: var(--rk-ink, #211a16);
6
+ }
7
+
8
+ .shell {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 10px;
12
+ padding: 0 10px 0 14px;
13
+ min-height: 52px;
14
+ background: var(--rk-surface, #fff);
15
+ border: 1px solid var(--rk-border, #ecdfd4);
16
+ border-radius: var(--rk-radius-pill, 999px);
17
+ box-shadow: var(--rk-shadow-sm);
18
+ transition: border-color var(--rk-transition), box-shadow var(--rk-transition);
19
+ }
20
+
21
+ .shell.is-focused {
22
+ border-color: var(--rk-primary, #d94f1b);
23
+ box-shadow: var(--rk-focus-ring);
24
+ }
25
+
26
+ .shell.is-disabled {
27
+ opacity: 0.6;
28
+ cursor: not-allowed;
29
+ }
30
+
31
+ .icon {
32
+ display: inline-flex;
33
+ flex: 0 0 auto;
34
+ color: var(--rk-muted, #857166);
35
+ }
36
+
37
+ .icon svg {
38
+ width: 20px;
39
+ height: 20px;
40
+ }
41
+
42
+ .input {
43
+ flex: 1 1 auto;
44
+ min-width: 0;
45
+ border: 0;
46
+ outline: none;
47
+ background: transparent;
48
+ font: inherit;
49
+ color: inherit;
50
+ padding: 14px 0;
51
+ appearance: none;
52
+ }
53
+
54
+ .input::placeholder {
55
+ color: var(--rk-muted, #857166);
56
+ opacity: 0.9;
57
+ }
58
+
59
+ /* Hide the native WebKit clear affordance — we render our own. */
60
+ .input::-webkit-search-cancel-button,
61
+ .input::-webkit-search-decoration {
62
+ appearance: none;
63
+ display: none;
64
+ }
65
+
66
+ .clear {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ flex: 0 0 auto;
71
+ width: 30px;
72
+ height: 30px;
73
+ padding: 0;
74
+ border: 0;
75
+ border-radius: 50%;
76
+ background: var(--rk-surface-2, #fdf5ef);
77
+ color: var(--rk-ink-2, #52443c);
78
+ cursor: pointer;
79
+ transition: background var(--rk-transition), color var(--rk-transition);
80
+ }
81
+
82
+ .clear:hover {
83
+ background: var(--rk-primary-soft, #fdeee6);
84
+ color: var(--rk-primary, #d94f1b);
85
+ }
86
+
87
+ .clear:focus-visible {
88
+ outline: none;
89
+ box-shadow: var(--rk-focus-ring);
90
+ }
91
+
92
+ .clear svg {
93
+ width: 15px;
94
+ height: 15px;
95
+ }
96
+
97
+ .spinner {
98
+ flex: 0 0 auto;
99
+ width: 18px;
100
+ height: 18px;
101
+ border-radius: 50%;
102
+ border: 2px solid var(--rk-border-strong, #d9c6b8);
103
+ border-top-color: var(--rk-primary, #d94f1b);
104
+ animation: rk-spin 700ms linear infinite;
105
+ }
106
+
107
+ @keyframes rk-spin {
108
+ to {
109
+ transform: rotate(360deg);
110
+ }
111
+ }
112
+
113
+ /*
114
+ * `display: contents` promotes slotted controls to flex items of `.shell`, so an
115
+ * empty `actions` slot leaves no stray box (and no stray gap) behind.
116
+ */
117
+ .actions {
118
+ display: contents;
119
+ }
120
+
121
+ .actions ::slotted(*) {
122
+ flex: 0 0 auto;
123
+ }
124
+
125
+ .hint {
126
+ margin: 8px 0 0;
127
+ padding-left: 16px;
128
+ font-size: 0.82em;
129
+ color: var(--rk-muted, #857166);
130
+ }
131
+
132
+ @media (prefers-reduced-motion: reduce) {
133
+ .spinner {
134
+ animation-duration: 2s;
135
+ }
136
+
137
+ .shell,
138
+ .clear {
139
+ transition: none;
140
+ }
141
+ }
@@ -0,0 +1,152 @@
1
+ import { Component, Event, EventEmitter, h, Host, Method, Prop, State, Watch } from '@stencil/core';
2
+
3
+ /**
4
+ * A debounced search input with a clear affordance and a slot for extra controls.
5
+ *
6
+ * @slot actions - Rendered to the right of the input, inside the field shell.
7
+ * Useful for a "filters" toggle or a sort control.
8
+ */
9
+ @Component({
10
+ tag: 'rk-search-bar',
11
+ styleUrl: 'rk-search-bar.css',
12
+ shadow: true,
13
+ })
14
+ export class RkSearchBar {
15
+ /** Current text in the field. Two-way: updated as the user types. */
16
+ @Prop({ mutable: true }) value = '';
17
+
18
+ /** Placeholder shown when the field is empty. */
19
+ @Prop() placeholder = 'Search recipes, ingredients, cuisines…';
20
+
21
+ /** Accessible label for the input. */
22
+ @Prop() label = 'Search recipes';
23
+
24
+ /** Milliseconds to wait after the last keystroke before emitting `rkSearch`. */
25
+ @Prop() debounce = 350;
26
+
27
+ /** Disables the whole control. */
28
+ @Prop() disabled = false;
29
+
30
+ /** Shows a busy indicator (e.g. while the app is fetching results). */
31
+ @Prop() loading = false;
32
+
33
+ /** Optional helper text rendered under the field. */
34
+ @Prop() hint?: string;
35
+
36
+ /** Emitted with the (trimmed) query after the debounce window, or on submit. */
37
+ @Event({ eventName: 'rkSearch' }) rkSearch!: EventEmitter<string>;
38
+
39
+ /** Emitted when the user clears the field with the clear button or Escape. */
40
+ @Event({ eventName: 'rkClear' }) rkClear!: EventEmitter<void>;
41
+
42
+ @State() private focused = false;
43
+
44
+ private inputEl?: HTMLInputElement;
45
+ private timer?: ReturnType<typeof setTimeout>;
46
+
47
+ @Watch('value')
48
+ protected onValueChange(next: string, prev: string) {
49
+ // Keep the DOM input in sync when the value is driven from the outside.
50
+ if (this.inputEl && this.inputEl.value !== next && next !== prev) {
51
+ this.inputEl.value = next ?? '';
52
+ }
53
+ }
54
+
55
+ disconnectedCallback() {
56
+ clearTimeout(this.timer);
57
+ }
58
+
59
+ /** Programmatically focus the input. */
60
+ @Method()
61
+ async setFocus() {
62
+ this.inputEl?.focus();
63
+ }
64
+
65
+ private emit(query: string) {
66
+ clearTimeout(this.timer);
67
+ this.rkSearch.emit(query.trim());
68
+ }
69
+
70
+ private handleInput = (event: Event) => {
71
+ const next = (event.target as HTMLInputElement).value;
72
+ this.value = next;
73
+ clearTimeout(this.timer);
74
+ this.timer = setTimeout(() => this.emit(next), Math.max(0, this.debounce));
75
+ };
76
+
77
+ private handleKeyDown = (event: KeyboardEvent) => {
78
+ if (event.key === 'Enter') {
79
+ event.preventDefault();
80
+ this.emit(this.value);
81
+ } else if (event.key === 'Escape' && this.value) {
82
+ event.preventDefault();
83
+ this.handleClear();
84
+ }
85
+ };
86
+
87
+ private handleClear = () => {
88
+ clearTimeout(this.timer);
89
+ this.value = '';
90
+ if (this.inputEl) this.inputEl.value = '';
91
+ this.rkClear.emit();
92
+ this.rkSearch.emit('');
93
+ this.inputEl?.focus();
94
+ };
95
+
96
+ render() {
97
+ const hasValue = (this.value ?? '').length > 0;
98
+
99
+ return (
100
+ <Host>
101
+ <div class={{ shell: true, 'is-focused': this.focused, 'is-disabled': this.disabled }} part="shell">
102
+ <span class="icon" aria-hidden="true">
103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
104
+ <circle cx="11" cy="11" r="7" />
105
+ <path d="m20 20-3.5-3.5" />
106
+ </svg>
107
+ </span>
108
+
109
+ <input
110
+ ref={el => (this.inputEl = el)}
111
+ class="input"
112
+ part="input"
113
+ type="search"
114
+ value={this.value}
115
+ placeholder={this.placeholder}
116
+ aria-label={this.label}
117
+ aria-busy={this.loading ? 'true' : 'false'}
118
+ disabled={this.disabled}
119
+ autocomplete="off"
120
+ autocapitalize="off"
121
+ spellcheck={false}
122
+ enterkeyhint="search"
123
+ onInput={this.handleInput}
124
+ onKeyDown={this.handleKeyDown}
125
+ onFocus={() => (this.focused = true)}
126
+ onBlur={() => (this.focused = false)}
127
+ />
128
+
129
+ {this.loading && <span class="spinner" part="spinner" aria-hidden="true" />}
130
+
131
+ {hasValue && !this.loading && (
132
+ <button class="clear" part="clear" type="button" onClick={this.handleClear} aria-label="Clear search" disabled={this.disabled}>
133
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
134
+ <path d="M6 6l12 12M18 6 6 18" />
135
+ </svg>
136
+ </button>
137
+ )}
138
+
139
+ <span class="actions" part="actions">
140
+ <slot name="actions" />
141
+ </span>
142
+ </div>
143
+
144
+ {this.hint && (
145
+ <p class="hint" part="hint">
146
+ {this.hint}
147
+ </p>
148
+ )}
149
+ </Host>
150
+ );
151
+ }
152
+ }
@@ -0,0 +1,93 @@
1
+ import { newSpecPage } from '@stencil/core/testing';
2
+ import { RkSearchBar } from '../rk-search-bar';
3
+
4
+ /*
5
+ * Real timers on purpose: Stencil's spec renderer drives its own scheduler, and
6
+ * `jest.useFakeTimers()` stalls `waitForChanges()`. The debounce windows below
7
+ * are therefore kept short enough to wait out.
8
+ */
9
+ const DEBOUNCE = 40;
10
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
11
+
12
+ describe('rk-search-bar', () => {
13
+ async function setup(html = `<rk-search-bar debounce="${DEBOUNCE}"></rk-search-bar>`) {
14
+ const page = await newSpecPage({ components: [RkSearchBar], html });
15
+ const input = page.root.shadowRoot.querySelector('input') as HTMLInputElement;
16
+ const onSearch = jest.fn();
17
+ page.root.addEventListener('rkSearch', onSearch);
18
+ return { page, input, onSearch };
19
+ }
20
+
21
+ function type(input: HTMLInputElement, value: string) {
22
+ input.value = value;
23
+ input.dispatchEvent(new Event('input'));
24
+ }
25
+
26
+ it('debounces input and emits the trimmed query once', async () => {
27
+ const { page, input, onSearch } = await setup();
28
+
29
+ type(input, 'ch');
30
+ type(input, 'chic');
31
+ type(input, ' chicken ');
32
+ expect(onSearch).not.toHaveBeenCalled();
33
+
34
+ await wait(DEBOUNCE * 3);
35
+ await page.waitForChanges();
36
+
37
+ expect(onSearch).toHaveBeenCalledTimes(1);
38
+ expect(onSearch.mock.calls[0][0].detail).toBe('chicken');
39
+ });
40
+
41
+ it('emits immediately on Enter, skipping the pending debounce', async () => {
42
+ const { page, input, onSearch } = await setup(`<rk-search-bar debounce="400"></rk-search-bar>`);
43
+
44
+ type(input, 'pasta');
45
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
46
+ await page.waitForChanges();
47
+
48
+ expect(onSearch).toHaveBeenCalledTimes(1);
49
+ expect(onSearch.mock.calls[0][0].detail).toBe('pasta');
50
+
51
+ // The queued debounce must have been cancelled, not merely pre-empted.
52
+ await wait(500);
53
+ expect(onSearch).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ it('clears on Escape and reports the empty query', async () => {
57
+ const { page, input, onSearch } = await setup();
58
+ const onClear = jest.fn();
59
+ page.root.addEventListener('rkClear', onClear);
60
+
61
+ type(input, 'soup');
62
+ await wait(DEBOUNCE * 3);
63
+ await page.waitForChanges();
64
+ onSearch.mockClear();
65
+
66
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
67
+ await page.waitForChanges();
68
+
69
+ expect(onClear).toHaveBeenCalledTimes(1);
70
+ expect(onSearch).toHaveBeenCalledTimes(1);
71
+ expect(onSearch.mock.calls[0][0].detail).toBe('');
72
+ expect((page.root as HTMLRkSearchBarElement).value).toBe('');
73
+ });
74
+
75
+ it('only shows the clear button when there is text', async () => {
76
+ const { page, input } = await setup();
77
+ expect(page.root.shadowRoot.querySelector('.clear')).toBeNull();
78
+
79
+ type(input, 'x');
80
+ await page.waitForChanges();
81
+ expect(page.root.shadowRoot.querySelector('.clear')).not.toBeNull();
82
+ });
83
+
84
+ it('shows a spinner instead of the clear button while loading', async () => {
85
+ const { page, input } = await setup(`<rk-search-bar debounce="${DEBOUNCE}" loading="true"></rk-search-bar>`);
86
+
87
+ type(input, 'x');
88
+ await page.waitForChanges();
89
+
90
+ expect(page.root.shadowRoot.querySelector('.spinner')).not.toBeNull();
91
+ expect(page.root.shadowRoot.querySelector('.clear')).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,41 @@
1
+ # rk-segmented-control
2
+
3
+
4
+
5
+ <!-- Auto Generated Below -->
6
+
7
+
8
+ ## Overview
9
+
10
+ A single-select segmented control, used for sorting and view switching.
11
+
12
+ `options` is a complex property. Frameworks should assign it as a DOM
13
+ property; for plain HTML it also accepts a JSON string attribute.
14
+
15
+ ## Properties
16
+
17
+ | Property | Attribute | Description | Type | Default |
18
+ | --------- | --------- | ------------------------------------------------------------------ | ----------------------------- | ---------------- |
19
+ | `label` | `label` | Accessible label for the group. | `string` | `'View options'` |
20
+ | `options` | `options` | The available segments. Accepts an array or a JSON-encoded string. | `RkSegmentOption[] \| string` | `[]` |
21
+ | `stretch` | `stretch` | Stretches the control to fill its container. | `boolean` | `false` |
22
+ | `value` | `value` | The value of the selected segment. | `string` | `undefined` |
23
+
24
+
25
+ ## Events
26
+
27
+ | Event | Description | Type |
28
+ | ----------------- | -------------------------------------- | --------------------- |
29
+ | `rkSegmentChange` | Emitted with the newly selected value. | `CustomEvent<string>` |
30
+
31
+
32
+ ## Shadow Parts
33
+
34
+ | Part | Description |
35
+ | --------- | ----------- |
36
+ | `"group"` | |
37
+
38
+
39
+ ----------------------------------------------
40
+
41
+ *Built with [StencilJS](https://stenciljs.com/)*
@@ -0,0 +1,91 @@
1
+ :host {
2
+ display: inline-block;
3
+ font-family: var(--rk-font);
4
+ font-size: var(--rk-font-size, 15px);
5
+ max-width: 100%;
6
+ }
7
+
8
+ .group {
9
+ display: inline-flex;
10
+ align-items: center;
11
+ gap: 2px;
12
+ padding: 3px;
13
+ background: var(--rk-surface-2, #fdf5ef);
14
+ border: 1px solid var(--rk-border, #ecdfd4);
15
+ border-radius: var(--rk-radius-pill, 999px);
16
+ max-width: 100%;
17
+ overflow-x: auto;
18
+ scrollbar-width: none;
19
+ }
20
+
21
+ .group::-webkit-scrollbar {
22
+ display: none;
23
+ }
24
+
25
+ .group.is-stretch {
26
+ display: flex;
27
+ width: 100%;
28
+ }
29
+
30
+ .segment {
31
+ display: inline-flex;
32
+ align-items: center;
33
+ gap: 6px;
34
+ flex: 0 0 auto;
35
+ padding: 8px 16px;
36
+ border: 0;
37
+ border-radius: var(--rk-radius-pill, 999px);
38
+ background: transparent;
39
+ color: var(--rk-ink-2, #52443c);
40
+ font: inherit;
41
+ font-size: 0.88em;
42
+ font-weight: 550;
43
+ line-height: 1;
44
+ white-space: nowrap;
45
+ cursor: pointer;
46
+ transition: background var(--rk-transition), color var(--rk-transition), box-shadow var(--rk-transition);
47
+ }
48
+
49
+ .group.is-stretch .segment {
50
+ flex: 1 1 0;
51
+ justify-content: center;
52
+ }
53
+
54
+ .segment:hover:not(:disabled):not(.is-selected) {
55
+ background: rgba(0, 0, 0, 0.04);
56
+ color: var(--rk-ink, #211a16);
57
+ }
58
+
59
+ .segment:focus-visible {
60
+ outline: none;
61
+ box-shadow: var(--rk-focus-ring);
62
+ }
63
+
64
+ .segment.is-selected {
65
+ background: var(--rk-surface, #fff);
66
+ color: var(--rk-ink, #211a16);
67
+ box-shadow: var(--rk-shadow-sm);
68
+ font-weight: 650;
69
+ }
70
+
71
+ .segment:disabled {
72
+ opacity: 0.45;
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .count {
77
+ font-size: 0.85em;
78
+ font-weight: 650;
79
+ color: var(--rk-muted, #857166);
80
+ font-variant-numeric: tabular-nums;
81
+ }
82
+
83
+ .segment.is-selected .count {
84
+ color: var(--rk-primary, #d94f1b);
85
+ }
86
+
87
+ @media (prefers-reduced-motion: reduce) {
88
+ .segment {
89
+ transition: none;
90
+ }
91
+ }
@@ -0,0 +1,115 @@
1
+ import { Component, Event, EventEmitter, h, Prop, Watch } from '@stencil/core';
2
+
3
+ export interface RkSegmentOption {
4
+ label: string;
5
+ value: string;
6
+ /** Optional trailing count, e.g. number of items in that segment. */
7
+ count?: number;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ /**
12
+ * A single-select segmented control, used for sorting and view switching.
13
+ *
14
+ * `options` is a complex property. Frameworks should assign it as a DOM
15
+ * property; for plain HTML it also accepts a JSON string attribute.
16
+ */
17
+ @Component({
18
+ tag: 'rk-segmented-control',
19
+ styleUrl: 'rk-segmented-control.css',
20
+ shadow: true,
21
+ })
22
+ export class RkSegmentedControl {
23
+ /** The available segments. Accepts an array or a JSON-encoded string. */
24
+ @Prop() options: RkSegmentOption[] | string = [];
25
+
26
+ /** The value of the selected segment. */
27
+ @Prop({ mutable: true }) value?: string;
28
+
29
+ /** Accessible label for the group. */
30
+ @Prop() label = 'View options';
31
+
32
+ /** Stretches the control to fill its container. */
33
+ @Prop() stretch = false;
34
+
35
+ /** Emitted with the newly selected value. */
36
+ @Event({ eventName: 'rkSegmentChange' }) rkSegmentChange!: EventEmitter<string>;
37
+
38
+ private parsed: RkSegmentOption[] = [];
39
+
40
+ @Watch('options')
41
+ protected onOptionsChange() {
42
+ this.parseOptions();
43
+ }
44
+
45
+ componentWillLoad() {
46
+ this.parseOptions();
47
+ }
48
+
49
+ /**
50
+ * Attributes can only carry strings, so a JSON payload is accepted as a
51
+ * fallback for consumers that cannot set DOM properties.
52
+ */
53
+ private parseOptions() {
54
+ if (Array.isArray(this.options)) {
55
+ this.parsed = this.options;
56
+ return;
57
+ }
58
+ if (typeof this.options === 'string' && this.options.trim()) {
59
+ try {
60
+ const decoded = JSON.parse(this.options);
61
+ this.parsed = Array.isArray(decoded) ? decoded : [];
62
+ } catch {
63
+ console.warn('[rk-segmented-control] `options` is not valid JSON:', this.options);
64
+ this.parsed = [];
65
+ }
66
+ return;
67
+ }
68
+ this.parsed = [];
69
+ }
70
+
71
+ private select(option: RkSegmentOption) {
72
+ if (option.disabled || option.value === this.value) return;
73
+ this.value = option.value;
74
+ this.rkSegmentChange.emit(option.value);
75
+ }
76
+
77
+ private handleKeyDown = (event: KeyboardEvent, index: number) => {
78
+ const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
79
+ if (step === 0) return;
80
+ event.preventDefault();
81
+
82
+ const items = this.parsed.filter(option => !option.disabled);
83
+ if (items.length === 0) return;
84
+ const currentIndex = items.indexOf(this.parsed[index]);
85
+ const next = items[(currentIndex + step + items.length) % items.length];
86
+ this.select(next);
87
+ };
88
+
89
+ render() {
90
+ return (
91
+ <div class={{ group: true, 'is-stretch': this.stretch }} part="group" role="tablist" aria-label={this.label}>
92
+ {this.parsed.map((option, index) => {
93
+ const selected = option.value === this.value;
94
+ return (
95
+ <button
96
+ key={option.value}
97
+ class={{ segment: true, 'is-selected': selected }}
98
+ part={selected ? 'segment segment-selected' : 'segment'}
99
+ type="button"
100
+ role="tab"
101
+ aria-selected={String(selected)}
102
+ disabled={option.disabled}
103
+ tabindex={selected ? 0 : -1}
104
+ onClick={() => this.select(option)}
105
+ onKeyDown={event => this.handleKeyDown(event, index)}
106
+ >
107
+ {option.label}
108
+ {typeof option.count === 'number' && <span class="count">{option.count}</span>}
109
+ </button>
110
+ );
111
+ })}
112
+ </div>
113
+ );
114
+ }
115
+ }
@@ -0,0 +1,47 @@
1
+ # rk-tag-input
2
+
3
+
4
+
5
+ <!-- Auto Generated Below -->
6
+
7
+
8
+ ## Overview
9
+
10
+ A tag / token editor used for recipe tags and free-form ingredient lists.
11
+
12
+ `values` is an array property — the SvelteKit app assigns it as a DOM
13
+ property. A JSON-encoded or comma-separated string attribute also works.
14
+
15
+ ## Properties
16
+
17
+ | Property | Attribute | Description | Type | Default |
18
+ | ------------- | ------------- | ------------------------------------------------------------ | -------------------- | ----------------------------- |
19
+ | `disabled` | `disabled` | Prevent editing. | `boolean` | `false` |
20
+ | `error` | `error` | Validation message rendered under the field. | `string` | `undefined` |
21
+ | `label` | `label` | Visible field label. | `string` | `undefined` |
22
+ | `maxTags` | `max-tags` | Maximum number of tags. `0` means unlimited. | `number` | `0` |
23
+ | `placeholder` | `placeholder` | Placeholder for the entry field. | `string` | `'Add a tag and press Enter'` |
24
+ | `values` | `values` | Current tags. Array, JSON string, or comma-separated string. | `string \| string[]` | `[]` |
25
+
26
+
27
+ ## Events
28
+
29
+ | Event | Description | Type |
30
+ | -------------- | --------------------------------------------------------- | ----------------------- |
31
+ | `rkTagsChange` | Emitted with the full, updated list whenever tags change. | `CustomEvent<string[]>` |
32
+
33
+
34
+ ## Shadow Parts
35
+
36
+ | Part | Description |
37
+ | --------- | ----------- |
38
+ | `"error"` | |
39
+ | `"input"` | |
40
+ | `"label"` | |
41
+ | `"shell"` | |
42
+ | `"tag"` | |
43
+
44
+
45
+ ----------------------------------------------
46
+
47
+ *Built with [StencilJS](https://stenciljs.com/)*