milk-lib 0.0.23 → 0.0.24

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.
@@ -0,0 +1,200 @@
1
+ <script lang="ts">
2
+ import { setContext } from 'svelte';
3
+ import { writable } from 'svelte/store';
4
+ import {
5
+ SEGMENTED_CONTROL_CONTEXT,
6
+ type ISegmentedControlContext,
7
+ type ISegmentedControlItemEntry,
8
+ type ISegmentedControlRootProps
9
+ } from './SegmentedControl.types';
10
+
11
+ let {
12
+ value = $bindable(),
13
+ defaultValue,
14
+ orientation = 'horizontal',
15
+ gap = '2px',
16
+ disabled = false,
17
+ classNames = '',
18
+ style,
19
+ dataVariant,
20
+ onValueChange,
21
+ children
22
+ }: ISegmentedControlRootProps = $props();
23
+
24
+ if (value === undefined && defaultValue !== undefined) {
25
+ value = defaultValue;
26
+ }
27
+
28
+ const selectedValue = writable<string | null>(value ?? defaultValue ?? null);
29
+ const disabledState = writable<boolean>(disabled);
30
+ let items: ISegmentedControlItemEntry[] = [];
31
+
32
+ function registerItem(entry: ISegmentedControlItemEntry) {
33
+ const index = items.findIndex(item => item.id === entry.id);
34
+ if (index === -1) {
35
+ items = [...items, entry];
36
+ } else {
37
+ items = [
38
+ ...items.slice(0, index),
39
+ entry,
40
+ ...items.slice(index + 1)
41
+ ];
42
+ }
43
+ }
44
+
45
+ function unregisterItem(id: symbol) {
46
+ items = items.filter(item => item.id !== id);
47
+ }
48
+
49
+ function selectValue(newValue: string) {
50
+ if (disabled) return;
51
+ if (value === newValue) return;
52
+ const target = items.find(item => item.value === newValue);
53
+ if (target?.disabled) return;
54
+ value = newValue;
55
+ selectedValue.set(newValue);
56
+ onValueChange?.(newValue);
57
+ }
58
+
59
+ const context: ISegmentedControlContext = {
60
+ orientation,
61
+ disabled: disabledState,
62
+ selectedValue,
63
+ selectValue,
64
+ registerItem,
65
+ unregisterItem
66
+ };
67
+
68
+ setContext(SEGMENTED_CONTROL_CONTEXT, context);
69
+
70
+ $effect(() => {
71
+ context.orientation = orientation;
72
+ });
73
+
74
+ $effect(() => {
75
+ disabledState.set(disabled);
76
+ });
77
+
78
+ $effect(() => {
79
+ selectedValue.set(value ?? defaultValue ?? null);
80
+ });
81
+
82
+ let rootStyle = $state('');
83
+ $effect(() => {
84
+ const normalized = style ? style.trim() : '';
85
+ const normalizedStyle = normalized
86
+ ? normalized.endsWith(';')
87
+ ? normalized
88
+ : `${normalized};`
89
+ : '';
90
+ rootStyle = `${normalizedStyle} --segmented-control-gap: ${gap};`;
91
+ });
92
+
93
+ function moveFocus(step: number) {
94
+ if (!items.length) return;
95
+ if (!items.some(entry => !entry.disabled)) return;
96
+
97
+ let nextIndex = items.findIndex(entry => entry.value === value);
98
+ if (nextIndex === -1) {
99
+ nextIndex = step > 0 ? -1 : 0;
100
+ }
101
+
102
+ for (let i = 0; i < items.length; i += 1) {
103
+ nextIndex = (nextIndex + step + items.length) % items.length;
104
+ const entry = items[nextIndex];
105
+ if (!entry?.disabled) {
106
+ selectValue(entry.value);
107
+ entry.el?.focus();
108
+ break;
109
+ }
110
+ }
111
+ }
112
+
113
+ function focusEdge(isFirst: boolean) {
114
+ if (!items.length) return;
115
+ const ordered = isFirst ? items : [...items].reverse();
116
+ for (const entry of ordered) {
117
+ if (!entry.disabled) {
118
+ selectValue(entry.value);
119
+ entry.el?.focus();
120
+ break;
121
+ }
122
+ }
123
+ }
124
+
125
+ function handleKeydown(event: KeyboardEvent) {
126
+ if (disabled) return;
127
+ const { key } = event;
128
+ if (orientation === 'horizontal') {
129
+ if (key === 'ArrowRight') {
130
+ event.preventDefault();
131
+ moveFocus(1);
132
+ return;
133
+ }
134
+ if (key === 'ArrowLeft') {
135
+ event.preventDefault();
136
+ moveFocus(-1);
137
+ return;
138
+ }
139
+ }
140
+
141
+ if (orientation === 'vertical') {
142
+ if (key === 'ArrowDown') {
143
+ event.preventDefault();
144
+ moveFocus(1);
145
+ return;
146
+ }
147
+ if (key === 'ArrowUp') {
148
+ event.preventDefault();
149
+ moveFocus(-1);
150
+ return;
151
+ }
152
+ }
153
+
154
+ if (key === 'Home') {
155
+ event.preventDefault();
156
+ focusEdge(true);
157
+ }
158
+
159
+ if (key === 'End') {
160
+ event.preventDefault();
161
+ focusEdge(false);
162
+ }
163
+ }
164
+ </script>
165
+
166
+ <div
167
+ class={`SegmentedControlRoot ${classNames}`}
168
+ role="group"
169
+ aria-orientation={orientation}
170
+ aria-disabled={disabled}
171
+ data-orientation={orientation}
172
+ style={rootStyle}
173
+ onkeydown={handleKeydown}
174
+ {...(dataVariant ? { 'data-variant': dataVariant } : {})}
175
+ >
176
+ {@render children?.()}
177
+ </div>
178
+
179
+ <style>.SegmentedControlRoot {
180
+ --segmented-control-padding: 2px;
181
+ --segmented-control-radius: var(--border-radius-button, 10px);
182
+ --segmented-control-border: 1px solid var(--line-base);
183
+ display: inline-flex;
184
+ gap: var(--segmented-control-gap, 2px);
185
+ padding: var(--segmented-control-padding);
186
+ border: var(--segmented-control-border);
187
+ border-radius: var(--segmented-control-radius);
188
+ background-color: var(--bg-base);
189
+ align-items: stretch;
190
+ justify-content: flex-start;
191
+ transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
192
+ }
193
+ .SegmentedControlRoot[data-orientation=vertical] {
194
+ flex-direction: column;
195
+ }
196
+
197
+ .SegmentedControlRoot:focus-within {
198
+ border-color: var(--line-base);
199
+ box-shadow: 0 0 0 1px var(--line-base);
200
+ }</style>
@@ -0,0 +1,4 @@
1
+ import { type ISegmentedControlRootProps } from './SegmentedControl.types';
2
+ declare const SegmentedControl: import("svelte").Component<ISegmentedControlRootProps, {}, "value">;
3
+ type SegmentedControl = ReturnType<typeof SegmentedControl>;
4
+ export default SegmentedControl;
@@ -0,0 +1,55 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Writable } from 'svelte/store';
3
+ /** Shared context key so items can find the current root instance. */
4
+ export declare const SEGMENTED_CONTROL_CONTEXT: unique symbol;
5
+ export type SegmentedControlOrientation = 'horizontal' | 'vertical';
6
+ export interface ISegmentedControlItemEntry {
7
+ id: symbol;
8
+ value: string;
9
+ el: HTMLButtonElement | null;
10
+ disabled?: boolean;
11
+ }
12
+ export interface ISegmentedControlContext {
13
+ orientation: SegmentedControlOrientation;
14
+ disabled: Writable<boolean>;
15
+ selectedValue: Writable<string | null>;
16
+ selectValue: (value: string) => void;
17
+ registerItem: (entry: ISegmentedControlItemEntry) => void;
18
+ unregisterItem: (id: symbol) => void;
19
+ }
20
+ export interface ISegmentedControlRootProps {
21
+ /** Controlled value. */
22
+ value?: string | null;
23
+ /** Fallback selection when `value` is not supplied. */
24
+ defaultValue?: string;
25
+ /** Exposed gap between items. */
26
+ gap?: string;
27
+ /** Direction of the control. */
28
+ orientation?: SegmentedControlOrientation;
29
+ /** Disable the whole control. */
30
+ disabled?: boolean;
31
+ /** Custom CSS class names for the root wrapper. */
32
+ classNames?: string;
33
+ /** Inline style helpers for the root wrapper. */
34
+ style?: string;
35
+ /** Arbitrary variant attribute. */
36
+ dataVariant?: string;
37
+ /** Callback that fires when the active value changes. */
38
+ onValueChange?: (value: string) => void;
39
+ children: Snippet;
40
+ }
41
+ export interface ISegmentedControlItemProps {
42
+ /** Unique identifier for the item (must be provided). */
43
+ value: string;
44
+ /** Disable just this item. */
45
+ disabled?: boolean;
46
+ /** Custom CSS class names applied to the item button. */
47
+ classNames?: string;
48
+ /** Inline styles for the item button. */
49
+ style?: string;
50
+ /** Arbitrary variant attribute. */
51
+ dataVariant?: string;
52
+ /** Click handler passed from the consumer. */
53
+ onClick?: (event: MouseEvent) => void;
54
+ children: Snippet;
55
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared context key so items can find the current root instance. */
2
+ export const SEGMENTED_CONTROL_CONTEXT = Symbol('segmented-control');
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ import { getContext, onDestroy } from 'svelte';
3
+ import { writable } from 'svelte/store';
4
+ import {
5
+ SEGMENTED_CONTROL_CONTEXT,
6
+ type ISegmentedControlContext,
7
+ type ISegmentedControlItemProps
8
+ } from './SegmentedControl.types';
9
+
10
+ const fallbackSelectedValue = writable<string | null>(null);
11
+ const fallbackDisabled = writable(false);
12
+
13
+ let {
14
+ value,
15
+ disabled = false,
16
+ classNames = '',
17
+ style,
18
+ dataVariant,
19
+ onClick,
20
+ children
21
+ }: ISegmentedControlItemProps = $props();
22
+
23
+ const context = getContext<ISegmentedControlContext>(SEGMENTED_CONTROL_CONTEXT);
24
+ const selectedValueStore = context?.selectedValue ?? fallbackSelectedValue;
25
+ const disabledStore = context?.disabled ?? fallbackDisabled;
26
+ const itemId = Symbol(value);
27
+ let buttonEl: HTMLButtonElement | null = null;
28
+ let isDisabled = $derived(disabled || $disabledStore);
29
+ let isSelected = $derived($selectedValueStore === value);
30
+ let tabIndex = $derived(($selectedValueStore !== null) ? (isSelected ? 0 : -1) : 0);
31
+
32
+ $effect(() => {
33
+ if (context) {
34
+ context.registerItem({
35
+ id: itemId,
36
+ value,
37
+ el: buttonEl,
38
+ disabled: isDisabled
39
+ });
40
+ }
41
+ });
42
+
43
+ let buttonClass = $state('');
44
+ $effect(() => {
45
+ const base = ['SegmentedControlItem'];
46
+ if (isSelected) base.push('SegmentedControlItem--selected');
47
+ if (isDisabled) base.push('SegmentedControlItem--disabled');
48
+ if (classNames) base.push(classNames);
49
+ buttonClass = base.join(' ');
50
+ });
51
+
52
+ onDestroy(() => {
53
+ context?.unregisterItem(itemId);
54
+ });
55
+
56
+ function handleClick(event: MouseEvent) {
57
+ if (isDisabled) return;
58
+ context?.selectValue(value);
59
+ onClick?.(event);
60
+ }
61
+ </script>
62
+
63
+ <button
64
+ type="button"
65
+ class={buttonClass}
66
+ role="radio"
67
+ aria-checked={isSelected}
68
+ aria-disabled={isDisabled}
69
+ tabindex={tabIndex}
70
+ data-state={isSelected ? 'on' : 'off'}
71
+ {style}
72
+ onclick={handleClick}
73
+ bind:this={buttonEl}
74
+ disabled={isDisabled}
75
+ {...(dataVariant ? { 'data-variant': dataVariant } : {})}
76
+ >
77
+ {@render children?.()}
78
+ </button>
79
+
80
+ <style>.SegmentedControlItem {
81
+ flex: 1;
82
+ min-width: 0;
83
+ border: 0;
84
+ border-radius: 6px;
85
+ background: transparent;
86
+ color: var(--text-base-muted);
87
+ font-size: 0.875rem;
88
+ line-height: 1.3;
89
+ padding: 0.35rem 0.9rem;
90
+ cursor: pointer;
91
+ text-align: center;
92
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
93
+ align-items: center;
94
+ display: inline-flex;
95
+ justify-content: center;
96
+ gap: 0.35rem;
97
+ outline: none;
98
+ }
99
+ .SegmentedControlItem:not(:disabled):hover:not([data-state=on]) {
100
+ background: var(--bg-base-200);
101
+ }
102
+
103
+ .SegmentedControlItem[data-state=on] {
104
+ background: var(--bg-base-emp-100);
105
+ color: var(--text-base-inv);
106
+ }
107
+
108
+ .SegmentedControlItem--disabled {
109
+ opacity: 0.5;
110
+ cursor: not-allowed;
111
+ }</style>
@@ -0,0 +1,4 @@
1
+ import { type ISegmentedControlItemProps } from './SegmentedControl.types';
2
+ declare const SegmentedControlItem: import("svelte").Component<ISegmentedControlItemProps, {}, "">;
3
+ type SegmentedControlItem = ReturnType<typeof SegmentedControlItem>;
4
+ export default SegmentedControlItem;
@@ -0,0 +1,8 @@
1
+ import SegmentedControlRoot from './SegmentedControl.svelte';
2
+ import SegmentedControlItem from './SegmentedControlItem.svelte';
3
+ declare const SegmentedControl: import("svelte").Component<import("./SegmentedControl.types").ISegmentedControlRootProps, {}, "value"> & {
4
+ Root: import("svelte").Component<import("./SegmentedControl.types").ISegmentedControlRootProps, {}, "value">;
5
+ Item: import("svelte").Component<import("./SegmentedControl.types").ISegmentedControlItemProps, {}, "">;
6
+ };
7
+ export { SegmentedControl, SegmentedControlRoot, SegmentedControlItem };
8
+ export * from './SegmentedControl.types';
@@ -0,0 +1,8 @@
1
+ import SegmentedControlRoot from './SegmentedControl.svelte';
2
+ import SegmentedControlItem from './SegmentedControlItem.svelte';
3
+ const SegmentedControl = Object.assign(SegmentedControlRoot, {
4
+ Root: SegmentedControlRoot,
5
+ Item: SegmentedControlItem
6
+ });
7
+ export { SegmentedControl, SegmentedControlRoot, SegmentedControlItem };
8
+ export * from './SegmentedControl.types';
@@ -13,6 +13,7 @@ export * from './TextInputBlock';
13
13
  export * from './Command';
14
14
  export * from './Portal';
15
15
  export * from './Modal';
16
+ export * from './SegmentedControl';
16
17
  export * from './Select';
17
18
  export * from './Icon';
18
19
  export * from './Checkbox';
@@ -13,6 +13,7 @@ export * from './TextInputBlock';
13
13
  export * from './Command';
14
14
  export * from './Portal';
15
15
  export * from './Modal';
16
+ export * from './SegmentedControl';
16
17
  export * from './Select';
17
18
  export * from './Icon';
18
19
  export * from './Checkbox';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "milk-lib",
3
3
  "license": "MIT",
4
- "version": "0.0.23",
4
+ "version": "0.0.24",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
7
7
  "build": "vite build && npm run prepack",