@santi020k/lumen-astro 1.0.0 → 1.2.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/README.md CHANGED
@@ -51,6 +51,40 @@ import { Button, Card, Input } from '@santi020k/lumen-astro'
51
51
  </Card>
52
52
  ```
53
53
 
54
+ `Input` and `NativeSelect` preserve the native numeric `size` attribute. Use `visualSize="sm"` or
55
+ `visualSize="lg"` for presentation. The pre-1.0 `size="sm|lg"` visual alias remains available for
56
+ incremental migrations, but new code should keep native behavior and visual styling separate.
57
+
58
+ ```astro
59
+ <Input size={32} visualSize="sm" />
60
+ <NativeSelect size={8} visualSize="lg" />
61
+ ```
62
+
63
+ ## Context navigation
64
+
65
+ Use `ContextNavigation` below a primary header when the available links change with a selected
66
+ platform, product, or workspace. Put the stable selector or identity in the `context` slot and one
67
+ independently named `NavigationMenu` in the default slot. Long link sets scroll horizontally.
68
+
69
+ ```astro
70
+ ---
71
+ import { ContextNavigation, NativeSelect, NavigationMenu } from '@santi020k/lumen-astro'
72
+ ---
73
+
74
+ <ContextNavigation>
75
+ <NativeSelect slot="context" aria-label="Documentation platform" visualSize="sm">
76
+ <option>Web</option>
77
+ <option>Apple</option>
78
+ <option>Android</option>
79
+ </NativeSelect>
80
+ <NavigationMenu aria-label="Web documentation" variant="unstyled">
81
+ <a href="/docs/web" aria-current="page">Overview</a>
82
+ <a href="/docs/components">Components</a>
83
+ <a href="/docs/web/playground">Playground</a>
84
+ </NavigationMenu>
85
+ </ContextNavigation>
86
+ ```
87
+
54
88
  ## Forms and Astro Actions
55
89
 
56
90
  `Form` renders a semantic form and preserves native `method`, `action`, `enctype`, autocomplete,
@@ -218,6 +252,14 @@ with `--ui-logo-delay`.
218
252
 
219
253
  The package ships the complete Astro primitive catalog plus a small progressive-enhancement runtime for dialogs, popovers, tabs, menus, command filtering, carousels, and toasts.
220
254
 
255
+ ## Kanban boards
256
+
257
+ Compose `KanbanBoard` and `KanbanColumn` with ordinary `Card` items. Give every item a stable
258
+ `data-ui-kanban-item` and add a dedicated button with `data-ui-kanban-handle`. `UIPrimitives` emits
259
+ the cancellable `ui:kanban-move-request` event for keyboard, mouse, and touch movement without
260
+ changing the DOM. Keep persistence, pending state, rollback, and status rules in the application.
261
+ Use `Empty variant="compact"` inside empty columns and `Skeleton` cards while loading.
262
+
221
263
  ## Motion
222
264
 
223
265
  Use the shared `fast`, `standard`, and `slow` duration vocabulary across motion primitives.
@@ -0,0 +1,37 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
5
+
6
+ interface Props extends HTMLAttributes<'div'> {
7
+ class?: string | null
8
+ className?: string
9
+ variant?: 'default' | 'unstyled'
10
+ }
11
+
12
+ const {
13
+ class: classProp = '',
14
+ className = '',
15
+ variant = 'default',
16
+ ...rest
17
+ } = Astro.props
18
+
19
+ const { classList, passthrough } = resolveLumenAstroProps(rest, [
20
+ 'ui-context-navigation',
21
+ variant === 'unstyled' && 'ui-context-navigation--unstyled'
22
+ ], classProp, className)
23
+ ---
24
+
25
+ <div
26
+ class:list={classList}
27
+ data-slot="context-navigation"
28
+ data-variant={variant}
29
+ {...passthrough}
30
+ >
31
+ {Astro.slots.has('context') && (
32
+ <div class="ui-context-navigation__context" data-slot="context-navigation-context">
33
+ <slot name="context" />
34
+ </div>
35
+ )}
36
+ <slot />
37
+ </div>
@@ -7,17 +7,20 @@ interface Props extends HTMLAttributes<'section'> {
7
7
  class?: string | null
8
8
  className?: string
9
9
  glass?: boolean | 'subtle' | 'strong'
10
+ variant?: 'compact' | 'default'
10
11
  }
11
12
 
12
13
  const {
13
14
  class: classProp = '',
14
15
  className = '',
15
16
  glass = false,
17
+ variant = 'default',
16
18
  ...rest
17
19
  } = Astro.props
18
20
 
19
21
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
20
22
  'ui-empty',
23
+ variant === 'compact' && 'ui-empty--compact',
21
24
  glass && 'ui-empty--glass',
22
25
  glass === 'subtle' && 'ui-glass-subtle',
23
26
  glass === 'strong' && 'ui-glass-strong'
@@ -6,20 +6,33 @@ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
6
6
  interface Props extends Omit<HTMLAttributes<'input'>, 'size'> {
7
7
  class?: string | null
8
8
  className?: string
9
- size?: 'default' | 'lg' | 'sm'
9
+ size?: HTMLAttributes<'input'>['size']
10
+ visualSize?: 'default' | 'lg' | 'sm'
10
11
  }
11
12
 
12
- const { class: classProp = '', className = '', size = 'default', type = 'text', ...rest } = Astro.props
13
+ const {
14
+ class: classProp = '',
15
+ className = '',
16
+ size,
17
+ type = 'text',
18
+ visualSize,
19
+ ...rest
20
+ } = Astro.props
21
+
22
+ const legacyVisualSize = size === 'default' || size === 'lg' || size === 'sm' ? size : undefined
23
+ const resolvedVisualSize = visualSize ?? legacyVisualSize ?? 'default'
24
+ const nativeSize = legacyVisualSize ? undefined : size
13
25
 
14
26
  const { classList, passthrough } = resolveLumenAstroProps(rest, [
15
27
  'ui-input',
16
- size === 'sm' && 'ui-input--sm',
17
- size === 'lg' && 'ui-input--lg'
28
+ resolvedVisualSize === 'sm' && 'ui-input--sm',
29
+ resolvedVisualSize === 'lg' && 'ui-input--lg'
18
30
  ], classProp, className)
19
31
  ---
20
32
 
21
33
  <input
22
34
  class:list={classList}
35
+ size={nativeSize}
23
36
  type={type}
24
37
  {...passthrough}
25
38
  />
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
5
+
6
+ interface Props extends HTMLAttributes<'section'> {
7
+ class?: string | null
8
+ className?: string
9
+ }
10
+
11
+ const {
12
+ class: classProp = '',
13
+ className = '',
14
+ tabindex = '0',
15
+ ...rest
16
+ } = Astro.props
17
+
18
+ const { classList, passthrough } = resolveLumenAstroProps(rest, [
19
+ 'ui-kanban-board',
20
+ 'ui-kanban'
21
+ ], classProp, className)
22
+ ---
23
+
24
+ <section
25
+ aria-orientation="horizontal"
26
+ class:list={classList}
27
+ data-ui-kanban
28
+ tabindex={tabindex}
29
+ {...passthrough}
30
+ >
31
+ <slot />
32
+ </section>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types'
3
+
4
+ import { resolveLumenAstroProps } from '@santi020k/lumen-core'
5
+
6
+ interface Props extends HTMLAttributes<'section'> {
7
+ class?: string | null
8
+ className?: string
9
+ value: string
10
+ }
11
+
12
+ const {
13
+ class: classProp = '',
14
+ className = '',
15
+ value,
16
+ ...rest
17
+ } = Astro.props
18
+
19
+ const { classList, passthrough } = resolveLumenAstroProps(rest, [
20
+ 'ui-kanban__column'
21
+ ], classProp, className)
22
+ ---
23
+
24
+ <section
25
+ class:list={classList}
26
+ data-ui-kanban-column={value}
27
+ {...passthrough}
28
+ >
29
+ <slot />
30
+ </section>
@@ -14,20 +14,33 @@ type SelectOption = Option | string
14
14
  interface Props extends Omit<HTMLAttributes<'select'>, 'size'> {
15
15
  class?: string | null
16
16
  className?: string
17
- size?: 'default' | 'lg' | 'sm'
17
+ size?: HTMLAttributes<'select'>['size']
18
18
  options?: SelectOption[]
19
19
  placeholder?: string
20
+ visualSize?: 'default' | 'lg' | 'sm'
20
21
  }
21
22
 
22
- const { class: classProp = '', className = '', options = [], placeholder, size = 'default', ...rest } = Astro.props
23
+ const {
24
+ class: classProp = '',
25
+ className = '',
26
+ options = [],
27
+ placeholder,
28
+ size,
29
+ visualSize,
30
+ ...rest
31
+ } = Astro.props
32
+
23
33
  const hasValue = Object.hasOwn(rest, 'value')
24
34
  const normalizedOptions = options.map(option => typeof option === 'string' ? { label: option, value: option } : option)
35
+ const legacyVisualSize = size === 'default' || size === 'lg' || size === 'sm' ? size : undefined
36
+ const resolvedVisualSize = visualSize ?? legacyVisualSize ?? 'default'
37
+ const nativeSize = legacyVisualSize ? undefined : size
25
38
 
26
- const { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-select', size === 'sm' && 'ui-select--sm', size === 'lg' && 'ui-select--lg'
39
+ const { classList, passthrough } = resolveLumenAstroProps(rest, ['ui-select', resolvedVisualSize === 'sm' && 'ui-select--sm', resolvedVisualSize === 'lg' && 'ui-select--lg'
27
40
  ], classProp, className)
28
41
  ---
29
42
 
30
- <select class:list={classList} {...passthrough}>
43
+ <select class:list={classList} size={nativeSize} {...passthrough}>
31
44
  {placeholder && <option disabled selected={!hasValue} value="">{placeholder}</option>}
32
45
  <slot />
33
46
  {normalizedOptions.map(option => (
package/index.ts CHANGED
@@ -40,6 +40,7 @@ export { default as Combobox } from './components/Combobox.astro'
40
40
  export { default as Command } from './components/Command.astro'
41
41
  export { default as Container } from './components/Container.astro'
42
42
  export { default as ContextMenu } from './components/ContextMenu.astro'
43
+ export { default as ContextNavigation } from './components/ContextNavigation.astro'
43
44
  export { default as CopyButton } from './components/CopyButton.astro'
44
45
  export { default as CoverImage } from './components/CoverImage.astro'
45
46
  export { default as DataTable } from './components/DataTable.astro'
@@ -68,6 +69,8 @@ export { default as Input } from './components/Input.astro'
68
69
  export { default as InputGroup } from './components/InputGroup.astro'
69
70
  export { default as InputOTP } from './components/InputOTP.astro'
70
71
  export { default as Item } from './components/Item.astro'
72
+ export { default as KanbanBoard } from './components/KanbanBoard.astro'
73
+ export { default as KanbanColumn } from './components/KanbanColumn.astro'
71
74
  export { default as Kbd } from './components/Kbd.astro'
72
75
  export { default as Label } from './components/Label.astro'
73
76
  export { default as LanguageToggle } from './components/LanguageToggle.astro'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@santi020k/lumen-astro",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -37,12 +37,12 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "uqr": "^0.1.3",
40
- "@santi020k/lumen": "^1.0.0",
41
- "@santi020k/lumen-core": "^1.0.0"
40
+ "@santi020k/lumen": "^1.2.0",
41
+ "@santi020k/lumen-core": "^1.2.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@astrojs/ts-plugin": "^1.10.10",
45
- "astro": "^7.1.3",
45
+ "astro": "^7.2.0",
46
46
  "typescript": "^6.0.3",
47
47
  "vitest": "^4.1.10"
48
48
  },
@@ -57,10 +57,19 @@
57
57
  "url": "https://github.com/santi020k/lumen.git",
58
58
  "directory": "packages/astro"
59
59
  },
60
- "homepage": "https://lumen.santi020k.com",
60
+ "homepage": "https://lumen.santi020k.com/docs/frameworks/astro",
61
61
  "bugs": {
62
62
  "url": "https://github.com/santi020k/lumen/issues"
63
63
  },
64
+ "keywords": [
65
+ "astro",
66
+ "astro-components",
67
+ "accessible-ui",
68
+ "component-library",
69
+ "design-system",
70
+ "lumen",
71
+ "ui"
72
+ ],
64
73
  "author": {
65
74
  "name": "Santiago Molina",
66
75
  "url": "https://santi020k.com"
@@ -10,6 +10,7 @@
10
10
 
11
11
  <script>
12
12
  import {
13
+ createLumenKanbanMoveDetail,
13
14
  createThemeFromHue,
14
15
  exportThemeCss,
15
16
  exportThemeDesignTokens,
@@ -3237,6 +3238,239 @@ const initSchedules = (scope: ParentNode): void => {
3237
3238
  }
3238
3239
  }
3239
3240
 
3241
+ const kanbanColumnSelector = '[data-ui-kanban-column]'
3242
+
3243
+ const initKanbanBoards = (scope: ParentNode): void => {
3244
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-kanban]')) {
3245
+ if (root.dataset.uiKanbanBound === 'true') continue
3246
+
3247
+ root.dataset.uiKanbanBound = 'true'
3248
+
3249
+ for (const handle of root.querySelectorAll<HTMLElement>('[data-ui-kanban-handle]'))
3250
+ handle.draggable = true
3251
+
3252
+ let pointer: {
3253
+ active: boolean
3254
+ handle: HTMLElement
3255
+ item: HTMLElement
3256
+ pointerId: number
3257
+ startX: number
3258
+ startY: number
3259
+ } | undefined
3260
+
3261
+ const itemFor = (target: EventTarget | null): HTMLElement | null => target instanceof Element ?
3262
+ target.closest<HTMLElement>('[data-ui-kanban-item]') :
3263
+ null
3264
+
3265
+ const columnFor = (target: EventTarget | null): HTMLElement | null => target instanceof Element ?
3266
+ target.closest<HTMLElement>(kanbanColumnSelector) :
3267
+ null
3268
+
3269
+ const clearState = (): void => {
3270
+ pointer = undefined
3271
+
3272
+ for (const item of root.querySelectorAll<HTMLElement>('[data-ui-kanban-item]'))
3273
+ if (item.dataset.state === 'dragging') delete item.dataset.state
3274
+
3275
+ for (const column of root.querySelectorAll<HTMLElement>(kanbanColumnSelector))
3276
+ if (column.dataset.state === 'drop-target') delete column.dataset.state
3277
+ }
3278
+
3279
+ const announce = (column: HTMLElement): void => {
3280
+ let live = root.querySelector<HTMLElement>('[data-ui-kanban-live]')
3281
+
3282
+ if (!live) {
3283
+ live = document.createElement('span')
3284
+
3285
+ live.className = 'ui-visually-hidden'
3286
+
3287
+ live.dataset.uiKanbanLive = ''
3288
+
3289
+ live.setAttribute('aria-live', 'polite')
3290
+
3291
+ live.setAttribute('role', 'status')
3292
+
3293
+ root.append(live)
3294
+ }
3295
+
3296
+ const label = column.getAttribute('aria-label') ||
3297
+ column.querySelector('h2, h3, [data-ui-kanban-column-label]')?.textContent?.trim() ||
3298
+ column.dataset.uiKanbanColumn || ''
3299
+
3300
+ live.textContent = `Move requested to ${label}.`
3301
+ }
3302
+
3303
+ const requestMove = (
3304
+ item: HTMLElement,
3305
+ targetColumn: HTMLElement,
3306
+ input: 'keyboard' | 'pointer'
3307
+ ): void => {
3308
+ const fromColumn = item.closest<HTMLElement>(kanbanColumnSelector)
3309
+ ?.dataset.uiKanbanColumn ?? ''
3310
+
3311
+ const detail = createLumenKanbanMoveDetail({
3312
+ fromColumn,
3313
+ input,
3314
+ itemId: item.dataset.uiKanbanItem ?? item.id,
3315
+ toColumn: targetColumn.dataset.uiKanbanColumn ?? ''
3316
+ })
3317
+
3318
+ if (!detail) return
3319
+
3320
+ const accepted = root.dispatchEvent(new CustomEvent('ui:kanban-move-request', {
3321
+ bubbles: true,
3322
+ cancelable: true,
3323
+ detail
3324
+ }))
3325
+
3326
+ if (accepted) announce(targetColumn)
3327
+ }
3328
+
3329
+ root.addEventListener('dragstart', event => {
3330
+ const handle = event.target instanceof Element ?
3331
+ event.target.closest<HTMLElement>('[data-ui-kanban-handle]') :
3332
+ null
3333
+
3334
+ const item = itemFor(handle)
3335
+
3336
+ if (!handle || !item || item.getAttribute('aria-busy') === 'true') {
3337
+ event.preventDefault()
3338
+
3339
+ return
3340
+ }
3341
+
3342
+ event.dataTransfer?.setData('text/plain', item.dataset.uiKanbanItem ?? item.id)
3343
+
3344
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
3345
+
3346
+ item.dataset.state = 'dragging'
3347
+ })
3348
+
3349
+ root.addEventListener('dragend', clearState)
3350
+
3351
+ root.addEventListener('dragover', event => {
3352
+ const column = columnFor(event.target)
3353
+
3354
+ if (!column) return
3355
+
3356
+ event.preventDefault()
3357
+
3358
+ column.dataset.state = 'drop-target'
3359
+
3360
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
3361
+ })
3362
+
3363
+ root.addEventListener('dragleave', event => {
3364
+ const column = columnFor(event.target)
3365
+
3366
+ if (
3367
+ !column ||
3368
+ (event.relatedTarget instanceof Node && column.contains(event.relatedTarget))
3369
+ ) return
3370
+
3371
+ delete column.dataset.state
3372
+ })
3373
+
3374
+ root.addEventListener('drop', event => {
3375
+ const column = columnFor(event.target)
3376
+ const itemId = event.dataTransfer?.getData('text/plain') ?? ''
3377
+
3378
+ const item = [...root.querySelectorAll<HTMLElement>('[data-ui-kanban-item]')]
3379
+ .find(candidate => (candidate.dataset.uiKanbanItem ?? candidate.id) === itemId)
3380
+
3381
+ if (!column || !item) return
3382
+
3383
+ event.preventDefault()
3384
+
3385
+ requestMove(item, column, 'pointer')
3386
+
3387
+ clearState()
3388
+ })
3389
+
3390
+ root.addEventListener('keydown', event => {
3391
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
3392
+
3393
+ const handle = event.target instanceof Element ?
3394
+ event.target.closest<HTMLElement>('[data-ui-kanban-handle]') :
3395
+ null
3396
+
3397
+ const item = itemFor(handle)
3398
+ const currentColumn = columnFor(item)
3399
+
3400
+ if (!handle || !item || !currentColumn || item.getAttribute('aria-busy') === 'true') return
3401
+
3402
+ const columns = [...root.querySelectorAll<HTMLElement>(kanbanColumnSelector)]
3403
+ const currentIndex = columns.indexOf(currentColumn)
3404
+ const direction = event.key === 'ArrowRight' ? 1 : -1
3405
+ const rtl = getComputedStyle(root).direction === 'rtl'
3406
+ const target = columns[currentIndex + (rtl ? -direction : direction)]
3407
+
3408
+ if (!target) return
3409
+
3410
+ event.preventDefault()
3411
+
3412
+ requestMove(item, target, 'keyboard')
3413
+ })
3414
+
3415
+ root.addEventListener('pointerdown', event => {
3416
+ if (event.pointerType === 'mouse') return
3417
+
3418
+ const handle = event.target instanceof Element ?
3419
+ event.target.closest<HTMLElement>('[data-ui-kanban-handle]') :
3420
+ null
3421
+
3422
+ const item = itemFor(handle)
3423
+
3424
+ if (!handle || !item || item.getAttribute('aria-busy') === 'true') return
3425
+
3426
+ pointer = {
3427
+ active: false,
3428
+ handle,
3429
+ item,
3430
+ pointerId: event.pointerId,
3431
+ startX: event.clientX,
3432
+ startY: event.clientY
3433
+ }
3434
+ })
3435
+
3436
+ root.addEventListener('pointermove', event => {
3437
+ if (!pointer || pointer.pointerId !== event.pointerId) return
3438
+
3439
+ if (!pointer.active && Math.hypot(
3440
+ event.clientX - pointer.startX,
3441
+ event.clientY - pointer.startY
3442
+ ) < 8) return
3443
+
3444
+ pointer.active = true
3445
+
3446
+ pointer.handle.setPointerCapture(event.pointerId)
3447
+
3448
+ pointer.item.dataset.state = 'dragging'
3449
+
3450
+ const target = document.elementFromPoint(event.clientX, event.clientY)
3451
+ ?.closest<HTMLElement>(kanbanColumnSelector)
3452
+
3453
+ for (const column of root.querySelectorAll<HTMLElement>(kanbanColumnSelector)) {
3454
+ if (column === target) column.dataset.state = 'drop-target'
3455
+ else delete column.dataset.state
3456
+ }
3457
+ })
3458
+
3459
+ root.addEventListener('pointerup', event => {
3460
+ if (!pointer || pointer.pointerId !== event.pointerId) return
3461
+
3462
+ const target = document.elementFromPoint(event.clientX, event.clientY)
3463
+ ?.closest<HTMLElement>(kanbanColumnSelector)
3464
+
3465
+ if (pointer.active && target) requestMove(pointer.item, target, 'pointer')
3466
+
3467
+ clearState()
3468
+ })
3469
+
3470
+ root.addEventListener('pointercancel', clearState)
3471
+ }
3472
+ }
3473
+
3240
3474
  const initTreeCollections = (scope: ParentNode): void => {
3241
3475
  for (const root of scope.querySelectorAll<HTMLElement>('[role="tree"], [role="treegrid"]')) {
3242
3476
  if (root.dataset.uiBound === 'true') continue
@@ -5252,6 +5486,8 @@ const initUiPrimitives = (scope: ParentNode = document): void => {
5252
5486
 
5253
5487
  initSchedules(scope)
5254
5488
 
5489
+ initKanbanBoards(scope)
5490
+
5255
5491
  initTreeCollections(scope)
5256
5492
 
5257
5493
  initVirtualLists(scope)