Repository: IduxFE/components (ts)

Issue:
[cdk:breakpoint] add breakpoint system and observeBreakpoint
- [x] I have searched the [issues](https://github.com/IduxFE/components/issues) of this repository and believe that this is not a duplicate.

### What problem does this feature solve?
<!-- Explain your use case, context, and rationale behind this feature request. More importantly, what is the end user experience you are trying to build that led to the need for this feature? -->
- 栅格断点系统
- 支持响应式

### What does the proposed API look like?
<!-- Describe how you propose to solve the problem and provide code samples of how the API would work once implemented. Note that you can use Markdown to format your code blocks. -->

```ts
export enum Breakpoints = {
  XSmall: '(max-width: 599.99px)',
  Small: '(min-width: 768px) and (max-width: 1023.99px)',
  Medium: '(min-width: 1024px) and (max-width: 1279.99px)',
  Large: '(min-width: 1280px) and (max-width: 1719.99px)',
  XLarge: '(min-width: 1720px)',
}
```

```ts
// 非响应式
const isXSmall  = isMatchedBreakpoint(Breakpoints.XSmall)
// 响应式
const layoutChange = observeBreakpoint(Breakpoints.XSmall)
// const layoutChange = observeBreakpoint([Breakpoints.XSmall, Breakpoints.Large])
watchEffect(() => {
 console.log('breakpoints', layoutChanges.value.breakpoints)
  console.log('matches', layoutChanges.value.matches)
})
// 清空观察者
cleanObservables()
```

Pull request:
feat(cdk:breakpoint): add breakpoint system and observables
fix #44

## PR Checklist

Please check if your PR fulfills the following requirements:

- [x] The commit message follows [our guidelines](https://github.com/IduxFE/components/blob/main/docs/contributing.zh.md#commit)
- [x] Tests for the changes have been added/updated or not needed
- [x] Docs and demo have been added/updated or not needed

## PR Type

What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Component style update
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Application (the showcase website) / infrastructure changes
- [ ] Other... Please describe:

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

## What is the new behavior?

## Other information


Patch:
diff --git a/packages/cdk/breakpoint/demo/basic.md b/packages/cdk/breakpoint/demo/basic.md
new file mode 100644
index 00000000..c54c7211
--- /dev/null
+++ b/packages/cdk/breakpoint/demo/basic.md
@@ -0,0 +1,37 @@
+---
+order: 0
+title:
+  zh: 基本使用
+  en: Basic usage
+---
+
+## zh
+
+## demo
+
+```html
+<template>
+  <div>
+    <p>isSmallScreen: {{ isSmallScreen }}</p>
+    <p>layoutChanges: {{ layoutChanges.matches }} </p>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, watchEffect } from 'vue'
+import { Breakpoints, isMatchedBreakpoint, observeBreakpoint } from '@idux/cdk/breakpoint'
+
+export default defineComponent({
+  setup() {
+    const { Small, Large } = Breakpoints
+    const isSmallScreen = isMatchedBreakpoint(Small)
+    const layoutChanges = observeBreakpoint([Small, Large])
+    watchEffect(() => {
+      console.log('breakpoints', layoutChanges.value.breakpoints)
+      console.log('matches', layoutChanges.value.matches)
+    })
+    return { isSmallScreen, layoutChanges }
+  },
+})
+</script>
+```
diff --git a/packages/cdk/breakpoint/docs/index.zh.md b/packages/cdk/breakpoint/docs/index.zh.md
new file mode 100644
index 00000000..73e31f0a
--- /dev/null
+++ b/packages/cdk/breakpoint/docs/index.zh.md
@@ -0,0 +1,63 @@
+---
+category: cdk
+type:
+title: Breakpoint
+subtitle: 断点
+cover:
+---
+
+提供构建响应式系统的工具, 以响应屏幕尺寸的变化：
+
+## API
+
+### Breakpoints
+
+默认提供的 5 个断点
+
+| 属性 | 说明 |
+| --- | --- |
+| `XSmall` | `< 768px` |
+| `Small` | `≥ 768px && < 1024px` |
+| `Medium` | `≥ 1024px && < 1280px` |
+| `Large` | `≥ 1280px && < 1720px` |
+| `Medium` | `≥ 1720px` |
+
+### isMatchedBreakpoint
+
+非响应式的断点匹配, 参数支持: `string | string[]`
+
+```ts
+const { Small, Small } = Breakpoints
+const isXSmallScreen = isMatchedBreakpoint(Small)
+const isSmallOrLarge = isMatchedBreakpoint([XSmall, Small])
+```
+
+### observeBreakpoint
+
+断点观察者, 响应式的监听断点变化, 参数支持: `string | string[]`
+
+```ts
+const { Small, Small } = Breakpoints
+const layoutChanges = observeBreakpoint([XSmall, Small])
+
+watchEffect(() => {
+  console.log('breakpoints', layoutChanges.value.breakpoints)
+  console.log('matches', layoutChanges.value.matches)
+})
+```
+
+### cleanObservables
+
+用于清空断点观察者的缓存
+
+```ts
+cleanObservables()
+```
+
+### matchMedia
+
+媒体匹配器，主要用于磨皮各个浏览器的实现差异, 参数支持：`string`
+
+```ts
+const mediaQueryList = mediaMatcher.matchMedia(Breakpoints.Small);
+```
diff --git a/packages/cdk/breakpoint/index.ts b/packages/cdk/breakpoint/index.ts
new file mode 100644
index 00000000..3091b721
--- /dev/null
+++ b/packages/cdk/breakpoint/index.ts
@@ -0,0 +1,1 @@
+export * from './src/index'
diff --git a/packages/cdk/breakpoint/src/breakpoints.ts b/packages/cdk/breakpoint/src/breakpoints.ts
new file mode 100644
index 00000000..aae2a1ec
--- /dev/null
+++ b/packages/cdk/breakpoint/src/breakpoints.ts
@@ -0,0 +1,7 @@
+export const Breakpoints = {
+  XSmall: '(max-width: 767.99px)',
+  Small: '(min-width: 768px) and (max-width: 1023.99px)',
+  Medium: '(min-width: 1024px) and (max-width: 1279.99px)',
+  Large: '(min-width: 1280px) and (max-width: 1719.99px)',
+  XLarge: '(min-width: 1720px)',
+}
diff --git a/packages/cdk/breakpoint/src/index.ts b/packages/cdk/breakpoint/src/index.ts
new file mode 100644
index 00000000..40b07749
--- /dev/null
+++ b/packages/cdk/breakpoint/src/index.ts
@@ -0,0 +1,3 @@
+export * from './observe'
+export * from './breakpoints'
+export * from './mediaMatcher'
diff --git a/packages/cdk/breakpoint/src/mediaMatcher.ts b/packages/cdk/breakpoint/src/mediaMatcher.ts
new file mode 100644
index 00000000..20b978ce
--- /dev/null
+++ b/packages/cdk/breakpoint/src/mediaMatcher.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import { isBrowser, isWebKit } from '@idux/cdk/platform'
+
+/** Global registry for all dynamically-created, injected media queries. */
+const mediaQueriesForWebkitCompatibility: Set<string> = new Set<string>()
+
+/** Style tag that holds all of the dynamically-created media queries. */
+let mediaQueryStyleNode: HTMLStyleElement | undefined
+
+/**
+ * For Webkit engines that only trigger the MediaQueryListListener when
+ * there is at least one CSS selector for the respective media query.
+ */
+function createEmptyStyleRule(query: string): void {
+  if (mediaQueriesForWebkitCompatibility.has(query)) {
+    return
+  }
+
+  try {
+    if (!mediaQueryStyleNode) {
+      mediaQueryStyleNode = document.createElement('style')
+      mediaQueryStyleNode.setAttribute('type', 'text/css')
+      document.head.appendChild(mediaQueryStyleNode)
+    }
+
+    if (mediaQueryStyleNode.sheet) {
+      ;(mediaQueryStyleNode.sheet as CSSStyleSheet).insertRule(`@media ${query} {.fx-query-test{ }}`, 0)
+      mediaQueriesForWebkitCompatibility.add(query)
+    }
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+/** No-op matchMedia replacement for non-browser platforms. */
+function noopMatchMedia(query: string): MediaQueryList {
+  return ({
+    matches: query === 'all' || query === '',
+    media: query,
+    addEventListener: () => {},
+    removeEventListener: () => {},
+  } as unknown) as MediaQueryList
+}
+
+/**  matchMedia is bound to the window scope intentionally as it is an illegal invocation to call it from a different scope. */
+const _matchMedia = isBrowser && window.matchMedia ? window.matchMedia.bind(window) : noopMatchMedia
+
+/**
+ * A utility for calling matchMedia queries.
+ *
+ * Evaluates the given media query and returns the native MediaQueryList from which results
+ * can be retrieved.
+ * Confirms the layout engine will trigger for the selector query provided and returns the
+ * MediaQueryList for the query provided.
+ */
+export const matchMedia = (query: string): MediaQueryList => {
+  if (isWebKit) {
+    createEmptyStyleRule(query)
+  }
+  return _matchMedia(query)
+}
diff --git a/packages/cdk/breakpoint/src/observe.ts b/packages/cdk/breakpoint/src/observe.ts
new file mode 100644
index 00000000..f456e6ec
--- /dev/null
+++ b/packages/cdk/breakpoint/src/observe.ts
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import type { ComputedRef } from 'vue'
+import { reactive, computed } from 'vue'
+import { matchMedia } from './mediaMatcher'
+
+/** The current state of a layout breakpoint. */
+export interface BreakpointState {
+  /** Whether the breakpoint is currently matching. */
+  matches: boolean
+  /**
+   * A key boolean pair for each query provided to the observe method,
+   * with its current matched state.
+   */
+  breakpoints: Record<string, boolean>
+}
+
+/** The current state of a layout breakpoint. */
+interface InternalBreakpointState {
+  /** Whether the breakpoint is currently matching. */
+  matches: boolean
+  /** The media query being to be matched */
+  query: string
+}
+
+interface Query {
+  state: InternalBreakpointState
+  mql: MediaQueryList
+}
+
+/**  A map of all media queries currently being listened for. */
+const _queries = new Map<string, Query>()
+/** A subject for all other observables to takeUntil based on. */
+const _destroys: Array<() => void> = []
+
+/**
+ * Whether one or more media queries match the current viewport size.
+ * @param value One or more media queries to check.
+ * @returns Whether any of the media queries match.
+ */
+export function isMatchedBreakpoint(value: string | string[]): boolean {
+  return splitQueries(value).some(mediaQuery => _registerQuery(mediaQuery).mql.matches)
+}
+
+/**
+ * Gets an observable of results for the given queries that will emit new results for any changes
+ * in matching of the given queries.
+ * @param value One or more media queries to check.
+ * @returns A stream of matches for the given queries.
+ */
+export function observeBreakpoint(value: string | string[]): ComputedRef<BreakpointState> {
+  const breakpointStates = splitQueries(value).map(query => _registerQuery(query).state)
+
+  return computed(() => {
+    let matches = false
+    const breakpoints: Record<string, boolean> = {}
+
+    breakpointStates.forEach(state => {
+      matches = matches || state.matches
+      breakpoints[state.query] = state.matches
+    })
+    return { matches, breakpoints }
+  })
+}
+
+/** Completes the active subject, signalling to all other observables to complete. */
+export function cleanObservables(): void {
+  _queries.clear()
+  _destroys.forEach(destroy => destroy())
+}
+
+/** Registers a specific query to be listened for. */
+function _registerQuery(query: string): Query {
+  // Only set up a new MediaQueryList if it is not already being listened for.
+  if (_queries.has(query)) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return _queries.get(query)!
+  }
+
+  const mql = matchMedia(query)
+
+  const state = reactive({ query, matches: mql.matches })
+
+  const handler = (e: MediaQueryListEvent) => {
+    state.matches = e.matches
+  }
+  mql.addEventListener('change', handler)
+  _destroys.push(() => mql.removeEventListener('change', handler))
+
+  // Add the MediaQueryList to the set of queries.
+  const output = { state, mql }
+  _queries.set(query, output)
+  return output
+}
+
+/**
+ * Split each query string into separate query strings if two queries are provided as comma separated.
+ */
+function splitQueries(queries: string | string[]): string[] {
+  const _queries = Array.isArray(queries) ? queries : [queries]
+  return _queries
+    .map((query: string) => query.split(','))
+    .reduce((a1: string[], a2: string[]) => a1.concat(a2))
+    .map(query => query.trim())
+}
diff --git a/packages/cdk/platform/demo/basic.md b/packages/cdk/platform/demo/basic.md
new file mode 100644
index 00000000..030eeab8
--- /dev/null
+++ b/packages/cdk/platform/demo/basic.md
@@ -0,0 +1,36 @@
+---
+order: 0
+title:
+  zh: 基本使用
+  en: Basic usage
+---
+
+## zh
+
+平台环境信息
+
+## en
+
+## demo
+
+```html
+<template>
+  <h2>Platform:</h2>
+  <p>Is Browser: {{isBrowser}}</p>
+  <p>Is Blink: {{isBlink}}</p>
+  <p>Is Webkit: {{isWebKit}}</p>
+  <p>Is iOS: {{isIOS}}</p>
+  <p>Is Android: {{isAndroid}}</p>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { isBrowser, isBlink, isWebKit, isIOS, isAndroid } from '@idux/cdk/platform'
+
+export default defineComponent({
+  setup() {
+    return { isBrowser, isBlink, isWebKit, isIOS, isAndroid }
+  },
+})
+</script>
+```
diff --git a/packages/cdk/platform/docs/index.zh.md b/packages/cdk/platform/docs/index.zh.md
new file mode 100644
index 00000000..3ceef37b
--- /dev/null
+++ b/packages/cdk/platform/docs/index.zh.md
@@ -0,0 +1,27 @@
+---
+category: cdk
+type:
+title: Platform
+subtitle: 平台
+cover:
+---
+
+一组用于判断当前浏览器环境的工具
+
+## 何时使用
+
+需要对不同环境做兼容性处理时
+
+## API
+
+| 属性 | 说明 | 类型 |
+| --- | --- | --- |
+| `isBrowser` | 是否为浏览器环境  | `boolean` |
+| `isEdge` | 是否为 `Microsoft Edge` 浏览器  | `boolean` |
+| `isTrident` | 是否为 `Microsoft Trident` 渲染引擎  | `boolean` |
+| `isBlink` | 是否为 `Blink` 渲染引擎  | `boolean` |
+| `isWebKit` | 是否为 `WebKit` 渲染引擎  | `boolean` |
+| `isFirefox` | 是否为 `Firefox` 浏览器  | `boolean` |
+| `isSafari` | 是否为 `Safari` 浏览器  | `boolean` |
+| `isIOS` | 是否为 `IOS` 平台  | `boolean` |
+| `isAndroid` | 是否为 `Android` 平台  | `boolean` |
diff --git a/packages/cdk/platform/index.ts b/packages/cdk/platform/index.ts
new file mode 100644
index 00000000..c1886688
--- /dev/null
+++ b/packages/cdk/platform/index.ts
@@ -0,0 +1,1 @@
+export * from './src/platform'
diff --git a/packages/cdk/platform/src/platform.ts b/packages/cdk/platform/src/platform.ts
new file mode 100755
index 00000000..bbfa2cca
--- /dev/null
+++ b/packages/cdk/platform/src/platform.ts
@@ -0,0 +1,71 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+// Whether the current platform supports the V8 Break Iterator. The V8 check
+// is necessary to detect all Blink based browsers.
+let hasV8BreakIterator: boolean
+
+// We need a try/catch around the reference to `Intl`, because accessing it in some cases can
+// cause IE to throw. These cases are tied to particular versions of Windows and can happen if
+// the consumer is providing a polyfilled `Map`. See:
+// https://github.com/Microsoft/ChakraCore/issues/3189
+// https://github.com/angular/components/issues/15687
+try {
+  hasV8BreakIterator = typeof Intl !== 'undefined' && (Intl as any).v8BreakIterator
+} catch {
+  hasV8BreakIterator = false
+}
+
+const testUserAgent = (regexp: RegExp) => {
+  return regexp.test(navigator.userAgent)
+}
+
+/**
+ * Service to detect the current platform by comparing the userAgent strings and
+ * checking browser-specific global properties.
+ */
+
+/** Whether the application is being rendered in the browser. */
+export const isBrowser = typeof document === 'object' && !!document
+
+/** Whether the current browser is Microsoft Edge. */
+export const isEdge = isBrowser && testUserAgent(/(edge)/i)
+
+/** Whether the current rendering engine is Microsoft Trident. */
+export const isTrident = isBrowser && testUserAgent(/(msie|trident)/i)
+
+// EdgeHTML and Trident mock Blink specific things and need to be excluded from this check.
+/** Whether the current rendering engine is Blink. */
+export const isBlink =
+  isBrowser && !!((window as any).chrome || hasV8BreakIterator) && typeof CSS !== 'undefined' && !isEdge && !isTrident
+
+// Webkit is part of the userAgent in EdgeHTML, Blink and Trident. Therefore we need to
+// ensure that Webkit runs standalone and is not used as another engine's base.
+/** Whether the current rendering engine is WebKit. */
+export const isWebKit = isBrowser && testUserAgent(/AppleWebKit/i) && !isBlink && !isEdge && !isTrident
+
+// It's difficult to detect the plain Gecko engine, because most of the browsers identify
+// them self as Gecko-like browsers and modify the userAgent's according to that.
+// Since we only cover one explicit Firefox case, we can simply check for Firefox
+// instead of having an unstable check for Gecko.
+/** Whether the current browser is Firefox. */
+export const isFirefox = isBrowser && testUserAgent(/(firefox|minefield)/i)
+
+// Safari browsers will include the Safari keyword in their userAgent. Some browsers may fake
+// this and just place the Safari keyword in the userAgent. To be more safe about Safari every
+// Safari browser should also use Webkit as its layout engine.
+/** Whether the current browser is Safari. */
+export const isSafari: boolean = isBrowser && testUserAgent(/safari/i) && isWebKit
+
+/** Whether the current platform is Apple iOS. */
+export const isIOS = isBrowser && testUserAgent(/iPad|iPhone|iPod/) && !('MSStream' in window)
+
+/** Whether the current platform is Android. */
+// Trident on mobile adds the android platform to the userAgent to trick detections.
+export const isAndroid = isBrowser && testUserAgent(/android/i) && !isTrident


Test patch:
diff --git a/packages/cdk/breakpoint/__tests__/mediaMatcher.spec.ts b/packages/cdk/breakpoint/__tests__/mediaMatcher.spec.ts
new file mode 100644
index 00000000..912dda69
--- /dev/null
+++ b/packages/cdk/breakpoint/__tests__/mediaMatcher.spec.ts
@@ -0,0 +1,19 @@
+import { Breakpoints } from '../src/breakpoints'
+import { matchMedia } from '../src/mediaMatcher'
+
+describe('matchMedia.ts', () => {
+  test('matchMedia work', () => {
+    expect(matchMedia(Breakpoints.Small).matches).toBeFalsy()
+  })
+
+  test('createEmptyStyleRule work', () => {
+    const createElementSyn = jest.spyOn(document, 'createElement').mockReturnValue(document.createElement('style'))
+
+    matchMedia(Breakpoints.Small)
+    expect(createElementSyn).toBeCalledTimes(1)
+
+    matchMedia(Breakpoints.Small)
+    matchMedia(Breakpoints.Large)
+    expect(createElementSyn).toBeCalledTimes(1)
+  })
+})
diff --git a/packages/cdk/breakpoint/__tests__/observe.spec.ts b/packages/cdk/breakpoint/__tests__/observe.spec.ts
new file mode 100644
index 00000000..b5dcefa6
--- /dev/null
+++ b/packages/cdk/breakpoint/__tests__/observe.spec.ts
@@ -0,0 +1,24 @@
+import { Breakpoints } from '../src/breakpoints'
+import { isMatchedBreakpoint, observeBreakpoint, cleanObservables } from '../src/observe'
+
+describe('observe.ts', () => {
+  beforeEach(() => {
+    cleanObservables()
+  })
+
+  test('isMatchedBreakpoint work', () => {
+    expect(isMatchedBreakpoint(Breakpoints.Large)).toBeFalsy()
+    expect(isMatchedBreakpoint('all')).toBeTruthy()
+    expect(isMatchedBreakpoint([Breakpoints.Large, 'all'])).toBeTruthy()
+  })
+
+  test('observeBreakpoint work', () => {
+    const largeBreakpoint = observeBreakpoint(Breakpoints.Large)
+    expect(largeBreakpoint.value.matches).toBeFalsy()
+    expect(largeBreakpoint.value.breakpoints[Breakpoints.Large]).toBeFalsy()
+
+    const allBreakpoint = observeBreakpoint(['all'])
+    expect(allBreakpoint.value.matches).toBeTruthy()
+    expect(allBreakpoint.value.breakpoints['all']).toBeTruthy()
+  })
+})