@refraction-ui/astro 0.14.0 → 0.15.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/dist/astro-kanban-board/KanbanBoard.astro +92 -0
- package/dist/astro-kanban-board/index.ts +1 -0
- package/dist/astro-slot-picker/SlotPicker.astro +105 -0
- package/dist/astro-slot-picker/index.ts +1 -0
- package/dist/astro-sortable-list/SortableList.astro +68 -0
- package/dist/astro-sortable-list/index.ts +1 -0
- package/dist/index.ts +3 -0
- package/dist/kanban-board/index.ts +20 -0
- package/dist/kanban-board/kanban-board.styles.ts +78 -0
- package/dist/kanban-board/kanban-board.ts +82 -0
- package/dist/slot-picker/index.ts +18 -0
- package/dist/slot-picker/slot-picker.styles.ts +70 -0
- package/dist/slot-picker/slot-picker.ts +64 -0
- package/dist/sortable-list/index.ts +13 -0
- package/dist/sortable-list/sortable-list.styles.ts +42 -0
- package/dist/sortable-list/sortable-list.ts +90 -0
- package/package.json +4 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
createKanbanBoard,
|
|
4
|
+
cardsByColumn,
|
|
5
|
+
visibleAndOverflow,
|
|
6
|
+
kanbanBoardVariants,
|
|
7
|
+
kanbanColumnVariants,
|
|
8
|
+
kanbanColumnHeaderClass,
|
|
9
|
+
kanbanColumnTitleClass,
|
|
10
|
+
kanbanColumnCountClass,
|
|
11
|
+
kanbanAccentBarClass,
|
|
12
|
+
kanbanNoteBarClass,
|
|
13
|
+
kanbanColumnBodyClass,
|
|
14
|
+
kanbanCardVariants,
|
|
15
|
+
type KanbanColumnDef,
|
|
16
|
+
} from '../kanban-board/index.ts'
|
|
17
|
+
import { cn } from '../shared/index.ts'
|
|
18
|
+
|
|
19
|
+
interface KanbanCard {
|
|
20
|
+
/** Must match one of the column ids. */
|
|
21
|
+
columnId: string
|
|
22
|
+
/** Primary label rendered in the card header. */
|
|
23
|
+
title: string
|
|
24
|
+
/** Optional secondary label rendered below the title. */
|
|
25
|
+
subtitle?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Props extends astroHTML.JSX.HTMLAttributes {
|
|
29
|
+
/** Column definitions in left-to-right order. */
|
|
30
|
+
columns: KanbanColumnDef[]
|
|
31
|
+
/** All cards to distribute across columns. */
|
|
32
|
+
cards: KanbanCard[]
|
|
33
|
+
/** Max visible cards per column before overflow is hidden. Defaults to 5. */
|
|
34
|
+
cardCap?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { columns, cards, cardCap = 5, class: className, ...rest } = Astro.props
|
|
38
|
+
|
|
39
|
+
const { ariaProps, dataAttributes } = createKanbanBoard()
|
|
40
|
+
const buckets = cardsByColumn(cards, (c) => c.columnId, columns)
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<div class={cn(kanbanBoardVariants(), className)} {...ariaProps} {...dataAttributes} {...rest}>
|
|
44
|
+
{
|
|
45
|
+
columns.map((def) => {
|
|
46
|
+
const colCards = buckets.get(def.id) ?? []
|
|
47
|
+
const { visible, overflow } = visibleAndOverflow(colCards, cardCap)
|
|
48
|
+
const count = colCards.length
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
class={kanbanColumnVariants()}
|
|
53
|
+
style={def.accent ? `--kanban-accent: ${def.accent}` : undefined}
|
|
54
|
+
>
|
|
55
|
+
{/* Column header */}
|
|
56
|
+
<div class={kanbanColumnHeaderClass}>
|
|
57
|
+
<span class={kanbanColumnTitleClass}>{def.title}</span>
|
|
58
|
+
<span class={kanbanColumnCountClass}>{count}</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Accent bar */}
|
|
62
|
+
<div
|
|
63
|
+
class={kanbanAccentBarClass}
|
|
64
|
+
style={def.accent ? 'background-color: var(--kanban-accent)' : 'background-color: transparent'}
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
{/* Note bar */}
|
|
69
|
+
{def.note && <p class={kanbanNoteBarClass}>{def.note}</p>}
|
|
70
|
+
|
|
71
|
+
{/* Cards */}
|
|
72
|
+
<div class={kanbanColumnBodyClass}>
|
|
73
|
+
{visible.map((card) => (
|
|
74
|
+
<div class={kanbanCardVariants({ clickable: 'false' })}>
|
|
75
|
+
<p class="font-medium text-foreground leading-snug">{card.title}</p>
|
|
76
|
+
{card.subtitle && (
|
|
77
|
+
<p class="mt-0.5 text-xs text-muted-foreground">{card.subtitle}</p>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
|
|
82
|
+
{overflow > 0 && (
|
|
83
|
+
<p class="mt-1 text-center text-xs text-muted-foreground">
|
|
84
|
+
{`+${overflow} more`}
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
createSlotPicker,
|
|
4
|
+
slotsForDay,
|
|
5
|
+
isSlotSelected,
|
|
6
|
+
slotPickerVariants,
|
|
7
|
+
slotPickerDayRowClass,
|
|
8
|
+
slotPickerDayVariants,
|
|
9
|
+
slotPickerGridClass,
|
|
10
|
+
slotPickerSlotVariants,
|
|
11
|
+
slotPickerSectionLabelClass,
|
|
12
|
+
slotPickerTimezoneClass,
|
|
13
|
+
type SlotDay,
|
|
14
|
+
type SlotSelection,
|
|
15
|
+
} from '../slot-picker/index.ts'
|
|
16
|
+
import { cn } from '../shared/index.ts'
|
|
17
|
+
|
|
18
|
+
interface Props extends astroHTML.JSX.HTMLAttributes {
|
|
19
|
+
/** The list of days to show as chips. */
|
|
20
|
+
days: SlotDay[]
|
|
21
|
+
/** Map of dayId → available time slot strings. */
|
|
22
|
+
slotsByDay: Record<string, string[]>
|
|
23
|
+
/** The currently-selected day (determines which slots are shown). */
|
|
24
|
+
selectedDayId?: string
|
|
25
|
+
/** The full selection (day + slot) used to highlight the selected slot. */
|
|
26
|
+
value?: SlotSelection
|
|
27
|
+
/** Label shown beside the "Pick a time" heading (e.g. "Eastern Time"). */
|
|
28
|
+
timezoneLabel?: string
|
|
29
|
+
/** Slot strings that should be rendered as disabled. */
|
|
30
|
+
disabledSlots?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
days,
|
|
35
|
+
slotsByDay,
|
|
36
|
+
selectedDayId,
|
|
37
|
+
value,
|
|
38
|
+
timezoneLabel,
|
|
39
|
+
disabledSlots = [],
|
|
40
|
+
class: className,
|
|
41
|
+
...rest
|
|
42
|
+
} = Astro.props
|
|
43
|
+
|
|
44
|
+
const currentSlots = selectedDayId ? slotsForDay(slotsByDay, selectedDayId) : []
|
|
45
|
+
const { ariaProps, dataAttributes } = createSlotPicker()
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<div class={cn(slotPickerVariants(), className)} {...ariaProps} {...dataAttributes} {...rest}>
|
|
49
|
+
<!-- Day row -->
|
|
50
|
+
<div>
|
|
51
|
+
<p class={slotPickerSectionLabelClass}>Pick a day</p>
|
|
52
|
+
<div role="radiogroup" aria-label="Pick a day" class={`${slotPickerDayRowClass} mt-2`}>
|
|
53
|
+
{
|
|
54
|
+
days.map((day) => {
|
|
55
|
+
const isSelected = selectedDayId === day.id
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
role="radio"
|
|
60
|
+
aria-checked={isSelected}
|
|
61
|
+
data-state={isSelected ? 'checked' : 'unchecked'}
|
|
62
|
+
class={slotPickerDayVariants({ selected: isSelected ? 'true' : 'false' })}
|
|
63
|
+
>
|
|
64
|
+
<span class="text-xs">{day.weekday}</span>
|
|
65
|
+
<span class="text-base font-bold leading-none">{day.dayNum}</span>
|
|
66
|
+
</button>
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Slot grid (only when a day is selected) -->
|
|
74
|
+
{selectedDayId && currentSlots.length > 0 && (
|
|
75
|
+
<div>
|
|
76
|
+
<p class={slotPickerSectionLabelClass}>
|
|
77
|
+
Pick a time
|
|
78
|
+
{timezoneLabel && <span class={slotPickerTimezoneClass}>{timezoneLabel}</span>}
|
|
79
|
+
</p>
|
|
80
|
+
<div role="radiogroup" aria-label="Pick a time" class={`${slotPickerGridClass} mt-2`}>
|
|
81
|
+
{
|
|
82
|
+
currentSlots.map((slot) => {
|
|
83
|
+
const isSlotActive = isSlotSelected(value, selectedDayId, slot)
|
|
84
|
+
const isDisabled = disabledSlots.includes(slot)
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
role="radio"
|
|
89
|
+
aria-checked={isSlotActive}
|
|
90
|
+
aria-disabled={isDisabled || undefined}
|
|
91
|
+
data-state={isSlotActive ? 'checked' : 'unchecked'}
|
|
92
|
+
class={slotPickerSlotVariants({
|
|
93
|
+
selected: isSlotActive ? 'true' : 'false',
|
|
94
|
+
disabled: isDisabled ? 'true' : 'false',
|
|
95
|
+
})}
|
|
96
|
+
>
|
|
97
|
+
{slot}
|
|
98
|
+
</button>
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
createSortableList,
|
|
4
|
+
sortableListVariants,
|
|
5
|
+
sortableListRowVariants,
|
|
6
|
+
sortableListGripClass,
|
|
7
|
+
sortableListContentClass,
|
|
8
|
+
} from '../sortable-list/index.ts'
|
|
9
|
+
import { cn } from '../shared/index.ts'
|
|
10
|
+
|
|
11
|
+
interface Item {
|
|
12
|
+
/** Unique key for the item. */
|
|
13
|
+
id: string | number
|
|
14
|
+
/** Display label rendered as the row content. */
|
|
15
|
+
label: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Props extends astroHTML.JSX.HTMLAttributes {
|
|
19
|
+
/** The ordered list of items to render. */
|
|
20
|
+
items: Item[]
|
|
21
|
+
/** Disables drag affordances visually. */
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
items,
|
|
27
|
+
disabled = false,
|
|
28
|
+
class: className,
|
|
29
|
+
...rest
|
|
30
|
+
} = Astro.props
|
|
31
|
+
|
|
32
|
+
const { ariaProps, dataAttributes } = createSortableList()
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<div
|
|
36
|
+
class={cn(sortableListVariants({ disabled: disabled ? 'true' : 'false' }), className)}
|
|
37
|
+
{...ariaProps}
|
|
38
|
+
{...dataAttributes}
|
|
39
|
+
{...rest}
|
|
40
|
+
>
|
|
41
|
+
{
|
|
42
|
+
items.map((item) => (
|
|
43
|
+
<div
|
|
44
|
+
role="listitem"
|
|
45
|
+
class={sortableListRowVariants({ dragging: 'false' })}
|
|
46
|
+
>
|
|
47
|
+
{/* Grip handle — static in SSR; interactivity is React-only */}
|
|
48
|
+
<span class={sortableListGripClass} aria-hidden="true">
|
|
49
|
+
<svg
|
|
50
|
+
width="16"
|
|
51
|
+
height="16"
|
|
52
|
+
viewBox="0 0 16 16"
|
|
53
|
+
fill="none"
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
>
|
|
56
|
+
<path
|
|
57
|
+
d="M2 4h12M2 8h12M2 12h12"
|
|
58
|
+
stroke="currentColor"
|
|
59
|
+
stroke-width="1.5"
|
|
60
|
+
stroke-linecap="round"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
</span>
|
|
64
|
+
<span class={sortableListContentClass}>{item.label}</span>
|
|
65
|
+
</div>
|
|
66
|
+
))
|
|
67
|
+
}
|
|
68
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ export * from './astro-inline-editor/index.ts';
|
|
|
56
56
|
export * from './astro-input/index.ts';
|
|
57
57
|
export * from './astro-input-group/index.ts';
|
|
58
58
|
export * from './astro-install-prompt/index.ts';
|
|
59
|
+
export * from './astro-kanban-board/index.ts';
|
|
59
60
|
export * from './astro-keyboard-shortcut/index.ts';
|
|
60
61
|
export * from './astro-language-selector/index.ts';
|
|
61
62
|
export * from './astro-link-card/index.ts';
|
|
@@ -95,7 +96,9 @@ export * from './astro-skeleton/index.ts';
|
|
|
95
96
|
export * from './astro-skip-to-content/index.ts';
|
|
96
97
|
export * from './astro-slide-viewer/index.ts';
|
|
97
98
|
export * from './astro-slider/index.ts';
|
|
99
|
+
export * from './astro-slot-picker/index.ts';
|
|
98
100
|
export * from './astro-social-auth-button/index.ts';
|
|
101
|
+
export * from './astro-sortable-list/index.ts';
|
|
99
102
|
export * from './astro-stat-grid/index.ts';
|
|
100
103
|
export * from './astro-status-indicator/index.ts';
|
|
101
104
|
export * from './astro-steps/index.ts';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createKanbanBoard,
|
|
3
|
+
cardsByColumn,
|
|
4
|
+
visibleAndOverflow,
|
|
5
|
+
type KanbanColumnDef,
|
|
6
|
+
type KanbanBoardAPI,
|
|
7
|
+
} from './kanban-board.js'
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
kanbanBoardVariants,
|
|
11
|
+
kanbanColumnVariants,
|
|
12
|
+
kanbanColumnHeaderClass,
|
|
13
|
+
kanbanColumnTitleClass,
|
|
14
|
+
kanbanColumnCountClass,
|
|
15
|
+
kanbanAccentBarClass,
|
|
16
|
+
kanbanNoteBarClass,
|
|
17
|
+
kanbanColumnBodyClass,
|
|
18
|
+
kanbanOverflowButtonClass,
|
|
19
|
+
kanbanCardVariants,
|
|
20
|
+
} from './kanban-board.styles.js'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { cva } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/** The outer board: horizontal scroll, flex row, gap between columns. */
|
|
4
|
+
export const kanbanBoardVariants = cva({
|
|
5
|
+
base: 'flex flex-row gap-4 overflow-x-auto pb-4',
|
|
6
|
+
variants: {},
|
|
7
|
+
defaultVariants: {},
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** A single column: muted background, rounded, fixed minimum width. */
|
|
11
|
+
export const kanbanColumnVariants = cva({
|
|
12
|
+
base: 'flex flex-col min-w-[260px] max-w-[300px] flex-shrink-0 rounded-xl bg-muted/40',
|
|
13
|
+
variants: {},
|
|
14
|
+
defaultVariants: {},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Column header row: icon slot + title + count badge. */
|
|
18
|
+
export const kanbanColumnHeaderClass =
|
|
19
|
+
'flex items-center gap-2 px-4 pt-4 pb-2'
|
|
20
|
+
|
|
21
|
+
/** Column title text. */
|
|
22
|
+
export const kanbanColumnTitleClass = 'flex-1 text-sm font-semibold text-foreground truncate'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Column count badge: a compact pill to the right of the title.
|
|
26
|
+
* Uses muted tokens so it sits quietly against any column accent.
|
|
27
|
+
*/
|
|
28
|
+
export const kanbanColumnCountClass =
|
|
29
|
+
'ml-auto inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Thin accent bar directly below the header.
|
|
33
|
+
* Callers apply `style={{ '--kanban-accent': def.accent }}` on the column
|
|
34
|
+
* element; this bar reads `var(--kanban-accent)` via an inline style.
|
|
35
|
+
* When no accent is supplied the bar is invisible (transparent).
|
|
36
|
+
*/
|
|
37
|
+
export const kanbanAccentBarClass = 'h-0.5 w-full'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Note/gate bar shown beneath the accent bar.
|
|
41
|
+
* Displays brief stage notes (e.g. "Technical screen required").
|
|
42
|
+
*/
|
|
43
|
+
export const kanbanNoteBarClass =
|
|
44
|
+
'mx-4 mb-2 rounded-md bg-muted px-3 py-1.5 text-xs text-muted-foreground'
|
|
45
|
+
|
|
46
|
+
/** Column body: card stack with gap, padded sides and bottom. */
|
|
47
|
+
export const kanbanColumnBodyClass = 'flex flex-col gap-2 px-3 pb-3'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* "+N more" overflow button.
|
|
51
|
+
* Styled as a subtle full-width ghost button inside the column.
|
|
52
|
+
*/
|
|
53
|
+
export const kanbanOverflowButtonClass = [
|
|
54
|
+
'mt-1 w-full rounded-lg border border-dashed border-border',
|
|
55
|
+
'py-1.5 text-center text-xs text-muted-foreground',
|
|
56
|
+
'transition-colors hover:bg-muted hover:text-foreground',
|
|
57
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
58
|
+
].join(' ')
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Default card surface.
|
|
62
|
+
*
|
|
63
|
+
* Variants:
|
|
64
|
+
* - `clickable: 'true'` — adds hover treatment for interactive cards.
|
|
65
|
+
* - `clickable: 'false'` — static (default).
|
|
66
|
+
*/
|
|
67
|
+
export const kanbanCardVariants = cva({
|
|
68
|
+
base: 'rounded-lg border border-border bg-card p-3 text-sm text-foreground',
|
|
69
|
+
variants: {
|
|
70
|
+
clickable: {
|
|
71
|
+
true: 'cursor-pointer transition-colors hover:bg-muted/60',
|
|
72
|
+
false: '',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
defaultVariants: {
|
|
76
|
+
clickable: 'false',
|
|
77
|
+
},
|
|
78
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** A single column definition for the Kanban board. */
|
|
2
|
+
export type KanbanColumnDef = {
|
|
3
|
+
/** Unique identifier for this column (matches cards' column id). */
|
|
4
|
+
id: string
|
|
5
|
+
/** Display title of the column (e.g. stage name). */
|
|
6
|
+
title: string
|
|
7
|
+
/** Optional accent color applied via CSS var `--kanban-accent`. */
|
|
8
|
+
accent?: string
|
|
9
|
+
/** Optional note or gate description shown below the column header. */
|
|
10
|
+
note?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface KanbanBoardAPI {
|
|
14
|
+
/** ARIA attributes to spread on the board container. */
|
|
15
|
+
ariaProps: { role: 'group'; 'aria-label': string }
|
|
16
|
+
/** Data attributes for styling hooks. */
|
|
17
|
+
dataAttributes: Record<string, string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the framework-agnostic board props.
|
|
22
|
+
*
|
|
23
|
+
* Returns `role="group"` with a fixed accessible label and data attributes
|
|
24
|
+
* so adapters can style based on state without inlining logic.
|
|
25
|
+
*/
|
|
26
|
+
export function createKanbanBoard(): KanbanBoardAPI {
|
|
27
|
+
return {
|
|
28
|
+
ariaProps: {
|
|
29
|
+
role: 'group',
|
|
30
|
+
'aria-label': 'Board',
|
|
31
|
+
},
|
|
32
|
+
dataAttributes: {
|
|
33
|
+
'data-component': 'kanban-board',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bucket cards into a Map keyed by column id, preserving column order.
|
|
40
|
+
* Columns that have no cards get an empty array entry so callers can always
|
|
41
|
+
* iterate `columns` and look up their bucket safely.
|
|
42
|
+
*
|
|
43
|
+
* @param cards - All cards to distribute.
|
|
44
|
+
* @param getColumnId - Selector that reads the column id from a card.
|
|
45
|
+
* @param columns - Column definitions (determines output order).
|
|
46
|
+
*/
|
|
47
|
+
export function cardsByColumn<T>(
|
|
48
|
+
cards: T[],
|
|
49
|
+
getColumnId: (c: T) => string,
|
|
50
|
+
columns: KanbanColumnDef[],
|
|
51
|
+
): Map<string, T[]> {
|
|
52
|
+
const map = new Map<string, T[]>()
|
|
53
|
+
// Pre-populate every column so empty columns are present.
|
|
54
|
+
for (const col of columns) {
|
|
55
|
+
map.set(col.id, [])
|
|
56
|
+
}
|
|
57
|
+
for (const card of cards) {
|
|
58
|
+
const colId = getColumnId(card)
|
|
59
|
+
const bucket = map.get(colId)
|
|
60
|
+
if (bucket !== undefined) {
|
|
61
|
+
bucket.push(card)
|
|
62
|
+
}
|
|
63
|
+
// Cards referencing unknown columns are silently dropped.
|
|
64
|
+
}
|
|
65
|
+
return map
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split a card list into a visible slice and an overflow count.
|
|
70
|
+
*
|
|
71
|
+
* @param cards - Full list of cards in the column.
|
|
72
|
+
* @param cap - Maximum visible cards. Defaults to 5.
|
|
73
|
+
*/
|
|
74
|
+
export function visibleAndOverflow<T>(
|
|
75
|
+
cards: T[],
|
|
76
|
+
cap = 5,
|
|
77
|
+
): { visible: T[]; overflow: number } {
|
|
78
|
+
if (cards.length <= cap) {
|
|
79
|
+
return { visible: cards, overflow: 0 }
|
|
80
|
+
}
|
|
81
|
+
return { visible: cards.slice(0, cap), overflow: cards.length - cap }
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
slotsForDay,
|
|
3
|
+
isSlotSelected,
|
|
4
|
+
createSlotPicker,
|
|
5
|
+
type SlotDay,
|
|
6
|
+
type SlotSelection,
|
|
7
|
+
type SlotPickerAPI,
|
|
8
|
+
} from './slot-picker.js'
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
slotPickerVariants,
|
|
12
|
+
slotPickerDayRowClass,
|
|
13
|
+
slotPickerDayVariants,
|
|
14
|
+
slotPickerGridClass,
|
|
15
|
+
slotPickerSlotVariants,
|
|
16
|
+
slotPickerSectionLabelClass,
|
|
17
|
+
slotPickerTimezoneClass,
|
|
18
|
+
} from './slot-picker.styles.js'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cva } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/** Root wrapper for the entire slot picker (day row + slots grid). */
|
|
4
|
+
export const slotPickerVariants = cva({
|
|
5
|
+
base: 'flex flex-col gap-4',
|
|
6
|
+
variants: {},
|
|
7
|
+
defaultVariants: {},
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** Scrollable row of day chips. */
|
|
11
|
+
export const slotPickerDayRowClass = 'flex items-center gap-2 overflow-x-auto pb-1'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Individual day chip.
|
|
15
|
+
*
|
|
16
|
+
* `selected` → primary treatment; `unselected` → card with border.
|
|
17
|
+
*/
|
|
18
|
+
export const slotPickerDayVariants = cva({
|
|
19
|
+
base: [
|
|
20
|
+
'inline-flex flex-col items-center justify-center rounded-lg px-3 py-2 min-w-[3.5rem]',
|
|
21
|
+
'text-sm font-medium cursor-pointer transition-colors',
|
|
22
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
23
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
24
|
+
].join(' '),
|
|
25
|
+
variants: {
|
|
26
|
+
selected: {
|
|
27
|
+
true: 'bg-primary text-primary-foreground',
|
|
28
|
+
false: 'bg-card border border-input text-foreground hover:bg-muted',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
selected: 'false',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** Grid that holds the time-slot buttons. */
|
|
37
|
+
export const slotPickerGridClass = 'grid grid-cols-3 gap-2 sm:grid-cols-4'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Individual time-slot button.
|
|
41
|
+
*
|
|
42
|
+
* `selected` → primary; `disabled` → dimmed regardless of selection.
|
|
43
|
+
*/
|
|
44
|
+
export const slotPickerSlotVariants = cva({
|
|
45
|
+
base: [
|
|
46
|
+
'inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm font-medium',
|
|
47
|
+
'transition-colors cursor-pointer',
|
|
48
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
|
49
|
+
].join(' '),
|
|
50
|
+
variants: {
|
|
51
|
+
selected: {
|
|
52
|
+
true: 'bg-primary text-primary-foreground border-primary',
|
|
53
|
+
false: 'bg-card border-input text-foreground hover:bg-muted',
|
|
54
|
+
},
|
|
55
|
+
disabled: {
|
|
56
|
+
true: 'opacity-40 pointer-events-none',
|
|
57
|
+
false: '',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
defaultVariants: {
|
|
61
|
+
selected: 'false',
|
|
62
|
+
disabled: 'false',
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** Section label above the day row or slot grid (e.g. "Pick a day"). */
|
|
67
|
+
export const slotPickerSectionLabelClass = 'text-sm font-semibold text-foreground'
|
|
68
|
+
|
|
69
|
+
/** Timezone label rendered beside the "Pick a time" heading. */
|
|
70
|
+
export const slotPickerTimezoneClass = 'text-xs text-muted-foreground ml-2'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** A single day chip shown in the day-selector row. */
|
|
2
|
+
export type SlotDay = {
|
|
3
|
+
/** Unique identifier for the day (e.g. "2024-06-14"). */
|
|
4
|
+
id: string
|
|
5
|
+
/** Short weekday label (e.g. "Fri"). */
|
|
6
|
+
weekday: string
|
|
7
|
+
/** Day-of-month label (e.g. "14" or 14). */
|
|
8
|
+
dayNum: string | number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The currently-selected day + time slot pair. */
|
|
12
|
+
export type SlotSelection = {
|
|
13
|
+
/** The `id` of the selected day. */
|
|
14
|
+
dayId: string
|
|
15
|
+
/** The selected time slot string (e.g. "10:00 AM"). */
|
|
16
|
+
slot: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return the list of available time slots for a given day.
|
|
21
|
+
* Returns an empty array when `dayId` has no entry in `slotsByDay`.
|
|
22
|
+
*/
|
|
23
|
+
export function slotsForDay(
|
|
24
|
+
slotsByDay: Record<string, string[]>,
|
|
25
|
+
dayId: string,
|
|
26
|
+
): string[] {
|
|
27
|
+
return slotsByDay[dayId] ?? []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check whether a specific slot in a specific day is currently selected.
|
|
32
|
+
*/
|
|
33
|
+
export function isSlotSelected(
|
|
34
|
+
value: SlotSelection | undefined,
|
|
35
|
+
dayId: string,
|
|
36
|
+
slot: string,
|
|
37
|
+
): boolean {
|
|
38
|
+
return value?.dayId === dayId && value?.slot === slot
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SlotPickerAPI {
|
|
42
|
+
/** ARIA attributes to spread on the root group element. */
|
|
43
|
+
ariaProps: { role: 'group'; 'aria-label': string }
|
|
44
|
+
/** Data attributes for styling hooks. */
|
|
45
|
+
dataAttributes: Record<string, string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the framework-agnostic root props for a slot picker.
|
|
50
|
+
*
|
|
51
|
+
* Returns `role="group"` with an accessible label and data attributes.
|
|
52
|
+
* Adapters spread these onto their container element.
|
|
53
|
+
*/
|
|
54
|
+
export function createSlotPicker(): SlotPickerAPI {
|
|
55
|
+
return {
|
|
56
|
+
ariaProps: {
|
|
57
|
+
role: 'group',
|
|
58
|
+
'aria-label': 'Pick a time',
|
|
59
|
+
},
|
|
60
|
+
dataAttributes: {
|
|
61
|
+
'data-component': 'slot-picker',
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
reorder,
|
|
3
|
+
getNextOrderIndex,
|
|
4
|
+
createSortableList,
|
|
5
|
+
type SortableListAPI,
|
|
6
|
+
} from './sortable-list.js'
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
sortableListVariants,
|
|
10
|
+
sortableListRowVariants,
|
|
11
|
+
sortableListGripClass,
|
|
12
|
+
sortableListContentClass,
|
|
13
|
+
} from './sortable-list.styles.js'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { cva } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
/** The outer list container. */
|
|
4
|
+
export const sortableListVariants = cva({
|
|
5
|
+
base: 'flex flex-col gap-1 w-full',
|
|
6
|
+
variants: {
|
|
7
|
+
disabled: {
|
|
8
|
+
true: 'opacity-50 pointer-events-none',
|
|
9
|
+
false: '',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
defaultVariants: {
|
|
13
|
+
disabled: 'false',
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single row in the list. The `dragging` variant visually lifts the row
|
|
19
|
+
* while it is being dragged.
|
|
20
|
+
*/
|
|
21
|
+
export const sortableListRowVariants = cva({
|
|
22
|
+
base: [
|
|
23
|
+
'flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2',
|
|
24
|
+
'transition-opacity',
|
|
25
|
+
].join(' '),
|
|
26
|
+
variants: {
|
|
27
|
+
dragging: {
|
|
28
|
+
true: 'opacity-50 ring-2 ring-ring',
|
|
29
|
+
false: '',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
dragging: 'false',
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** The drag-grip handle button/icon area. */
|
|
38
|
+
export const sortableListGripClass =
|
|
39
|
+
'cursor-grab text-muted-foreground shrink-0 flex items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded'
|
|
40
|
+
|
|
41
|
+
/** Wraps the caller-supplied row content (fills remaining space). */
|
|
42
|
+
export const sortableListContentClass = 'flex-1 min-w-0'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, JSX-free headless logic for a drag-to-reorder vertical list.
|
|
3
|
+
*
|
|
4
|
+
* Three exports:
|
|
5
|
+
* - `reorder` — immutable array move, clamped
|
|
6
|
+
* - `getNextOrderIndex` — keyboard navigation rule (clamped, no wrap)
|
|
7
|
+
* - `createSortableList` — builds ARIA props for the list container
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SortableListAPI {
|
|
11
|
+
/** ARIA attributes to spread on the list element. */
|
|
12
|
+
ariaProps: Record<string, string | number | boolean>
|
|
13
|
+
/** Data attributes for styling hooks. */
|
|
14
|
+
dataAttributes: Record<string, string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Move an item from index `from` to index `to` in `items`, returning a new
|
|
19
|
+
* array. Out-of-range indices return a shallow copy of the original array
|
|
20
|
+
* (no mutation, no throw).
|
|
21
|
+
*/
|
|
22
|
+
export function reorder<T>(items: T[], from: number, to: number): T[] {
|
|
23
|
+
const len = items.length
|
|
24
|
+
if (
|
|
25
|
+
len === 0 ||
|
|
26
|
+
from < 0 ||
|
|
27
|
+
from >= len ||
|
|
28
|
+
to < 0 ||
|
|
29
|
+
to >= len ||
|
|
30
|
+
from === to
|
|
31
|
+
) {
|
|
32
|
+
return [...items]
|
|
33
|
+
}
|
|
34
|
+
const result = [...items]
|
|
35
|
+
const [moved] = result.splice(from, 1)
|
|
36
|
+
result.splice(to, 0, moved)
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keyboard reorder navigation — returns the next index for a given key.
|
|
42
|
+
*
|
|
43
|
+
* - ArrowUp / ArrowLeft → max(0, current - 1)
|
|
44
|
+
* - ArrowDown / ArrowRight → min(count - 1, current + 1)
|
|
45
|
+
* - Home → 0
|
|
46
|
+
* - End → count - 1
|
|
47
|
+
* - All other keys / empty list → current (clamped to valid range)
|
|
48
|
+
*
|
|
49
|
+
* Never wraps.
|
|
50
|
+
*/
|
|
51
|
+
export function getNextOrderIndex(
|
|
52
|
+
current: number,
|
|
53
|
+
key: string,
|
|
54
|
+
count: number,
|
|
55
|
+
): number {
|
|
56
|
+
if (count <= 0) return current
|
|
57
|
+
const clamped = Math.max(0, Math.min(current, count - 1))
|
|
58
|
+
|
|
59
|
+
switch (key) {
|
|
60
|
+
case 'ArrowUp':
|
|
61
|
+
case 'ArrowLeft':
|
|
62
|
+
return Math.max(0, clamped - 1)
|
|
63
|
+
case 'ArrowDown':
|
|
64
|
+
case 'ArrowRight':
|
|
65
|
+
return Math.min(count - 1, clamped + 1)
|
|
66
|
+
case 'Home':
|
|
67
|
+
return 0
|
|
68
|
+
case 'End':
|
|
69
|
+
return count - 1
|
|
70
|
+
default:
|
|
71
|
+
return clamped
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the framework-agnostic props for a sortable list container.
|
|
77
|
+
*
|
|
78
|
+
* Returns `role="list"` so screen readers announce the number of items
|
|
79
|
+
* correctly, plus a `data-component` hook for styling.
|
|
80
|
+
*/
|
|
81
|
+
export function createSortableList(): SortableListAPI {
|
|
82
|
+
return {
|
|
83
|
+
ariaProps: {
|
|
84
|
+
role: 'list',
|
|
85
|
+
},
|
|
86
|
+
dataAttributes: {
|
|
87
|
+
'data-component': 'sortable-list',
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@refraction-ui/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "All Refraction UI Astro components in one package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"@refraction-ui/astro-input": "workspace:*",
|
|
77
77
|
"@refraction-ui/astro-input-group": "workspace:*",
|
|
78
78
|
"@refraction-ui/astro-install-prompt": "workspace:*",
|
|
79
|
+
"@refraction-ui/astro-kanban-board": "workspace:*",
|
|
79
80
|
"@refraction-ui/astro-keyboard-shortcut": "workspace:*",
|
|
80
81
|
"@refraction-ui/astro-language-selector": "workspace:*",
|
|
81
82
|
"@refraction-ui/astro-link-card": "workspace:*",
|
|
@@ -115,7 +116,9 @@
|
|
|
115
116
|
"@refraction-ui/astro-skip-to-content": "workspace:*",
|
|
116
117
|
"@refraction-ui/astro-slide-viewer": "workspace:*",
|
|
117
118
|
"@refraction-ui/astro-slider": "workspace:*",
|
|
119
|
+
"@refraction-ui/astro-slot-picker": "workspace:*",
|
|
118
120
|
"@refraction-ui/astro-social-auth-button": "workspace:*",
|
|
121
|
+
"@refraction-ui/astro-sortable-list": "workspace:*",
|
|
119
122
|
"@refraction-ui/astro-stat-grid": "workspace:*",
|
|
120
123
|
"@refraction-ui/astro-status-indicator": "workspace:*",
|
|
121
124
|
"@refraction-ui/astro-steps": "workspace:*",
|